Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 22 additions & 10 deletions app/api/ai/briefing/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { BRIEFING_RESPONSE_LIMITS, parseBriefingResponse } from '@/lib/briefing/
import { fenceUntrustedBlock, UNTRUSTED_CONTENT_RULE } from '@/lib/ai/safety/untrusted';
import { dayKeyInTz, zonedDayBoundsMs, scopeFromUserContext } from '@/lib/services/scope';
import { withAiRequest } from '@/lib/ai/observability';
import { briefingCalendarWindow } from '@/lib/briefing/calendar-window';

function normalizeBriefTimezone(candidate: string): string {
try {
Expand Down Expand Up @@ -95,8 +96,8 @@ export async function POST(req: NextRequest) {
});
const [
{ data: members },
{ data: todayEvents },
{ data: tomorrowEvents },
todayEventsResult,
tomorrowEventsResult,
{ data: choresDue },
{ data: schoolEvents },
{ data: sportsEvents },
Expand All @@ -114,8 +115,8 @@ export async function POST(req: NextRequest) {
{ data: agentActivity },
] = await settleAll([
supabase.from('family_members').select('id, display_name, role').eq('family_id', familyId).eq('is_active', true),
supabase.from('calendar_events').select('title, starts_at, ends_at, location, category, assignee_id').eq('family_id', familyId).gte('starts_at', todayStart).lte('starts_at', todayEnd).order('starts_at'),
supabase.from('calendar_events').select('title, starts_at, category').eq('family_id', familyId).gt('starts_at', todayEnd).lte('starts_at', weekEnd).order('starts_at').limit(8),
supabase.from('calendar_events').select('title, starts_at, ends_at, all_day, location, category, assignee_id').eq('family_id', familyId).or(briefingCalendarWindow(today, tz, 0, 1)).order('starts_at'),
supabase.from('calendar_events').select('title, starts_at, all_day, category').eq('family_id', familyId).or(briefingCalendarWindow(today, tz, 1, 7)).order('starts_at').limit(8),
supabase.from('chore_assignments').select('status, due_at, member_id').eq('family_id', familyId).in('status', ['todo', 'in_progress']).lte('due_at', todayEnd),
supabase.from('school_events').select('title, starts_at, event_type, notes, member_id').eq('family_id', familyId).gte('starts_at', todayStart).lte('starts_at', weekEnd).order('starts_at').limit(10),
supabase.from('sports_events').select('title, starts_at, sport, team, location, member_id').eq('family_id', familyId).gte('starts_at', todayStart).lte('starts_at', weekEnd).order('starts_at').limit(10),
Expand Down Expand Up @@ -144,6 +145,15 @@ export async function POST(req: NextRequest) {
.order('created_at', { ascending: false }).limit(5),
]);

// A failed required calendar read is unavailable, never an empty day. The
// settled result also covers transport rejection before optional AI work.
if (todayEventsResult.error || tomorrowEventsResult.error) {
console.error('[api/ai/briefing] calendar read failed', { today: todayEventsResult.error, upcoming: tomorrowEventsResult.error });
return NextResponse.json({ error: tr('briefing.failedToGenerateBriefing') }, { status: 503 });
}
const todayEvents = todayEventsResult.data;
const tomorrowEvents = tomorrowEventsResult.data;

// A brief that cannot say what is waiting on you must not pretend nothing
// is. The read failed closed; so does the request, and the page shows a
// retryable error instead of a calm morning.
Expand All @@ -160,6 +170,7 @@ export async function POST(req: NextRequest) {
// rendered in the server's zone, which on Vercel is UTC.
const fmt = (iso: string) => new Date(iso).toLocaleTimeString('en-US', { timeZone: tz, hour: 'numeric', minute: '2-digit' });
const fmtDate = (iso: string) => dayKeyInTz(new Date(iso), tz);
const calendarTime = (event: { starts_at: string; all_day: boolean }) => event.all_day ? tr('calendar.allDay') : fmt(event.starts_at);

// ── Build the deterministic cross-domain concierge digest ─────────────────
const medsDueToday = medicationsDueOn((medSchedules ?? []) as unknown as MedicationScheduleRow[], {
Expand All @@ -186,11 +197,11 @@ GENERATING FOR: ${ctx.active.member?.display_name ?? 'family'}
TODAY'S CALENDAR EVENTS (${(todayEvents ?? []).length}):
${(todayEvents ?? []).map(e => {
const assignee = e.assignee_id ? memberMap.get(e.assignee_id)?.display_name : null;
return `- ${fmt(e.starts_at)} ${e.title}${assignee ? ` [${assignee}]` : ''}${e.location ? ` @ ${e.location}` : ''}${e.ends_at ? ` until ${fmt(e.ends_at)}` : ''}`;
return `- ${calendarTime(e)} ${e.title}${assignee ? ` [${assignee}]` : ''}${e.location ? ` @ ${e.location}` : ''}${!e.all_day && e.ends_at ? ` until ${fmt(e.ends_at)}` : ''}`;
}).join('\n') || '- No events today'}

TOMORROW/THIS WEEK EVENTS:
${(tomorrowEvents ?? []).map(e => `- ${fmtDate(e.starts_at)} ${fmt(e.starts_at)} ${e.title}`).join('\n') || '- None'}
${(tomorrowEvents ?? []).map(e => `- ${e.all_day ? e.starts_at.slice(0, 10) : fmtDate(e.starts_at)} ${calendarTime(e)} ${e.title}`).join('\n') || '- None'}

CHORES DUE TODAY (${(choresDue ?? []).length}):
${(choresDue ?? []).map(c => {
Expand Down Expand Up @@ -358,7 +369,7 @@ ${UNTRUSTED_CONTENT_RULE}
? digest.byDomain.map(d => `${d.count} ${d.domain}${d.count > 1 ? 's' : ''} need attention`)
: ['Nothing outstanding — enjoy the open day!'],
schedule: (todayEvents ?? []).map(e => ({
time: fmt(e.starts_at),
time: calendarTime(e),
title: e.title,
member: e.assignee_id ? memberMap.get(e.assignee_id)?.display_name ?? '' : '',
emoji: '📅',
Expand Down Expand Up @@ -442,7 +453,7 @@ ${UNTRUSTED_CONTENT_RULE}
const brief = buildBrief({
kind: type === 'evening' ? 'evening' : 'daily',
now,
events: (todayEvents ?? []).map(e => ({ title: e.title, start: e.starts_at, end: e.ends_at, location: e.location })),
events: (todayEvents ?? []).map(e => ({ title: e.title, start: e.starts_at, end: e.ends_at, allDay: e.all_day, location: e.location })),
snapshot: { ...conciergeSnapshot, now: undefined } as Omit<ConciergeSnapshot, 'now'>,
completedRuns: (completedRuns ?? []) as CompletedRunRow[],
activity: (agentActivity ?? []) as AiActivityRow[],
Expand Down Expand Up @@ -518,7 +529,7 @@ ${UNTRUSTED_CONTENT_RULE}

/** Overlapping events on the family's day, described the way the card renders them. */
function todayConflicts(
events: { title: string; starts_at: string; ends_at: string | null }[],
events: { title: string; starts_at: string; ends_at: string | null; all_day: boolean }[],
tz: string,
): { description: string; suggestion: string }[] {
const at = (iso: string) => new Date(iso).toLocaleTimeString('en-US', { timeZone: tz, hour: 'numeric', minute: '2-digit' });
Expand All @@ -527,11 +538,12 @@ function todayConflicts(
for (let j = i + 1; j < events.length; j += 1) {
const a = events[i];
const b = events[j];
if (a.all_day || b.all_day) continue;
const aStart = Date.parse(a.starts_at);
const bStart = Date.parse(b.starts_at);
const aEnd = a.ends_at ? Date.parse(a.ends_at) : aStart + 3_600_000;
const bEnd = b.ends_at ? Date.parse(b.ends_at) : bStart + 3_600_000;
if (!(aStart < bEnd && bStart < aEnd)) continue;
if (!(aStart < aEnd && bStart < bEnd && aStart < bEnd && bStart < aEnd)) continue;
out.push({
description: `${a.title} and ${b.title} overlap at ${at(new Date(Math.max(aStart, bStart)).toISOString())}`,
suggestion: 'Decide who covers which, or move one.',
Expand Down
62 changes: 44 additions & 18 deletions components/dashboard/ai-home-dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { type ParentApprovalRow, type RenewalRow, type DocumentRow, type Awaitin
import { buildHomeNeeds } from '@/lib/home/needs-build';
import { detectConflicts, type ConflictEvent } from '@/lib/home/conflicts';
import { buildHomeBrief } from '@/lib/home/home-brief';
import { briefingCalendarWindow } from '@/lib/briefing/calendar-window';
import type { DinnerIdea, DinnerEffort } from '@/lib/onboarding/dinner-ideas';
import { HomeOutcomeCard } from '@/components/dashboard/home-outcome-card';
import { buildInsightCandidates, rankInsights, type InsightKind, type InsightSources } from '@/lib/home/insight-of-day';
Expand Down Expand Up @@ -73,6 +74,20 @@ export async function AiHomeDashboard({ ctx }: { ctx: UserContext }) {
const dayBounds = zonedDayBoundsMs(todayKey, tz);
const todayStart = new Date(dayBounds.start);
const todayEnd = new Date(dayBounds.end);
const calendarTime = new Intl.DateTimeFormat(locale.code, { timeZone: tz, hour: 'numeric', minute: '2-digit' });
const calendarDateTime = new Intl.DateTimeFormat(locale.code, { timeZone: tz, month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
const calendarDate = new Intl.DateTimeFormat(locale.code, { timeZone: 'UTC', month: 'short', day: 'numeric' });
const calendarLabel = (at: string | null, upcoming = false, allDay = false) => {
if (!at) return '';
const date = new Date(allDay ? `${at.slice(0, 10)}T12:00:00Z` : at);
if (!Number.isFinite(date.getTime())) return '';
return (allDay ? calendarDate : upcoming ? calendarDateTime : calendarTime).format(date);
};
const calendarNotice = (key: string) => (
<p role="status" className="rounded-2xl border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm text-muted">
{t(key)}{' '}<Link href="/dashboard" className="font-semibold text-brand-text underline focus-ring">{t('root.tryAgain')}</Link>
</p>
);

// Not a { data, error } read, so it sits beside the batch rather than inside
// it. A failure costs the plan tier — defaulting to the free tier — instead
Expand All @@ -84,13 +99,13 @@ export async function AiHomeDashboard({ ctx }: { ctx: UserContext }) {

const [
{ count: pendingChores },
{ data: todayEvents },
todayCalendarRes,
{ count: groceryCount },
{ count: overdueMedsCount },
{ count: pendingApprovals },
{ count: openTodos },
{ data: members },
{ data: upcomingEvents },
upcomingCalendarRes,
{ data: autopilotOpen },
{ count: autopilotHandledCount },
{ count: unreadCommsCount },
Expand All @@ -102,7 +117,7 @@ export async function AiHomeDashboard({ ctx }: { ctx: UserContext }) {
{ data: weekReminderRows },
{ data: approvalRows },
{ data: renewalRows },
{ data: conflictEventRows },
assignedCalendarRes,
{ data: documentRows },
{ data: homeworkRows },
{ count: plannedDinnerCount },
Expand All @@ -113,7 +128,7 @@ export async function AiHomeDashboard({ ctx }: { ctx: UserContext }) {
.select('id, title, starts_at, all_day, location')
.eq('family_id', familyId)
.or(`assignee_id.eq.${myMemberId},assignee_id.is.null`)
.gte('starts_at', todayStart.toISOString()).lt('starts_at', todayEnd.toISOString())
.or(briefingCalendarWindow(todayKey, tz, 0, 1))
.order('starts_at').limit(5),
supabase.from('grocery_items').select('id', { count: 'exact', head: true })
.eq('family_id', familyId).eq('is_checked', false),
Expand All @@ -131,8 +146,7 @@ export async function AiHomeDashboard({ ctx }: { ctx: UserContext }) {
.select('id, title, starts_at, all_day')
.eq('family_id', familyId)
.or(`assignee_id.eq.${myMemberId},assignee_id.is.null`)
.gte('starts_at', todayEnd.toISOString())
.lte('starts_at', new Date(Date.now() + 7 * 86400000).toISOString())
.or(briefingCalendarWindow(todayKey, tz, 1, 7))
.order('starts_at').limit(5),
supabase.from('autopilot_suggestions')
.select('id, title, detail, kind, urgency, confidence')
Expand Down Expand Up @@ -170,11 +184,10 @@ export async function AiHomeDashboard({ ctx }: { ctx: UserContext }) {
supabase.from('renewals').select('id, title, expires_at, reminder_days, status, created_at')
.eq('family_id', familyId).in('status', ['active', 'expired'])
.lte('expires_at', new Date(Date.now() + 45 * 86400000).toISOString()).limit(50),
// Upcoming assigned events (next 14d) → detect personal double-bookings.
// Assigned events through the complete local date +14 → personal double-bookings.
supabase.from('calendar_events').select('id, title, starts_at, ends_at, all_day, assignee_id')
.eq('family_id', familyId).not('assignee_id', 'is', null)
.gte('starts_at', todayStart.toISOString())
.lte('starts_at', new Date(Date.now() + 14 * 86400000).toISOString())
.or(briefingCalendarWindow(todayKey, tz, 0, 15))
.order('starts_at').limit(200),
// Stored documents expiring within ~30 days (passports, licenses, insurance…).
supabase.from('documents').select('id, title, expires_at')
Expand All @@ -191,6 +204,15 @@ export async function AiHomeDashboard({ ctx }: { ctx: UserContext }) {
.gte('plan_date', todayStart.toISOString().slice(0, 10))
.lte('plan_date', new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10)),
]);
for (const [label, result] of [['today calendar', todayCalendarRes], ['upcoming calendar', upcomingCalendarRes], ['assigned calendar', assignedCalendarRes]] as const) {
if (result.error) console.error(`[dashboard-home] ${label} read failed`, result.error);
}
const todayUnavailable = !!todayCalendarRes.error;
const upcomingUnavailable = !!upcomingCalendarRes.error;
const conflictsUnavailable = !!assignedCalendarRes.error;
const todayEvents = todayUnavailable ? [] : (todayCalendarRes.data ?? []);
const upcomingEvents = upcomingUnavailable ? [] : (upcomingCalendarRes.data ?? []);
const conflictEventRows = conflictsUnavailable ? [] : (assignedCalendarRes.data ?? []);

// §16 Command Center reads: what Bubaly is doing, what it finished, what it
// is asking. Family-scoped; every error is captured and logged, and a failed
Expand Down Expand Up @@ -311,7 +333,7 @@ export async function AiHomeDashboard({ ctx }: { ctx: UserContext }) {
recommendations,
});
const needs = topNeeds(homeNeeds, 5);
const needsHeader = needsHeadline(summarizeNeeds(homeNeeds));
const needsHeader = conflictsUnavailable ? t('needsAttention.needsYourAttention') : needsHeadline(summarizeNeeds(homeNeeds));

const name = me.display_name ?? ctx.user.email?.split('@')[0] ?? 'there';
const today = buildToday({
Expand All @@ -337,7 +359,7 @@ export async function AiHomeDashboard({ ctx }: { ctx: UserContext }) {
// next best getting-started steps, and dinner ideas — instead of a bland
// "all caught up" void. Reuses the onboarding first-brief engine so home + the
// first-run screen tell the same story.
const showOutcome = needs.shown.length === 0 && !hasEvents;
const showOutcome = !todayUnavailable && !upcomingUnavailable && !conflictsUnavailable && needs.shown.length === 0 && !hasEvents;
let homeBrief: ReturnType<typeof buildHomeBrief> | null = null;
if (showOutcome) {
const { data: dinnerRows } = await supabase
Expand Down Expand Up @@ -408,6 +430,7 @@ export async function AiHomeDashboard({ ctx }: { ctx: UserContext }) {
.select('id, kind, title, detail, href, impact')
.eq('family_id', familyId).eq('as_of_date', today).eq('status', 'active');
const ranked = rankInsights(((activeRows ?? []) as { id: string; kind: string; title: string; detail: string | null; href: string | null; impact: number }[])
.filter((r) => !conflictsUnavailable || r.kind !== 'conflict')
.map((r) => ({ id: r.id, kind: r.kind as InsightKind, title: r.title, detail: r.detail ?? '', href: r.href ?? '/dashboard', impact: r.impact })));
const top = ranked[0];
if (top) insightRow = { id: top.id, kind: top.kind, title: top.title, detail: top.detail, href: top.href, impact: top.impact, alternatives: Math.max(0, ranked.length - 1) };
Expand Down Expand Up @@ -559,15 +582,16 @@ export async function AiHomeDashboard({ ctx }: { ctx: UserContext }) {
</div>

{/* §16 Needs Your Attention — one ranked queue, decisions made in place */}
<NeedsAttention
{conflictsUnavailable && calendarNotice('homeCalendar.conflictsUnavailable')}
{(!conflictsUnavailable || needs.shown.length > 0) && <NeedsAttention
items={needs.shown}
more={needs.more}
headline={needsHeader}
approvals={Object.fromEntries(aiApprovals.map((a) => [a.id, a]))}
moneyApprovalKinds={Object.fromEntries(((approvalRows ?? []) as ParentApprovalRow[]).map((a) => [a.id, a.kind]))}
recommendationBodies={Object.fromEntries(recommendations.map((r) => [r.id, r.body]))}
canDecide={manager}
/>
/>}

{/* §16 Bubaly Is Working On — live from Realtime after the first paint */}
<WorkingOn familyId={familyId} initial={workingRuns} historyHref="/dashboard/concierge/runs" />
Expand All @@ -581,7 +605,8 @@ export async function AiHomeDashboard({ ctx }: { ctx: UserContext }) {
</div>
<Link href="/dashboard/calendar" className="text-xs font-semibold text-brand-text hover:underline">{t('aiHomeDashboard.viewAll')}</Link>
</div>
{!hasEvents && (
{todayUnavailable && calendarNotice('homeCalendar.todayUnavailable')}
{!todayUnavailable && !hasEvents && (
<p className="rounded-2xl border border-dashed border-border px-4 py-5 text-center text-sm text-muted">{t('aiHomeDashboard.aClearDayNothingScheduledAnd')}</p>
)}
{today.schedule.length > 0 && (
Expand All @@ -594,7 +619,7 @@ export async function AiHomeDashboard({ ctx }: { ctx: UserContext }) {
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{item.title}</p>
<p className="text-xs text-muted">{item.allDay ? 'All day' : fmtTime(item.at)}</p>
<p className="text-xs text-muted">{item.kind === 'event' ? (item.allDay ? t('calendar.allDay') : calendarLabel(item.at)) : fmtTime(item.at)}</p>
</div>
</Link>
</li>
Expand All @@ -620,7 +645,7 @@ export async function AiHomeDashboard({ ctx }: { ctx: UserContext }) {
</div>

{/* Upcoming this week */}
{hasUpcoming && (
{(hasUpcoming || upcomingUnavailable) && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
Expand All @@ -629,6 +654,7 @@ export async function AiHomeDashboard({ ctx }: { ctx: UserContext }) {
</div>
<Link href="/dashboard/calendar" className="text-xs font-semibold text-brand-text hover:underline">{t('aiHomeDashboard.calendar')}</Link>
</div>
{upcomingUnavailable && calendarNotice('homeCalendar.upcomingUnavailable')}
<div className="space-y-1.5">
{upcomingItems.map((item) => (
<Link key={item.key} href={item.kind === 'reminder' ? '/dashboard/reminders' : '/dashboard/calendar'}
Expand All @@ -638,8 +664,8 @@ export async function AiHomeDashboard({ ctx }: { ctx: UserContext }) {
: <Calendar className="h-4 w-4 shrink-0 text-muted" />}
<span className="flex-1 truncate text-sm">{item.title}</span>
<span className="text-xs text-muted">
{item.allDay
? new Date(item.at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
{item.kind === 'event'
? calendarLabel(item.at, true, item.allDay)
: new Date(item.at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}
</span>
</Link>
Expand Down
Loading
Loading