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
56 changes: 35 additions & 21 deletions app/api/ai/briefing/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { NextRequest, NextResponse } from 'next/server';
import { createServer } from '@/lib/supabase/server';
import { settleAll } from '@/lib/supabase/settle';
import { getTranslations } from '@/lib/i18n/server';
import { getLocaleContext } from '@/lib/i18n/server';
import { translate } from '@/lib/i18n/messages';
import type { LocaleCode } from '@/lib/i18n/locales';
import { requireUserContext } from '@/lib/supabase/auth';
import { resolveProvider, isAIConfigured } from '@/lib/ai/provider';
import { buildConciergeDigest, digestToPromptLines, type ConciergeSnapshot } from '@/lib/concierge/digest';
import { formatConciergeDigest } from '@/lib/concierge/digest-display';
import { buildBrief, type BriefNotice } from '@/lib/briefing/build';
import { listUnread } from '@/lib/services/notifications';
import { readBriefDecisions } from '@/lib/briefing/decisions';
Expand All @@ -29,7 +32,8 @@ function normalizeBriefTimezone(candidate: string): string {
}

export async function POST(req: NextRequest) {
const tr = await getTranslations();
const { locale, messages } = await getLocaleContext();
const tr = (key: string, params?: Record<string, string | number>) => translate(messages, key, params);
try {
const ctx = await requireUserContext();
const { familyId } = ctx.active;
Expand Down Expand Up @@ -164,11 +168,11 @@ export async function POST(req: NextRequest) {
const decisions = decisionsRes.data;

const memberMap = new Map((members ?? []).map(m => [m.id, m]));
const firstName = ctx.active.member?.display_name?.split(' ')[0] ?? 'there';
const firstName = ctx.active.member?.display_name?.split(' ')[0] ?? '';

// Times a parent reads at 7am are their times. Without `timeZone` these
// 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 fmt = (iso: string) => new Date(iso).toLocaleTimeString(locale.code, { 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);

Expand All @@ -187,6 +191,7 @@ export async function POST(req: NextRequest) {
pantry: (pantry ?? []).map(p => ({ name: p.name, expiresAt: p.expires_at })),
};
const digest = buildConciergeDigest(conciergeSnapshot);
const digestView = formatConciergeDigest(digest, locale.code, tr);

const context = `
TODAY: ${now.toLocaleDateString('en-US', { timeZone: tz, weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })} at ${now.toLocaleTimeString('en-US', { timeZone: tz, hour: 'numeric', minute: '2-digit' })}
Expand Down Expand Up @@ -244,6 +249,9 @@ ${digestToPromptLines(digest)}
`.trim();

const systemPrompt = `You are the Bubaly AI Chief of Staff. Generate a ${type} family briefing as structured JSON.
Output locale: ${locale.code}. Use this locale for all generated user-facing prose and date/time labels. The family's timezone is ${tz}.
Use formal adult address: German Sie, French vous, and formal European Portuguese. Preserve family/user names, original event titles, locations, meal names, and quoted source content verbatim.
Keep JSON property names and protocol values unchanged: color, urgency and stressLevel enums remain exactly as specified. Meal status is "planned" or "not planned yet"; localize the visible meal label, not status.

IMPORTANT: Return ONLY valid JSON. No markdown, no code blocks, no explanation. Start with { and end with }.

Expand Down Expand Up @@ -298,15 +306,15 @@ ${UNTRUSTED_CONTENT_RULE}
// The concierge digest is the deterministic backbone: reminders and
// outstanding items are derived from real data so the briefing answers
// "what does my family need to do today?" even when AI is unconfigured.
const digestReminders = digest.items.map(i => ({
const digestReminders = digestView.items.map(i => ({
text: `${i.title} — ${i.detail}`,
urgency: (i.urgency === 'soon' ? 'medium' : 'high') as 'high' | 'medium' | 'low',
}));
const subtitle = now.toLocaleDateString('en-US', { timeZone: tz, weekday: 'long', month: 'long', day: 'numeric' });
const subtitle = now.toLocaleDateString(locale.code, { timeZone: tz, weekday: 'long', month: 'long', day: 'numeric' });

// Built from rows already read, so the deterministic path says as much as
// it honestly can rather than defaulting to "nothing".
const fallbackConflicts = todayConflicts(todayEvents ?? [], tz).slice(0, 6);
const fallbackConflicts = todayConflicts(todayEvents ?? [], tz, locale.code, tr).slice(0, 6);
const fallbackKidsNeeds = kidsNeedsFrom(
(schoolEvents ?? []) as { title: string; starts_at: string; member_id: string | null }[],
(sportsEvents ?? []) as { title: string; starts_at: string; member_id: string | null }[],
Expand All @@ -318,7 +326,8 @@ ${UNTRUSTED_CONTENT_RULE}
.map((m) => m as unknown as { plan_date: string; meal_type: string; meals: { name: string } | null })
.filter((m) => m.plan_date === today)
.slice(0, 6)
.map((m) => ({ meal: m.meal_type, name: m.meals?.name ?? null, status: m.meals?.name ? 'planned' : 'not planned yet' }));
.map((m) => ({ meal: ['breakfast', 'lunch', 'dinner', 'snack'].includes(m.meal_type) ? tr(`briefingGenerated.meal.${m.meal_type}`) : m.meal_type,
name: m.meals?.name ?? null, status: m.meals?.name ? 'planned' : 'not planned yet' }));

let briefing: Record<string, unknown> | null = null;

Expand Down Expand Up @@ -363,11 +372,11 @@ ${UNTRUSTED_CONTENT_RULE}
// Deterministic concierge briefing — used when AI is off or returns junk.
if (!briefing) {
briefing = {
greeting: `Good ${type === 'evening' ? 'evening' : 'morning'}, ${firstName}!`,
greeting: tr(`briefingGenerated.${type === 'evening' ? 'evening' : 'morning'}${firstName ? 'Greeting' : 'GreetingNameless'}`, { name: firstName }),
subtitle,
familySummary: digest.byDomain.length
? digest.byDomain.map(d => `${d.count} ${d.domain}${d.count > 1 ? 's' : ''} need attention`)
: ['Nothing outstanding — enjoy the open day!'],
? digest.byDomain.map(d => tr(`briefingGenerated.domain.${d.domain}.${d.count === 1 ? 'one' : 'other'}`, { count: d.count }))
: [tr('briefingGenerated.emptySummary')],
schedule: (todayEvents ?? []).map(e => ({
time: calendarTime(e),
title: e.title,
Expand All @@ -388,10 +397,10 @@ ${UNTRUSTED_CONTENT_RULE}
overall: digest.counts.overdue > 0 ? 60 : digest.counts.today > 3 ? 75 : 90,
categories: [],
stressLevel: (digest.counts.overdue > 0 ? 'high' : digest.counts.today > 3 ? 'moderate' : 'low') as 'low' | 'moderate' | 'high',
stressReason: digest.counts.total ? digest.headline : null,
recommendation: digest.items[0]
? `Start with: ${digest.items[0].title} — ${digest.items[0].detail}.`
: 'You are all caught up. Have a great day!',
stressReason: digest.counts.total ? digestView.headline : null,
recommendation: digestView.items[0]
? tr('briefingGenerated.startWith', { title: digestView.items[0].title, detail: digestView.items[0].detail })
: tr('briefingGenerated.emptyRecommendation'),
},
outstanding: digestReminders.filter(r => r.urgency === 'high'),
};
Expand Down Expand Up @@ -477,13 +486,16 @@ ${UNTRUSTED_CONTENT_RULE}
if (markError) console.error('[api/ai/briefing] mark folded notifications read failed', markError);
}

const completedRunByKey = new Map((completedRuns ?? []).map(run => [`run:${run.id}`, run]));
briefing = {
...briefing,
// A partly-finished run says so. Without this a run that did six of eight
// things reads exactly like one that did all eight (§29).
completed: brief.handled.map(h => {
if (!h.partial) return h.detail ? `${h.title} — ${h.detail}` : h.title;
return h.detail ? `${h.title} — ${h.detail}` : `${h.title} — partly done`;
const run = h.kind === 'run' ? completedRunByKey.get(h.key) : undefined;
const title = run && !run.summary?.trim() ? tr('briefingGenerated.familyRequest') : h.title;
if (h.detail) return `${title} — ${h.detail}`;
return h.partial ? tr('briefingGenerated.partlyDone', { title }) : title;
}),
};

Expand All @@ -506,7 +518,7 @@ ${UNTRUSTED_CONTENT_RULE}
// list is read from the notifications table, not written by a model.
return NextResponse.json({
briefing,
digest,
digest: digestView,
alsoToday: brief.alsoToday,
alsoTodayUnavailable,
generatedAt: new Date().toISOString(),
Expand All @@ -531,8 +543,10 @@ ${UNTRUSTED_CONTENT_RULE}
function todayConflicts(
events: { title: string; starts_at: string; ends_at: string | null; all_day: boolean }[],
tz: string,
locale: LocaleCode,
tr: (key: string, params?: Record<string, string | number>) => string,
): { description: string; suggestion: string }[] {
const at = (iso: string) => new Date(iso).toLocaleTimeString('en-US', { timeZone: tz, hour: 'numeric', minute: '2-digit' });
const at = (iso: string) => new Date(iso).toLocaleTimeString(locale, { timeZone: tz, hour: 'numeric', minute: '2-digit' });
const out: { description: string; suggestion: string }[] = [];
for (let i = 0; i < events.length; i += 1) {
for (let j = i + 1; j < events.length; j += 1) {
Expand All @@ -545,8 +559,8 @@ function todayConflicts(
const bEnd = b.ends_at ? Date.parse(b.ends_at) : bStart + 3_600_000;
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.',
description: tr('briefingGenerated.conflict', { first: a.title, second: b.title, time: at(new Date(Math.max(aStart, bStart)).toISOString()) }),
suggestion: tr('briefingGenerated.conflictSuggestion'),
});
}
}
Expand Down
63 changes: 63 additions & 0 deletions lib/concierge/digest-display.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { z } from 'zod';
import type { LocaleCode } from '@/lib/i18n/locales';
import type { ConciergeDigest, ConciergeItem, ConciergeItemDisplay } from './digest';
import { canonicalConciergeText } from './digest';

type Translator = (key: string, params?: Record<string, string | number>) => string;
const base = { version: z.literal(1), name: z.string(), dayOffset: z.number().int().safe() };
const displaySchema = z.discriminatedUnion('kind', [
z.object({ ...base, kind: z.literal('bill'), amount: z.number().finite().nullable() }).strict(),
z.object({ ...base, kind: z.literal('medication'), member: z.string().nullable(), timeOfDay: z.string().nullable() }).strict(),
z.object({ ...base, kind: z.literal('maintenance') }).strict(),
z.object({ ...base, kind: z.literal('pantry') }).strict(),
z.object({ ...base, kind: z.literal('warranty') }).strict(),
z.object({ ...base, kind: z.literal('trip'), phase: z.enum(['ongoing', 'departure']), destination: z.string().nullable() }).strict(),
]);

function displayFor(item: ConciergeItem): ConciergeItemDisplay | null {
const parsed = displaySchema.safeParse(item.display);
if (!parsed.success) return null;
const facts = parsed.data;
if (facts.kind !== item.domain || facts.dayOffset !== item.dayOffset) return null;
if (item.urgency !== (item.dayOffset < 0 ? 'overdue' : item.dayOffset === 0 ? 'today' : 'soon')) return null;
const source = canonicalConciergeText(facts);
if (source.title !== item.title || source.detail !== item.detail || source.dueLabel !== item.dueLabel || source.member !== item.member) return null;
if ((facts.kind === 'medication' || (facts.kind === 'trip' && facts.phase === 'ongoing'))
&& (item.dayOffset !== 0 || item.urgency !== 'today')) return null;
return facts;
}

/** A request-local view only. Never changes the canonical digest or returns metadata. */
export function formatConciergeDigest(digest: ConciergeDigest, locale: LocaleCode, t: Translator): ConciergeDigest {
const relative = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
const usd = new Intl.NumberFormat(locale, { style: 'currency', currency: 'USD' });
const items = digest.items.map((item): ConciergeItem => {
const facts = displayFor(item);
const { display: _display, ...legacy } = item;
if (!facts) return legacy;
const when = relative.format(item.dayOffset, 'day');
let title = item.title;
let detail: string;
let dueLabel = when;
switch (facts.kind) {
case 'bill': detail = facts.amount === null ? t('conciergeDisplay.due', { when }) : t('conciergeDisplay.amountDue', { amount: usd.format(facts.amount), when }); break;
case 'medication': detail = [facts.member, facts.timeOfDay].filter(Boolean).join(' · ') || t('conciergeDisplay.scheduledToday'); break;
case 'maintenance': detail = t('conciergeDisplay.due', { when }); break;
case 'warranty': title = t('conciergeDisplay.warrantyTitle', { name: facts.name });
detail = t(item.dayOffset < 0 ? 'conciergeDisplay.expired' : 'conciergeDisplay.expires', { when }); break;
case 'pantry': detail = t(item.dayOffset < 0 ? 'conciergeDisplay.expired' : 'conciergeDisplay.expires', { when }); break;
case 'trip':
detail = facts.phase === 'ongoing' ? t('conciergeDisplay.inProgress') : t('conciergeDisplay.departs', { when });
if (facts.phase === 'ongoing') dueLabel = detail;
if (facts.destination) detail = t('conciergeDisplay.destination', { detail, destination: facts.destination });
break;
}
return { ...legacy, title, detail, dueLabel };
});
const parts: string[] = [];
if (digest.counts.overdue) parts.push(t('conciergeDisplay.overdueCount', { count: digest.counts.overdue }));
if (digest.counts.today) parts.push(t('conciergeDisplay.todayCount', { count: digest.counts.today }));
if (digest.counts.soon) parts.push(t('conciergeDisplay.soonCount', { count: digest.counts.soon }));
const headline = parts.length ? t('conciergeDisplay.headline', { parts: parts.join(', ') }) : t('conciergeDisplay.empty');
return { items, counts: { ...digest.counts }, byDomain: digest.byDomain.map(entry => ({ ...entry })), headline };
}
Loading
Loading