From 863a58a80057e7a3090e5c40bbbf8aca61532c53 Mon Sep 17 00:00:00 2001 From: NewWorldVenture Date: Thu, 10 Sep 2026 08:08:57 -0400 Subject: [PATCH 1/2] Localize generated briefing API presentation from request locale --- app/api/ai/briefing/route.ts | 56 +++++---- lib/concierge/digest-display.ts | 63 ++++++++++ lib/concierge/digest.ts | 78 ++++++++----- lib/i18n/messages/de-DE.json | 43 ++++++- lib/i18n/messages/en-US.json | 43 ++++++- lib/i18n/messages/es-ES.json | 43 ++++++- lib/i18n/messages/fr-FR.json | 43 ++++++- lib/i18n/messages/it-IT.json | 43 ++++++- lib/i18n/messages/nl-NL.json | 43 ++++++- lib/i18n/messages/pt-PT.json | 43 ++++++- tests/api-ai-briefing-schema.test.ts | 21 ++-- tests/briefing-api-localization.test.ts | 148 ++++++++++++++++++++++++ tests/briefing-calendar-days.test.ts | 2 +- tests/concierge-digest-display.test.ts | 98 ++++++++++++++++ 14 files changed, 700 insertions(+), 67 deletions(-) create mode 100644 lib/concierge/digest-display.ts create mode 100644 tests/briefing-api-localization.test.ts create mode 100644 tests/concierge-digest-display.test.ts diff --git a/app/api/ai/briefing/route.ts b/app/api/ai/briefing/route.ts index 999db2719..f027e154f 100644 --- a/app/api/ai/briefing/route.ts +++ b/app/api/ai/briefing/route.ts @@ -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'; @@ -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) => translate(messages, key, params); try { const ctx = await requireUserContext(); const { familyId } = ctx.active; @@ -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); @@ -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' })} @@ -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 }. @@ -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 }[], @@ -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 | null = null; @@ -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, @@ -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'), }; @@ -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; }), }; @@ -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(), @@ -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, ): { 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) { @@ -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'), }); } } diff --git a/lib/concierge/digest-display.ts b/lib/concierge/digest-display.ts new file mode 100644 index 000000000..9ea1ab4a4 --- /dev/null +++ b/lib/concierge/digest-display.ts @@ -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; +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 }; +} diff --git a/lib/concierge/digest.ts b/lib/concierge/digest.ts index 8286bf5f9..26423b756 100644 --- a/lib/concierge/digest.ts +++ b/lib/concierge/digest.ts @@ -22,6 +22,14 @@ export type ConciergeDomain = export type ConciergeUrgency = 'overdue' | 'today' | 'soon'; +type DisplayBase = { version: 1; name: string; dayOffset: number }; +export type ConciergeItemDisplay = DisplayBase & ( + | { kind: 'bill'; amount: number | null } + | { kind: 'medication'; member: string | null; timeOfDay: string | null } + | { kind: 'maintenance' | 'warranty' | 'pantry' } + | { kind: 'trip'; phase: 'ongoing' | 'departure'; destination: string | null } +); + export interface ConciergeItem { domain: ConciergeDomain; urgency: ConciergeUrgency; @@ -32,6 +40,8 @@ export interface ConciergeItem { dueLabel: string; /** Day offset from `now` (negative = past). Used for stable ordering. */ dayOffset: number; + /** Optional presentation facts; legacy prose and canonical ordering stay unchanged. */ + display?: ConciergeItemDisplay; } export interface ConciergeSnapshot { @@ -103,6 +113,25 @@ function money(amount: number): string { return `$${amount.toFixed(2)}`; } +/** Canonical source text, also used to reject stale presentation facts without parsing prose. */ +export function canonicalConciergeText(facts: ConciergeItemDisplay): Pick { + const label = dueLabelFor(facts.dayOffset); + switch (facts.kind) { + case 'bill': return { title: facts.name, detail: facts.amount !== null ? `${money(facts.amount)} due ${label}` : `Due ${label}`, dueLabel: label }; + case 'medication': { + const parts = [facts.member, facts.timeOfDay].filter(Boolean); + return { title: facts.name, detail: parts.length ? parts.join(' · ') : 'Scheduled today', member: facts.member ?? undefined, dueLabel: 'today' }; + } + case 'maintenance': return { title: facts.name, detail: `Due ${label}`, dueLabel: label }; + case 'pantry': return { title: facts.name, detail: facts.dayOffset < 0 ? `Expired ${label}` : `Expires ${label}`, dueLabel: label }; + case 'warranty': return { title: `${facts.name} warranty`, detail: facts.dayOffset < 0 ? `Expired ${label}` : `Expires ${label}`, dueLabel: label }; + case 'trip': { + const detail = facts.phase === 'ongoing' ? 'In progress' : `Departs ${label}`; + return { title: facts.name, detail: facts.destination ? `${detail} · ${facts.destination}` : detail, dueLabel: facts.phase === 'ongoing' ? 'in progress' : label }; + } + } +} + /** * Build the prioritized cross-domain digest from a raw snapshot. Items are * sorted overdue → today → soon, then by soonest deadline, then by a stable @@ -119,28 +148,25 @@ export function buildConciergeDigest(snapshot: ConciergeSnapshot): ConciergeDige if (off === null) continue; const urgency = classify(off, SOON_WINDOW_DAYS.bill); if (!urgency) continue; - const label = dueLabelFor(off); + const display: ConciergeItemDisplay = { version: 1, kind: 'bill', name: b.name, amount: b.amount ?? null, dayOffset: off }; items.push({ domain: 'bill', urgency, - title: b.name, - detail: b.amount != null ? `${money(b.amount)} due ${label}` : `Due ${label}`, - dueLabel: label, + ...canonicalConciergeText(display), dayOffset: off, + display, }); } // ── Medications scheduled today ─────────────────────────────────────────── for (const m of snapshot.medications ?? []) { - const parts = [m.member, m.timeOfDay].filter(Boolean) as string[]; + const display: ConciergeItemDisplay = { version: 1, kind: 'medication', name: m.name, member: m.member ?? null, timeOfDay: m.timeOfDay ?? null, dayOffset: 0 }; items.push({ domain: 'medication', urgency: 'today', - title: m.name, - detail: parts.length ? parts.join(' · ') : 'Scheduled today', - member: m.member ?? undefined, - dueLabel: 'today', + ...canonicalConciergeText(display), dayOffset: 0, + display, }); } @@ -151,14 +177,13 @@ export function buildConciergeDigest(snapshot: ConciergeSnapshot): ConciergeDige if (off === null) continue; const urgency = classify(off, SOON_WINDOW_DAYS.maintenance); if (!urgency) continue; - const label = dueLabelFor(off); + const display: ConciergeItemDisplay = { version: 1, kind: 'maintenance', name: t.title, dayOffset: off }; items.push({ domain: 'maintenance', urgency, - title: t.title, - detail: `Due ${label}`, - dueLabel: label, + ...canonicalConciergeText(display), dayOffset: off, + display, }); } @@ -169,14 +194,13 @@ export function buildConciergeDigest(snapshot: ConciergeSnapshot): ConciergeDige if (off === null) continue; const urgency = classify(off, SOON_WINDOW_DAYS.pantry); if (!urgency) continue; - const label = dueLabelFor(off); + const display: ConciergeItemDisplay = { version: 1, kind: 'pantry', name: p.name, dayOffset: off }; items.push({ domain: 'pantry', urgency, - title: p.name, - detail: off < 0 ? `Expired ${label}` : `Expires ${label}`, - dueLabel: label, + ...canonicalConciergeText(display), dayOffset: off, + display, }); } @@ -188,26 +212,25 @@ export function buildConciergeDigest(snapshot: ConciergeSnapshot): ConciergeDige const endOff = tr.endDate ? dayOffset(tr.endDate, now) : startOff; // In progress: started but not yet ended. if (startOff <= 0 && endOff !== null && endOff >= 0) { + const display: ConciergeItemDisplay = { version: 1, kind: 'trip', phase: 'ongoing', name: tr.title, destination: tr.destination ?? null, dayOffset: 0 }; items.push({ domain: 'trip', urgency: 'today', - title: tr.title, - detail: tr.destination ? `In progress · ${tr.destination}` : 'In progress', - dueLabel: 'in progress', + ...canonicalConciergeText(display), dayOffset: 0, + display, }); continue; } const urgency = classify(startOff, SOON_WINDOW_DAYS.trip); if (!urgency) continue; - const label = dueLabelFor(startOff); + const display: ConciergeItemDisplay = { version: 1, kind: 'trip', phase: 'departure', name: tr.title, destination: tr.destination ?? null, dayOffset: startOff }; items.push({ domain: 'trip', urgency, - title: tr.title, - detail: tr.destination ? `Departs ${label} · ${tr.destination}` : `Departs ${label}`, - dueLabel: label, + ...canonicalConciergeText(display), dayOffset: startOff, + display, }); } @@ -218,14 +241,13 @@ export function buildConciergeDigest(snapshot: ConciergeSnapshot): ConciergeDige if (off === null) continue; const urgency = classify(off, SOON_WINDOW_DAYS.warranty); if (!urgency) continue; - const label = dueLabelFor(off); + const display: ConciergeItemDisplay = { version: 1, kind: 'warranty', name: w.name, dayOffset: off }; items.push({ domain: 'warranty', urgency, - title: `${w.name} warranty`, - detail: off < 0 ? `Expired ${label}` : `Expires ${label}`, - dueLabel: label, + ...canonicalConciergeText(display), dayOffset: off, + display, }); } diff --git a/lib/i18n/messages/de-DE.json b/lib/i18n/messages/de-DE.json index a7780e073..e337288a8 100644 --- a/lib/i18n/messages/de-DE.json +++ b/lib/i18n/messages/de-DE.json @@ -12751,5 +12751,46 @@ "onboardingCopy.dashboardReady": "Ihre Übersicht ist auf Sie abgestimmt und bereit zum Erkunden.", "onboardingCopy.kidLogin": "Richten Sie einen Zugang für {names} ein — mit Benutzername und PIN, ohne E-Mail-Adresse", "onboardingCopy.primaryCalendarPreview": "Vorschau aus Ihrem Hauptkalender: die nächsten 30 Tage. Abschließen importiert diese Termine. Spätere Abrufe ergänzen anstehende Termine und übernehmen gemeldete Änderungen; nicht mehr gelieferte Termine bleiben erhalten. Kalender und Aufgaben beim Anbieter bleiben unverändert.", - "onboardingWizard.invalidPreviewTimezone": "Wählen Sie eine gültige Zeitzone." + "onboardingWizard.invalidPreviewTimezone": "Wählen Sie eine gültige Zeitzone.", + "briefingGenerated.morningGreeting": "Guten Morgen, {name}!", + "briefingGenerated.eveningGreeting": "Guten Abend, {name}!", + "briefingGenerated.morningGreetingNameless": "Guten Morgen!", + "briefingGenerated.eveningGreetingNameless": "Guten Abend!", + "briefingGenerated.emptySummary": "Nichts steht aus — genießen Sie den freien Tag!", + "briefingGenerated.emptyRecommendation": "Alles ist erledigt. Einen schönen Tag!", + "briefingGenerated.startWith": "Beginnen Sie mit: {title} — {detail}.", + "briefingGenerated.familyRequest": "Eine Anfrage Ihrer Familie", + "briefingGenerated.partlyDone": "{title} — teilweise erledigt", + "briefingGenerated.conflict": "{first} und {second} überschneiden sich um {time}", + "briefingGenerated.conflictSuggestion": "Entscheiden Sie, wer welchen Termin übernimmt, oder verschieben Sie einen.", + "briefingGenerated.meal.breakfast": "Frühstück", + "briefingGenerated.meal.lunch": "Mittagessen", + "briefingGenerated.meal.dinner": "Abendessen", + "briefingGenerated.meal.snack": "Zwischenmahlzeit", + "conciergeDisplay.due": "Fällig {when}", + "conciergeDisplay.amountDue": "{amount}, fällig {when}", + "conciergeDisplay.scheduledToday": "Für heute geplant", + "conciergeDisplay.warrantyTitle": "Garantie für {name}", + "conciergeDisplay.expired": "Abgelaufen {when}", + "conciergeDisplay.expires": "Läuft {when} ab", + "conciergeDisplay.inProgress": "Läuft gerade", + "conciergeDisplay.departs": "Abreise {when}", + "conciergeDisplay.destination": "{detail} · {destination}", + "conciergeDisplay.overdueCount": "{count} überfällig", + "conciergeDisplay.todayCount": "{count} heute fällig", + "conciergeDisplay.soonCount": "{count} demnächst", + "conciergeDisplay.headline": "{parts}.", + "conciergeDisplay.empty": "Alles ist erledigt — derzeit erfordert nichts Ihre Aufmerksamkeit.", + "briefingGenerated.domain.bill.one": "{count} Rechnung erfordert Ihre Aufmerksamkeit", + "briefingGenerated.domain.bill.other": "{count} Rechnungen erfordern Ihre Aufmerksamkeit", + "briefingGenerated.domain.medication.one": "{count} Medikament erfordert Ihre Aufmerksamkeit", + "briefingGenerated.domain.medication.other": "{count} Medikamente erfordern Ihre Aufmerksamkeit", + "briefingGenerated.domain.maintenance.one": "{count} Wartungsaufgabe erfordert Ihre Aufmerksamkeit", + "briefingGenerated.domain.maintenance.other": "{count} Wartungsaufgaben erfordern Ihre Aufmerksamkeit", + "briefingGenerated.domain.warranty.one": "{count} Garantie erfordert Ihre Aufmerksamkeit", + "briefingGenerated.domain.warranty.other": "{count} Garantien erfordern Ihre Aufmerksamkeit", + "briefingGenerated.domain.trip.one": "{count} Reise erfordert Ihre Aufmerksamkeit", + "briefingGenerated.domain.trip.other": "{count} Reisen erfordern Ihre Aufmerksamkeit", + "briefingGenerated.domain.pantry.one": "{count} Vorratsartikel erfordert Ihre Aufmerksamkeit", + "briefingGenerated.domain.pantry.other": "{count} Vorratsartikel erfordern Ihre Aufmerksamkeit" } diff --git a/lib/i18n/messages/en-US.json b/lib/i18n/messages/en-US.json index 472c9703d..403db3119 100644 --- a/lib/i18n/messages/en-US.json +++ b/lib/i18n/messages/en-US.json @@ -12751,5 +12751,46 @@ "onboardingCopy.dashboardReady": "Your dashboard is personalized and ready to explore.", "onboardingCopy.kidLogin": "Give {names} a login — username and PIN, no email needed", "onboardingCopy.primaryCalendarPreview": "Preview from your primary calendar: the next 30 days. Finish imports these events. Future reads add upcoming appointments and apply reported changes; events missing from a read are kept. Your provider calendar and tasks stay unchanged.", - "onboardingWizard.invalidPreviewTimezone": "Choose a valid time zone." + "onboardingWizard.invalidPreviewTimezone": "Choose a valid time zone.", + "briefingGenerated.morningGreeting": "Good morning, {name}!", + "briefingGenerated.eveningGreeting": "Good evening, {name}!", + "briefingGenerated.morningGreetingNameless": "Good morning!", + "briefingGenerated.eveningGreetingNameless": "Good evening!", + "briefingGenerated.emptySummary": "Nothing outstanding — enjoy the open day!", + "briefingGenerated.emptyRecommendation": "You are all caught up. Have a great day!", + "briefingGenerated.startWith": "Start with: {title} — {detail}.", + "briefingGenerated.familyRequest": "A request from your family", + "briefingGenerated.partlyDone": "{title} — partly done", + "briefingGenerated.conflict": "{first} and {second} overlap at {time}", + "briefingGenerated.conflictSuggestion": "Decide who covers which, or move one.", + "briefingGenerated.meal.breakfast": "Breakfast", + "briefingGenerated.meal.lunch": "Lunch", + "briefingGenerated.meal.dinner": "Dinner", + "briefingGenerated.meal.snack": "Snack", + "conciergeDisplay.due": "Due {when}", + "conciergeDisplay.amountDue": "{amount} due {when}", + "conciergeDisplay.scheduledToday": "Scheduled today", + "conciergeDisplay.warrantyTitle": "{name} warranty", + "conciergeDisplay.expired": "Expired {when}", + "conciergeDisplay.expires": "Expires {when}", + "conciergeDisplay.inProgress": "In progress", + "conciergeDisplay.departs": "Departs {when}", + "conciergeDisplay.destination": "{detail} · {destination}", + "conciergeDisplay.overdueCount": "{count} overdue", + "conciergeDisplay.todayCount": "{count} due today", + "conciergeDisplay.soonCount": "{count} coming up", + "conciergeDisplay.headline": "{parts}.", + "conciergeDisplay.empty": "You're all caught up — nothing needs attention right now.", + "briefingGenerated.domain.bill.one": "{count} bill needs attention", + "briefingGenerated.domain.bill.other": "{count} bills need attention", + "briefingGenerated.domain.medication.one": "{count} medication needs attention", + "briefingGenerated.domain.medication.other": "{count} medications need attention", + "briefingGenerated.domain.maintenance.one": "{count} maintenance task needs attention", + "briefingGenerated.domain.maintenance.other": "{count} maintenance tasks need attention", + "briefingGenerated.domain.warranty.one": "{count} warranty needs attention", + "briefingGenerated.domain.warranty.other": "{count} warranties need attention", + "briefingGenerated.domain.trip.one": "{count} trip needs attention", + "briefingGenerated.domain.trip.other": "{count} trips need attention", + "briefingGenerated.domain.pantry.one": "{count} pantry item needs attention", + "briefingGenerated.domain.pantry.other": "{count} pantry items need attention" } diff --git a/lib/i18n/messages/es-ES.json b/lib/i18n/messages/es-ES.json index 327eae93b..c087d3ceb 100644 --- a/lib/i18n/messages/es-ES.json +++ b/lib/i18n/messages/es-ES.json @@ -12751,5 +12751,46 @@ "onboardingCopy.dashboardReady": "Tu panel está personalizado y listo para explorar.", "onboardingCopy.kidLogin": "Crea un acceso para {names}: nombre de usuario y PIN, sin necesidad de correo", "onboardingCopy.primaryCalendarPreview": "Vista previa de tu calendario principal: los próximos 30 días. Al finalizar se importan estos eventos. Las lecturas posteriores añaden próximas citas y aplican los cambios notificados; se conservan los eventos que no aparecen en una lectura. Tu calendario y tus tareas en el proveedor no cambian.", - "onboardingWizard.invalidPreviewTimezone": "Elige una zona horaria válida." + "onboardingWizard.invalidPreviewTimezone": "Elige una zona horaria válida.", + "briefingGenerated.morningGreeting": "¡Buenos días, {name}!", + "briefingGenerated.eveningGreeting": "¡Buenas tardes, {name}!", + "briefingGenerated.morningGreetingNameless": "¡Buenos días!", + "briefingGenerated.eveningGreetingNameless": "¡Buenas tardes!", + "briefingGenerated.emptySummary": "No hay nada pendiente. ¡Disfrute del día libre!", + "briefingGenerated.emptyRecommendation": "Todo está al día. ¡Que tenga un buen día!", + "briefingGenerated.startWith": "Empiece por: {title} — {detail}.", + "briefingGenerated.familyRequest": "Una petición de su familia", + "briefingGenerated.partlyDone": "{title} — completado parcialmente", + "briefingGenerated.conflict": "{first} y {second} coinciden a las {time}", + "briefingGenerated.conflictSuggestion": "Decida quién se encarga de cada evento o cambie uno de horario.", + "briefingGenerated.meal.breakfast": "Desayuno", + "briefingGenerated.meal.lunch": "Almuerzo", + "briefingGenerated.meal.dinner": "Cena", + "briefingGenerated.meal.snack": "Merienda", + "conciergeDisplay.due": "Vence {when}", + "conciergeDisplay.amountDue": "{amount}, vence {when}", + "conciergeDisplay.scheduledToday": "Programado para hoy", + "conciergeDisplay.warrantyTitle": "Garantía de {name}", + "conciergeDisplay.expired": "Caducó {when}", + "conciergeDisplay.expires": "Caduca {when}", + "conciergeDisplay.inProgress": "En curso", + "conciergeDisplay.departs": "Salida {when}", + "conciergeDisplay.destination": "{detail} · {destination}", + "conciergeDisplay.overdueCount": "{count} vencidos", + "conciergeDisplay.todayCount": "{count} para hoy", + "conciergeDisplay.soonCount": "{count} próximos", + "conciergeDisplay.headline": "{parts}.", + "conciergeDisplay.empty": "Todo está al día. Nada requiere atención ahora.", + "briefingGenerated.domain.bill.one": "{count} factura requiere atención", + "briefingGenerated.domain.bill.other": "{count} facturas requieren atención", + "briefingGenerated.domain.medication.one": "{count} medicamento requiere atención", + "briefingGenerated.domain.medication.other": "{count} medicamentos requieren atención", + "briefingGenerated.domain.maintenance.one": "{count} tarea de mantenimiento requiere atención", + "briefingGenerated.domain.maintenance.other": "{count} tareas de mantenimiento requieren atención", + "briefingGenerated.domain.warranty.one": "{count} garantía requiere atención", + "briefingGenerated.domain.warranty.other": "{count} garantías requieren atención", + "briefingGenerated.domain.trip.one": "{count} viaje requiere atención", + "briefingGenerated.domain.trip.other": "{count} viajes requieren atención", + "briefingGenerated.domain.pantry.one": "{count} producto de la despensa requiere atención", + "briefingGenerated.domain.pantry.other": "{count} productos de la despensa requieren atención" } diff --git a/lib/i18n/messages/fr-FR.json b/lib/i18n/messages/fr-FR.json index a9dc69fd6..cfe589fde 100644 --- a/lib/i18n/messages/fr-FR.json +++ b/lib/i18n/messages/fr-FR.json @@ -12751,5 +12751,46 @@ "onboardingCopy.dashboardReady": "Votre tableau de bord est personnalisé et prêt à être exploré.", "onboardingCopy.kidLogin": "Créez un accès pour {names} — un identifiant et un code PIN suffisent, sans e-mail", "onboardingCopy.primaryCalendarPreview": "Aperçu de votre calendrier principal : les 30 prochains jours. Terminer importe ces événements. Les lectures suivantes ajoutent les rendez-vous à venir et appliquent les modifications signalées ; les événements absents d’une lecture sont conservés. Votre calendrier et vos tâches chez le fournisseur restent inchangés.", - "onboardingWizard.invalidPreviewTimezone": "Choisissez un fuseau horaire valide." + "onboardingWizard.invalidPreviewTimezone": "Choisissez un fuseau horaire valide.", + "briefingGenerated.morningGreeting": "Bonjour, {name} !", + "briefingGenerated.eveningGreeting": "Bonsoir, {name} !", + "briefingGenerated.morningGreetingNameless": "Bonjour !", + "briefingGenerated.eveningGreetingNameless": "Bonsoir !", + "briefingGenerated.emptySummary": "Rien en attente — profitez de cette journée libre !", + "briefingGenerated.emptyRecommendation": "Tout est à jour. Bonne journée !", + "briefingGenerated.startWith": "Commencez par : {title} — {detail}.", + "briefingGenerated.familyRequest": "Une demande de votre famille", + "briefingGenerated.partlyDone": "{title} — partiellement terminé", + "briefingGenerated.conflict": "{first} et {second} se chevauchent à {time}", + "briefingGenerated.conflictSuggestion": "Décidez qui se charge de chaque événement ou déplacez-en un.", + "briefingGenerated.meal.breakfast": "Petit-déjeuner", + "briefingGenerated.meal.lunch": "Déjeuner", + "briefingGenerated.meal.dinner": "Dîner", + "briefingGenerated.meal.snack": "Collation", + "conciergeDisplay.due": "Échéance : {when}", + "conciergeDisplay.amountDue": "{amount}, échéance : {when}", + "conciergeDisplay.scheduledToday": "Prévu aujourd’hui", + "conciergeDisplay.warrantyTitle": "Garantie de {name}", + "conciergeDisplay.expired": "A expiré {when}", + "conciergeDisplay.expires": "Expire {when}", + "conciergeDisplay.inProgress": "En cours", + "conciergeDisplay.departs": "Départ {when}", + "conciergeDisplay.destination": "{detail} · {destination}", + "conciergeDisplay.overdueCount": "{count} en retard", + "conciergeDisplay.todayCount": "{count} pour aujourd’hui", + "conciergeDisplay.soonCount": "{count} à venir", + "conciergeDisplay.headline": "{parts}.", + "conciergeDisplay.empty": "Tout est à jour — rien ne nécessite votre attention pour le moment.", + "briefingGenerated.domain.bill.one": "{count} facture nécessite votre attention", + "briefingGenerated.domain.bill.other": "{count} factures nécessitent votre attention", + "briefingGenerated.domain.medication.one": "{count} médicament nécessite votre attention", + "briefingGenerated.domain.medication.other": "{count} médicaments nécessitent votre attention", + "briefingGenerated.domain.maintenance.one": "{count} tâche d’entretien nécessite votre attention", + "briefingGenerated.domain.maintenance.other": "{count} tâches d’entretien nécessitent votre attention", + "briefingGenerated.domain.warranty.one": "{count} garantie nécessite votre attention", + "briefingGenerated.domain.warranty.other": "{count} garanties nécessitent votre attention", + "briefingGenerated.domain.trip.one": "{count} voyage nécessite votre attention", + "briefingGenerated.domain.trip.other": "{count} voyages nécessitent votre attention", + "briefingGenerated.domain.pantry.one": "{count} produit du garde-manger nécessite votre attention", + "briefingGenerated.domain.pantry.other": "{count} produits du garde-manger nécessitent votre attention" } diff --git a/lib/i18n/messages/it-IT.json b/lib/i18n/messages/it-IT.json index 780ef38d7..79f2622d9 100644 --- a/lib/i18n/messages/it-IT.json +++ b/lib/i18n/messages/it-IT.json @@ -12751,5 +12751,46 @@ "onboardingCopy.dashboardReady": "La tua dashboard è personalizzata e pronta da esplorare.", "onboardingCopy.kidLogin": "Crea un accesso per {names}: nome utente e PIN, senza e-mail", "onboardingCopy.primaryCalendarPreview": "Anteprima dal tuo calendario principale: i prossimi 30 giorni. Completa la configurazione per importare questi eventi. Le letture successive aggiungono gli appuntamenti futuri e applicano le modifiche segnalate; gli eventi assenti da una lettura vengono conservati. Il calendario e le attività del fornitore restano invariati.", - "onboardingWizard.invalidPreviewTimezone": "Scegli un fuso orario valido." + "onboardingWizard.invalidPreviewTimezone": "Scegli un fuso orario valido.", + "briefingGenerated.morningGreeting": "Buongiorno, {name}!", + "briefingGenerated.eveningGreeting": "Buonasera, {name}!", + "briefingGenerated.morningGreetingNameless": "Buongiorno!", + "briefingGenerated.eveningGreetingNameless": "Buonasera!", + "briefingGenerated.emptySummary": "Non c’è nulla in sospeso: si goda la giornata libera!", + "briefingGenerated.emptyRecommendation": "È tutto a posto. Buona giornata!", + "briefingGenerated.startWith": "Inizi da: {title} — {detail}.", + "briefingGenerated.familyRequest": "Una richiesta della sua famiglia", + "briefingGenerated.partlyDone": "{title} — completato in parte", + "briefingGenerated.conflict": "{first} e {second} si sovrappongono alle {time}", + "briefingGenerated.conflictSuggestion": "Decida chi si occupa di ciascun evento oppure ne sposti uno.", + "briefingGenerated.meal.breakfast": "Colazione", + "briefingGenerated.meal.lunch": "Pranzo", + "briefingGenerated.meal.dinner": "Cena", + "briefingGenerated.meal.snack": "Spuntino", + "conciergeDisplay.due": "In scadenza {when}", + "conciergeDisplay.amountDue": "{amount}, in scadenza {when}", + "conciergeDisplay.scheduledToday": "Previsto per oggi", + "conciergeDisplay.warrantyTitle": "Garanzia di {name}", + "conciergeDisplay.expired": "Scaduto {when}", + "conciergeDisplay.expires": "Scade {when}", + "conciergeDisplay.inProgress": "In corso", + "conciergeDisplay.departs": "Partenza {when}", + "conciergeDisplay.destination": "{detail} · {destination}", + "conciergeDisplay.overdueCount": "{count} scaduti", + "conciergeDisplay.todayCount": "{count} per oggi", + "conciergeDisplay.soonCount": "{count} in arrivo", + "conciergeDisplay.headline": "{parts}.", + "conciergeDisplay.empty": "È tutto a posto: al momento nulla richiede attenzione.", + "briefingGenerated.domain.bill.one": "{count} bolletta richiede attenzione", + "briefingGenerated.domain.bill.other": "{count} bollette richiedono attenzione", + "briefingGenerated.domain.medication.one": "{count} farmaco richiede attenzione", + "briefingGenerated.domain.medication.other": "{count} farmaci richiedono attenzione", + "briefingGenerated.domain.maintenance.one": "{count} attività di manutenzione richiede attenzione", + "briefingGenerated.domain.maintenance.other": "{count} attività di manutenzione richiedono attenzione", + "briefingGenerated.domain.warranty.one": "{count} garanzia richiede attenzione", + "briefingGenerated.domain.warranty.other": "{count} garanzie richiedono attenzione", + "briefingGenerated.domain.trip.one": "{count} viaggio richiede attenzione", + "briefingGenerated.domain.trip.other": "{count} viaggi richiedono attenzione", + "briefingGenerated.domain.pantry.one": "{count} prodotto della dispensa richiede attenzione", + "briefingGenerated.domain.pantry.other": "{count} prodotti della dispensa richiedono attenzione" } diff --git a/lib/i18n/messages/nl-NL.json b/lib/i18n/messages/nl-NL.json index ebea91317..ac832c263 100644 --- a/lib/i18n/messages/nl-NL.json +++ b/lib/i18n/messages/nl-NL.json @@ -12751,5 +12751,46 @@ "onboardingCopy.dashboardReady": "Je dashboard is gepersonaliseerd en klaar om te verkennen.", "onboardingCopy.kidLogin": "Geef {names} toegang — gebruikersnaam en pincode, geen e-mail nodig", "onboardingCopy.primaryCalendarPreview": "Voorbeeld uit je primaire agenda: de komende 30 dagen. Afronden importeert deze afspraken. Latere uitlezingen voegen komende afspraken en gemelde wijzigingen toe; afspraken die ontbreken blijven bewaard. Je agenda en taken bij de aanbieder blijven ongewijzigd.", - "onboardingWizard.invalidPreviewTimezone": "Kies een geldige tijdzone." + "onboardingWizard.invalidPreviewTimezone": "Kies een geldige tijdzone.", + "briefingGenerated.morningGreeting": "Goedemorgen, {name}!", + "briefingGenerated.eveningGreeting": "Goedenavond, {name}!", + "briefingGenerated.morningGreetingNameless": "Goedemorgen!", + "briefingGenerated.eveningGreetingNameless": "Goedenavond!", + "briefingGenerated.emptySummary": "Er staat niets open — geniet van de vrije dag!", + "briefingGenerated.emptyRecommendation": "Alles is bijgewerkt. Een fijne dag!", + "briefingGenerated.startWith": "Begin met: {title} — {detail}.", + "briefingGenerated.familyRequest": "Een verzoek van uw gezin", + "briefingGenerated.partlyDone": "{title} — gedeeltelijk afgerond", + "briefingGenerated.conflict": "{first} en {second} overlappen om {time}", + "briefingGenerated.conflictSuggestion": "Bepaal wie welke afspraak op zich neemt of verplaats er één.", + "briefingGenerated.meal.breakfast": "Ontbijt", + "briefingGenerated.meal.lunch": "Lunch", + "briefingGenerated.meal.dinner": "Avondeten", + "briefingGenerated.meal.snack": "Tussendoortje", + "conciergeDisplay.due": "Uiterlijk {when}", + "conciergeDisplay.amountDue": "{amount}, uiterlijk {when}", + "conciergeDisplay.scheduledToday": "Gepland voor vandaag", + "conciergeDisplay.warrantyTitle": "Garantie van {name}", + "conciergeDisplay.expired": "Verlopen {when}", + "conciergeDisplay.expires": "Verloopt {when}", + "conciergeDisplay.inProgress": "Bezig", + "conciergeDisplay.departs": "Vertrek {when}", + "conciergeDisplay.destination": "{detail} · {destination}", + "conciergeDisplay.overdueCount": "{count} te laat", + "conciergeDisplay.todayCount": "{count} voor vandaag", + "conciergeDisplay.soonCount": "{count} binnenkort", + "conciergeDisplay.headline": "{parts}.", + "conciergeDisplay.empty": "Alles is bijgewerkt — op dit moment vraagt niets uw aandacht.", + "briefingGenerated.domain.bill.one": "{count} rekening vraagt uw aandacht", + "briefingGenerated.domain.bill.other": "{count} rekeningen vragen uw aandacht", + "briefingGenerated.domain.medication.one": "{count} medicijn vraagt uw aandacht", + "briefingGenerated.domain.medication.other": "{count} medicijnen vragen uw aandacht", + "briefingGenerated.domain.maintenance.one": "{count} onderhoudstaak vraagt uw aandacht", + "briefingGenerated.domain.maintenance.other": "{count} onderhoudstaken vragen uw aandacht", + "briefingGenerated.domain.warranty.one": "{count} garantie vraagt uw aandacht", + "briefingGenerated.domain.warranty.other": "{count} garanties vragen uw aandacht", + "briefingGenerated.domain.trip.one": "{count} reis vraagt uw aandacht", + "briefingGenerated.domain.trip.other": "{count} reizen vragen uw aandacht", + "briefingGenerated.domain.pantry.one": "{count} voorraadproduct vraagt uw aandacht", + "briefingGenerated.domain.pantry.other": "{count} voorraadproducten vragen uw aandacht" } diff --git a/lib/i18n/messages/pt-PT.json b/lib/i18n/messages/pt-PT.json index d9e6a6260..066dd17df 100644 --- a/lib/i18n/messages/pt-PT.json +++ b/lib/i18n/messages/pt-PT.json @@ -12751,5 +12751,46 @@ "onboardingCopy.dashboardReady": "O seu painel está personalizado e pronto a explorar.", "onboardingCopy.kidLogin": "Crie um acesso para {names} — nome de utilizador e PIN, sem precisar de e-mail", "onboardingCopy.primaryCalendarPreview": "Pré-visualização do seu calendário principal: os próximos 30 dias. Concluir importa estes eventos. As leituras seguintes adicionam compromissos futuros e aplicam alterações comunicadas; os eventos ausentes de uma leitura são mantidos. O calendário e as tarefas no fornecedor permanecem inalterados.", - "onboardingWizard.invalidPreviewTimezone": "Escolha um fuso horário válido." + "onboardingWizard.invalidPreviewTimezone": "Escolha um fuso horário válido.", + "briefingGenerated.morningGreeting": "Bom dia, {name}!", + "briefingGenerated.eveningGreeting": "Boa noite, {name}!", + "briefingGenerated.morningGreetingNameless": "Bom dia!", + "briefingGenerated.eveningGreetingNameless": "Boa noite!", + "briefingGenerated.emptySummary": "Não há nada pendente — aproveite o dia livre!", + "briefingGenerated.emptyRecommendation": "Está tudo em dia. Tenha um bom dia!", + "briefingGenerated.startWith": "Comece por: {title} — {detail}.", + "briefingGenerated.familyRequest": "Um pedido da sua família", + "briefingGenerated.partlyDone": "{title} — parcialmente concluído", + "briefingGenerated.conflict": "{first} e {second} sobrepõem-se às {time}", + "briefingGenerated.conflictSuggestion": "Decida quem fica responsável por cada evento ou altere o horário de um deles.", + "briefingGenerated.meal.breakfast": "Pequeno-almoço", + "briefingGenerated.meal.lunch": "Almoço", + "briefingGenerated.meal.dinner": "Jantar", + "briefingGenerated.meal.snack": "Lanche", + "conciergeDisplay.due": "Vence {when}", + "conciergeDisplay.amountDue": "{amount}, vence {when}", + "conciergeDisplay.scheduledToday": "Agendado para hoje", + "conciergeDisplay.warrantyTitle": "Garantia de {name}", + "conciergeDisplay.expired": "Expirou {when}", + "conciergeDisplay.expires": "Expira {when}", + "conciergeDisplay.inProgress": "Em curso", + "conciergeDisplay.departs": "Partida {when}", + "conciergeDisplay.destination": "{detail} · {destination}", + "conciergeDisplay.overdueCount": "{count} em atraso", + "conciergeDisplay.todayCount": "{count} para hoje", + "conciergeDisplay.soonCount": "{count} próximos", + "conciergeDisplay.headline": "{parts}.", + "conciergeDisplay.empty": "Está tudo em dia — de momento, nada requer a sua atenção.", + "briefingGenerated.domain.bill.one": "{count} fatura requer a sua atenção", + "briefingGenerated.domain.bill.other": "{count} faturas requerem a sua atenção", + "briefingGenerated.domain.medication.one": "{count} medicamento requer a sua atenção", + "briefingGenerated.domain.medication.other": "{count} medicamentos requerem a sua atenção", + "briefingGenerated.domain.maintenance.one": "{count} tarefa de manutenção requer a sua atenção", + "briefingGenerated.domain.maintenance.other": "{count} tarefas de manutenção requerem a sua atenção", + "briefingGenerated.domain.warranty.one": "{count} garantia requer a sua atenção", + "briefingGenerated.domain.warranty.other": "{count} garantias requerem a sua atenção", + "briefingGenerated.domain.trip.one": "{count} viagem requer a sua atenção", + "briefingGenerated.domain.trip.other": "{count} viagens requerem a sua atenção", + "briefingGenerated.domain.pantry.one": "{count} produto da despensa requer a sua atenção", + "briefingGenerated.domain.pantry.other": "{count} produtos da despensa requerem a sua atenção" } diff --git a/tests/api-ai-briefing-schema.test.ts b/tests/api-ai-briefing-schema.test.ts index f7eaee541..3256150a6 100644 --- a/tests/api-ai-briefing-schema.test.ts +++ b/tests/api-ai-briefing-schema.test.ts @@ -281,6 +281,7 @@ describe('Daily Brief route schema boundary', () => { byDomain: [{ domain: 'bill', count: 1 }, { domain: 'pantry', count: 1 }], headline: '2 items need attention', }; + const displayDigest = { ...digest, headline: '1 overdue, 1 coming up.' }; // The fixture family carries no timezone, so the route falls back to // America/New_York — and every date in the brief is rendered there. Before, @@ -293,7 +294,7 @@ describe('Daily Brief route schema boundary', () => { return { greeting: `Good ${type === 'evening' ? 'evening' : 'morning'}, Alex!`, subtitle: now.toLocaleDateString('en-US', { timeZone: FAMILY_TZ, weekday: 'long', month: 'long', day: 'numeric' }), - familySummary: ['1 bill need attention', '1 pantry need attention'], + familySummary: ['1 bill needs attention', '1 pantry item needs attention'], schedule: [{ time: new Date(event.starts_at).toLocaleTimeString('en-US', { timeZone: FAMILY_TZ, hour: 'numeric', minute: '2-digit' }), title: event.title, member: 'Sam', emoji: '\ud83d\udcc5', color: 'blue' }], conflicts: [], kidsNeeds: [], @@ -306,7 +307,7 @@ describe('Daily Brief route schema boundary', () => { overall: 60, categories: [], stressLevel: 'high', - stressReason: digest.headline, + stressReason: displayDigest.headline, recommendation: 'Start with: Electric bill \u2014 Due yesterday.', }, outstanding: [{ text: 'Electric bill \u2014 Due yesterday', urgency: 'high' }], @@ -378,7 +379,7 @@ describe('Daily Brief route schema boundary', () => { // as "nothing else today". expect(Object.keys(body).sort()).toEqual(['alsoToday', 'alsoTodayUnavailable', 'briefing', 'digest', 'generatedAt']); expect(body).toMatchObject({ - digest, generatedAt: utcNow.toISOString(), briefing: { completed: [] }, + digest: displayDigest, generatedAt: utcNow.toISOString(), briefing: { completed: [] }, alsoToday: [], alsoTodayUnavailable: false, }); expect(schoolQuery.gte).toHaveBeenCalledWith('starts_at', '2026-09-06T00:00:00.000Z'); @@ -445,7 +446,7 @@ describe('Daily Brief route schema boundary', () => { // Every field the model wrote survives EXCEPT `completed`: "Completed // Today" is evidence, so the route replaces the model's list with the runs // that really finished. Here no run has, so the honest answer is none. - expect(await response.json()).toEqual({ briefing: { ...validBriefing(), completed: [] }, digest, alsoToday: [], alsoTodayUnavailable: false, generatedAt: now.toISOString() }); + expect(await response.json()).toEqual({ briefing: { ...validBriefing(), completed: [] }, digest: displayDigest, alsoToday: [], alsoTodayUnavailable: false, generatedAt: now.toISOString() }); expect(mocks.complete).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ tools: [], maxTokens: 2000 })); expect(mocks.from).not.toHaveBeenCalledWith('home_briefs'); expect(fetch).not.toHaveBeenCalled(); @@ -480,7 +481,7 @@ describe('Daily Brief route schema boundary', () => { expect(body.briefing.conflicts).toHaveLength(1); expect(body.briefing.conflicts[0]).toMatchObject({ description: expect.stringContaining('Soccer practice and Dentist overlap') }); - expect(body.briefing.meals).toEqual([{ meal: 'dinner', name: 'Tacos', status: 'planned' }]); + expect(body.briefing.meals).toEqual([{ meal: 'Dinner', name: 'Tacos', status: 'planned' }]); expect(body.briefing.kidsNeeds).toEqual([{ name: 'Sam', items: ['Bring a costume'] }]); }); @@ -612,11 +613,11 @@ describe('what Bubaly claims to have done', () => { ['excessive list', JSON.stringify(changeField('reminders', Array.from({ length: limits.listItems + 1 }, () => validBriefing().reminders[0])))], ['prose-wrapped JSON', `Here it is: ${JSON.stringify(validBriefing())}`], ['oversized completion', ' '.repeat(MAX_BRIEFING_RESPONSE_BYTES + 1)], - ])('uses the unchanged deterministic fallback for %s', async (_label, text) => { + ])('uses the localized deterministic fallback for %s', async (_label, text) => { mocks.complete.mockResolvedValue({ text, toolCalls: [] }); const response = await requestBriefing(); expect(response.status).toBe(200); - expect(await response.json()).toEqual({ briefing: { ...expectedFallback(), completed: [] }, digest, alsoToday: [], alsoTodayUnavailable: false, generatedAt: now.toISOString() }); + expect(await response.json()).toEqual({ briefing: { ...expectedFallback(), completed: [] }, digest: displayDigest, alsoToday: [], alsoTodayUnavailable: false, generatedAt: now.toISOString() }); expect(mocks.complete).toHaveBeenCalledTimes(1); expect(fetch).not.toHaveBeenCalled(); }); @@ -625,7 +626,7 @@ describe('what Bubaly claims to have done', () => { mocks.isAIConfigured.mockResolvedValue(false); const response = await requestBriefing(type); expect(response.status).toBe(200); - expect(await response.json()).toEqual({ briefing: { ...expectedFallback(type), completed: [] }, digest, alsoToday: [], alsoTodayUnavailable: false, generatedAt: now.toISOString() }); + expect(await response.json()).toEqual({ briefing: { ...expectedFallback(type), completed: [] }, digest: displayDigest, alsoToday: [], alsoTodayUnavailable: false, generatedAt: now.toISOString() }); expect(mocks.resolveProvider).not.toHaveBeenCalled(); expect(mocks.complete).not.toHaveBeenCalled(); expect(fetch).not.toHaveBeenCalled(); @@ -639,7 +640,7 @@ describe('what Bubaly claims to have done', () => { const response = await requestBriefing(); const body = await response.json(); expect(response.status).toBe(200); - expect(body.digest).toEqual(emptyDigest); + expect(body.digest).toEqual({ ...emptyDigest, headline: "You're all caught up — nothing needs attention right now." }); expect(body.briefing).toMatchObject({ familySummary: ['Nothing outstanding \u2014 enjoy the open day!'], schedule: [], @@ -658,7 +659,7 @@ describe('what Bubaly claims to have done', () => { expect(response.status).toBe(200); expect(await response.json()).toEqual({ briefing: { ...expectedFallback(), completed: [] }, - digest, + digest: displayDigest, alsoToday: [], alsoTodayUnavailable: false, generatedAt: now.toISOString(), diff --git a/tests/briefing-api-localization.test.ts b/tests/briefing-api-localization.test.ts new file mode 100644 index 000000000..73b9a0416 --- /dev/null +++ b/tests/briefing-api-localization.test.ts @@ -0,0 +1,148 @@ +// Actual request-locale resolution, read models and API envelope over filter-aware transport doubles. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { Database } from '@/lib/database.types'; +import { createInMemorySupabase } from './helpers/in-memory-supabase'; +import { LOCALE_COOKIE, type LocaleCode } from '@/lib/i18n/locales'; +import { getLocaleContext } from '@/lib/i18n/server'; +import { getMessages, translate } from '@/lib/i18n/messages'; +import { parseBriefingResponse } from '@/lib/briefing/response-schema'; + +const mocks = vi.hoisted(() => ({ locale: 'en-US' as LocaleCode, cookie: undefined as string | null | undefined, country: 'US', acceptLanguage: 'en-US', context: vi.fn(), server: vi.fn(), complete: vi.fn(), configured: vi.fn() })); +vi.mock('next/headers', () => ({ cookies: async () => ({ get: (key: string) => key === LOCALE_COOKIE && mocks.cookie !== null ? { value: mocks.cookie ?? mocks.locale } : undefined }), headers: async () => new Headers({ 'accept-language': mocks.acceptLanguage, 'x-country': mocks.country }) })); +vi.mock('@/lib/supabase/auth', () => ({ requireUserContext: mocks.context })); +vi.mock('@/lib/supabase/server', () => ({ createServer: mocks.server })); +vi.mock('@/lib/ai/provider', () => ({ isAIConfigured: mocks.configured, resolveProvider: async () => ({ complete: mocks.complete }) })); +vi.mock('@/lib/ai/observability', () => ({ withAiRequest: async (_scope: unknown, _input: unknown, fn: (obs: { used: () => void }) => unknown) => fn({ used: () => {} }) })); +vi.mock('@/lib/server/ai-rate-limit', () => ({ enforceAIRateLimit: async () => ({ ok: true }) })); +vi.mock('@/lib/metric/time-saved-server', () => ({ countHandledThisWeek: async () => ({ total: 0 }) })); +import { POST } from '@/app/api/ai/briefing/route'; + +const directory = process.env.BUBALY_BRIEFING_DISPLAY_DIR; +const locales = ['en-US', 'de-DE', 'es-ES', 'fr-FR', 'it-IT', 'nl-NL', 'pt-PT'] as const; +const t = (key: string, params?: Record) => translate(getMessages(mocks.locale), key, params); +type DB = SupabaseClient; +let db = createInMemorySupabase(); +const rawTitle = 'Budget review {name} '; +const modelResponse = { + greeting: 'Good morning, Alex!', subtitle: 'Wednesday, September 9', familySummary: ['Your day is ready.'], + schedule: [{ time: '8:00 AM', title: rawTitle, member: 'Alex', emoji: '📅', color: 'blue' }], conflicts: [], kidsNeeds: [], meals: [], reminders: [], + operationsScore: { overall: 90, categories: [{ label: 'Schedule', score: 90, icon: '📅' }], stressLevel: 'low', stressReason: null, recommendation: 'Enjoy your day.' }, + completed: ['Invented completion must be replaced'], +}; +function seed() { + db.seed('family_members', [{ id: 'parent', family_id: 'family', display_name: 'Alex', role: 'parent', is_active: true }, { id: 'child', family_id: 'family', display_name: 'Sam', role: 'child', is_active: true }]); + db.seed('calendar_events', [{ title: rawTitle, starts_at: '2026-09-09T12:00:00.000Z', ends_at: '2026-09-09T13:00:00.000Z', family_id: 'family', all_day: false, assignee_id: 'parent', location: 'Porto {city}' }, + { title: 'Musik Probe', starts_at: '2026-09-09T12:30:00.000Z', ends_at: '2026-09-09T13:30:00.000Z', family_id: 'family', all_day: false, assignee_id: 'parent', location: null }, + { title: 'School holiday {title}', starts_at: '2026-09-09T00:00:00.000Z', ends_at: '2026-09-10T00:00:00.000Z', family_id: 'family', all_day: true, assignee_id: null, location: null }]); + db.seed('school_events', [{ title: 'Bring your costume {raw}', family_id: 'family', member_id: 'child', starts_at: '2026-09-09T14:00:00.000Z', event_type: 'activity' }]); + db.seed('bills', [{ family_id: 'family', name: 'Rent {amount}', amount: 12.5, due_date: '2026-09-08', status: 'unpaid' }]); + db.seed('maintenance_tasks', [{ family_id: 'family', title: 'Filter {raw}', due_at: '2026-09-10T12:00:00.000Z', status: 'todo', completed_at: null }]); + db.seed('home_warranties', [{ family_id: 'family', name: 'Machine {raw}', expires_on: '2026-09-10' }]); + db.seed('vacations', [{ family_id: 'family', title: 'Family trip {raw}', destination: 'Porto {city}', start_date: '2026-09-08', end_date: '2026-09-11', status: 'confirmed' }]); + db.seed('pantry_items', [{ family_id: 'family', name: 'Milk {raw}', expires_at: '2026-09-10' }]); + db.seed('meals', [{ id: 'meal', family_id: 'family', name: 'Grandma recipe {raw}' }]); + db.seed('meal_plans', [{ family_id: 'family', plan_date: '2026-09-09', meal_type: 'dinner', meal_id: 'meal' }]); + db.seed('medications', [{ id: 'med', family_id: 'family', name: 'Medicine {raw}', member_id: null, is_active: true }]); + db.seed('medication_schedules', [{ family_id: 'family', medication_id: 'med', time_of_day: null, days_of_week: [3], starts_on: '2026-01-01', ends_on: null }]); + db.seed('family_automation_runs', [{ id: 'partial', family_id: 'family', summary: 'Prepared trip {raw}', state: 'partially_completed', progress: null, completed_at: '2026-09-09T10:00:00.000Z', updated_at: '2026-09-09T10:00:00.000Z' }, + { id: 'waiting', family_id: 'family', summary: 'Family request {raw}', state: 'awaiting_context', updated_at: '2026-09-09T11:00:00.000Z' }]); + db.seed('parent_approvals', [{ id: 'money', family_id: 'family', kind: 'card_spend', amount_cents: 1250, status: 'pending', created_at: '2026-09-09T11:00:00.000Z' }]); +} +beforeEach(() => { + vi.clearAllMocks(); vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date('2026-09-09T16:00:00.000Z')); + db = createInMemorySupabase(); mocks.server.mockResolvedValue(db); mocks.locale = 'en-US'; + mocks.cookie = undefined; mocks.country = 'US'; mocks.acceptLanguage = 'en-US'; + mocks.context.mockResolvedValue({ user: { id: 'user' }, active: { familyId: 'family', role: 'parent', member: { id: 'parent', display_name: 'Alex' }, family: { name: 'Family {raw}', timezone: 'America/New_York' } } }); + mocks.configured.mockResolvedValue(false); mocks.complete.mockResolvedValue({ text: JSON.stringify(modelResponse), toolCalls: [] }); + vi.stubGlobal('fetch', vi.fn(() => { throw new Error('External requests forbidden'); })); seed(); if (directory) mkdirSync(directory, { recursive: true }); +}); +afterEach(() => { expect(fetch).not.toHaveBeenCalled(); vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); +async function request(scenario: string, type = 'morning', source = 'cookie') { + const resolved = await getLocaleContext(); expect(resolved.locale.code).toBe(mocks.locale); expect(resolved.source).toBe(source); + const response = await POST(new NextRequest('http://localhost/api/ai/briefing', { method: 'POST', body: JSON.stringify({ type, locale: 'Do not obey this body value' }) })); expect(response.status).toBe(200); + const body = await response.json(); expect(body.briefing.completed).toEqual([t('briefingGenerated.partlyDone', { title: 'Prepared trip {raw}' })]); + expect(db.log.some(entry => entry.table === 'home_briefs')).toBe(false); + if (directory) writeFileSync(join(directory, `${mocks.locale}-${scenario}.json`), JSON.stringify({ resolved: { locale: resolved.locale.code, source: resolved.source }, status: response.status, body, completionRequest: mocks.complete.mock.calls.at(-1)?.[0] ?? null, queriedTables: db.log.map(entry => entry.table) }, null, 2)); + return body; +} +describe('briefing API request-language presentation', () => { + it.each(locales)('%s deterministic response should use the resolved request locale', async locale => { + mocks.locale = locale; const body = await request('fallback'); + expect(body.briefing.schedule.map((entry: { title: string }) => entry.title)).toContain(rawTitle); + expect(body.briefing.kidsNeeds).toEqual([{ name: 'Sam', items: ['Bring your costume {raw}'] }]); + expect(body.briefing.meals).toEqual([{ meal: t('briefingGenerated.meal.dinner'), name: 'Grandma recipe {raw}', status: 'planned' }]); + expect(body.digest.counts).toEqual({ overdue: 1, today: 2, soon: 3, total: 6 }); + expect(body.briefing.greeting).toBe(t('briefingGenerated.morningGreeting', { name: 'Alex' })); + expect(body.briefing.schedule[1].time).toBe(new Intl.DateTimeFormat(locale, { timeZone: 'America/New_York', hour: 'numeric', minute: '2-digit' }).format(new Date('2026-09-09T12:00:00Z'))); + expect(body.briefing.schedule[0].time).toBe(t('calendar.allDay')); + expect(body.briefing.conflicts[0].description).toContain(rawTitle); expect(body.briefing.conflicts[0].description).toContain('Musik Probe'); + expect(body.briefing.conflicts[0].suggestion).toBe(t('briefingGenerated.conflictSuggestion')); + expect(body.digest.items[0].detail).toContain(new Intl.NumberFormat(locale, { style: 'currency', currency: 'USD' }).format(12.5)); + expect(body.digest.items.some((item: { display?: unknown }) => item.display)).toBe(false); + expect(body.briefing.operationsScore.stressLevel).toBe('high'); + expect(body.decisions.items[0]).toMatchObject({ id: 'approval:money', kind: 'approval', title: 'Card purchase to approve · $12.50', href: '/wallet/cards' }); + expect(body.briefing.subtitle).toBe(new Intl.DateTimeFormat(locale, { timeZone: 'America/New_York', weekday: 'long', month: 'long', day: 'numeric' }).format(new Date())); + }); + it.each(locales)('%s optional model instructions should carry the trusted output locale', async locale => { + mocks.locale = locale; mocks.configured.mockResolvedValue(true); const body = await request('model'); + expect(parseBriefingResponse(JSON.stringify(modelResponse))).not.toBeNull(); + expect(body.briefing.greeting).toBe('Good morning, Alex!'); expect(body.briefing.operationsScore.categories[0].label).toBe('Schedule'); + expect(body.briefing.completed).not.toContain('Invented completion must be replaced'); + expect(mocks.complete.mock.calls[0][0].system).toContain(locale); + expect(mocks.complete.mock.calls[0][0].system).toContain('German Sie, French vous, and formal European Portuguese'); + expect(mocks.complete.mock.calls[0][0].tools).toEqual([]); + expect(mocks.complete).toHaveBeenCalledOnce(); + }); + + it.each(locales)('%s preserves type-specific rendering and empty-state copy across all three tabs', async locale => { + mocks.locale = locale; + for (const type of ['morning', 'evening', 'weekly']) { + const body = await request(`dense-${type}`, type); + expect(body.briefing.greeting).toBe(t(type === 'evening' ? 'briefingGenerated.eveningGreeting' : 'briefingGenerated.morningGreeting', { name: 'Alex' })); + expect(body.digest.items.map((item: { domain: string }) => item.domain)).toEqual(['bill', 'medication', 'trip', 'maintenance', 'pantry', 'warranty']); + } + for (const table of ['calendar_events', 'bills', 'medication_schedules', 'maintenance_tasks', 'home_warranties', 'vacations', 'pantry_items', 'school_events', 'meal_plans']) db.replace(table, []); + const empty = await request('empty'); + expect(empty.briefing.familySummary).toEqual([t('briefingGenerated.emptySummary')]); + expect(empty.briefing.operationsScore).toMatchObject({ overall: 90, stressLevel: 'low', stressReason: null, recommendation: t('briefingGenerated.emptyRecommendation') }); + expect(empty.digest).toMatchObject({ items: [], counts: { total: 0 }, headline: t('conciergeDisplay.empty') }); + }); + + it.each(locales.flatMap(locale => (['invalid', 'rejected'] as const).map(failure => [locale, failure] as const)))('%s retains localized fallback after %s provider output', async (locale, failure) => { + mocks.locale = locale; mocks.configured.mockResolvedValue(true); + if (failure === 'invalid') mocks.complete.mockResolvedValue({ text: '{invalid', toolCalls: [] }); + else mocks.complete.mockRejectedValue(new Error('Provider secret must not escape')); + const body = await request(failure); + expect(body.briefing.greeting).toBe(t('briefingGenerated.morningGreeting', { name: 'Alex' })); + expect(JSON.stringify(body)).not.toContain('Provider secret'); expect(mocks.complete).toHaveBeenCalledOnce(); + }); + + it.each([ + ['de-DE', 'de-DE', 'FR', 'pt-PT', 'cookie'], + ['fr-FR', 'invalid', 'FR', 'pt-PT', 'geo'], + ['pt-PT', null, '', 'pt-PT', 'accept-language'], + ['en-US', 'invalid', 'ZZ', 'xx-YY', 'default'], + ] as const)('request locale %s follows recognized cookie/geo/header/default precedence', async (locale, cookie, country, language, source) => { + mocks.locale = locale; mocks.cookie = cookie; mocks.country = country; mocks.acceptLanguage = language; + const body = await request(`precedence-${source}`, 'morning', source); + expect(body.briefing.greeting).toBe(t('briefingGenerated.morningGreeting', { name: 'Alex' })); + }); + + it('leaves unknown meal labels and stored completion text unchanged; localizes only a truly missing title', async () => { + mocks.locale = 'fr-FR'; db.replace('meal_plans', [{ family_id: 'family', plan_date: '2026-09-09', meal_type: 'Family custom {meal}', meal_id: 'meal' }]); + db.replace('family_automation_runs', [ + { id: 'empty', family_id: 'family', summary: null, state: 'completed', progress: null, completed_at: '2026-09-09T10:00:00.000Z', updated_at: '2026-09-09T10:00:00.000Z' }, + { id: 'literal', family_id: 'family', summary: 'A request from your family', state: 'partially_completed', progress: { summary: 'Stored detail {raw}' }, completed_at: '2026-09-09T11:00:00.000Z', updated_at: '2026-09-09T11:00:00.000Z' }, + ]); + const before = structuredClone(db.table('family_automation_runs')); + const response = await POST(new NextRequest('http://localhost/api/ai/briefing', { method: 'POST', body: '{}' })); expect(response.status).toBe(200); + const { briefing } = await response.json(); + expect(briefing.meals).toEqual([{ meal: 'Family custom {meal}', name: 'Grandma recipe {raw}', status: 'planned' }]); + expect(briefing.completed).toEqual(['A request from your family — Stored detail {raw}', t('briefingGenerated.familyRequest')]); + expect(db.table('family_automation_runs')).toEqual(before); + }); +}); diff --git a/tests/briefing-calendar-days.test.ts b/tests/briefing-calendar-days.test.ts index eed80ebff..6cc38e980 100644 --- a/tests/briefing-calendar-days.test.ts +++ b/tests/briefing-calendar-days.test.ts @@ -10,7 +10,7 @@ import { createInMemorySupabase } from './helpers/in-memory-supabase'; const mocks = vi.hoisted(() => ({ context: vi.fn(), server: vi.fn(), complete: vi.fn(), build: vi.fn(), locale: 'en-US' as LocaleCode })); vi.mock('@/lib/supabase/auth', () => ({ requireUserContext: mocks.context })); vi.mock('@/lib/supabase/server', () => ({ createServer: mocks.server })); -vi.mock('@/lib/i18n/server', () => ({ getTranslations: async () => (key: string) => translate(getMessages(mocks.locale), key) })); +vi.mock('@/lib/i18n/server', () => ({ getLocaleContext: async () => ({ locale: { code: mocks.locale }, messages: getMessages(mocks.locale) }) })); vi.mock('@/lib/ai/provider', () => ({ isAIConfigured: async () => true, resolveProvider: async () => ({ complete: mocks.complete }) })); vi.mock('@/lib/ai/observability', () => ({ withAiRequest: async (_scope: unknown, _input: unknown, fn: (obs: { used: () => void }) => unknown) => fn({ used: () => {} }) })); vi.mock('@/lib/server/ai-rate-limit', () => ({ enforceAIRateLimit: async () => ({ ok: true }) })); diff --git a/tests/concierge-digest-display.test.ts b/tests/concierge-digest-display.test.ts new file mode 100644 index 000000000..44ef718bf --- /dev/null +++ b/tests/concierge-digest-display.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { buildConciergeDigest, digestToPromptLines, type ConciergeDigest, type ConciergeSnapshot } from '@/lib/concierge/digest'; +import { formatConciergeDigest } from '@/lib/concierge/digest-display'; +import { getMessages, translate } from '@/lib/i18n/messages'; +import type { LocaleCode } from '@/lib/i18n/locales'; + +const locales: LocaleCode[] = ['en-US', 'de-DE', 'es-ES', 'fr-FR', 'it-IT', 'nl-NL', 'pt-PT']; +const snapshot: ConciergeSnapshot = { + now: '2026-09-09T16:00:00.000Z', + bills: [{ name: 'Bill {amount} ', amount: 12.5, dueDate: '2026-09-08' }, { name: 'No amount', dueDate: '2026-09-09' }], + medications: [{ name: 'Medicine {raw}', member: 'Sam {raw}', timeOfDay: '08:30' }, { name: 'Unassigned medicine' }], + maintenance: [{ title: 'Filter {raw}', dueAt: '2026-09-11' }], + warranties: [{ name: 'Washer warranty {raw}', expiresOn: '2026-09-07' }], + trips: [{ title: 'Current trip', startDate: '2026-09-08', endDate: '2026-09-10', destination: 'Porto {raw}' }, { title: 'Next trip', startDate: '2026-09-12', destination: 'Roma ' }], + pantry: [{ name: 'Milk', expiresAt: '2026-09-10' }, { name: 'Expired food', expiresAt: '2026-09-06' }], +}; +const view = (digest: ConciergeDigest, locale: LocaleCode) => formatConciergeDigest(digest, locale, (key, params) => translate(getMessages(locale), key, params)); +const withoutFacts = (digest: ConciergeDigest) => ({ ...digest, items: digest.items.map(({ display: _display, ...item }) => item) }); + +describe('request-only concierge digest display', () => { + it.each(locales)('%s preserves canonical items/order/counts/grounding and raw values', locale => { + const digest = buildConciergeDigest(snapshot), before = JSON.stringify(digest), grounding = digestToPromptLines(digest); + const result = view(digest, locale); + expect(JSON.stringify(digest)).toBe(before); expect(digestToPromptLines(digest)).toBe(grounding); + expect(result.counts).toEqual(digest.counts); expect(result.byDomain).toEqual(digest.byDomain); + expect(result.items.map(item => [item.domain, item.urgency, item.dayOffset, item.member])).toEqual(digest.items.map(item => [item.domain, item.urgency, item.dayOffset, item.member])); + expect(result.items.every(item => !('display' in item))).toBe(true); + expect(result.items.find(item => item.domain === 'bill' && item.dayOffset === -1)?.title).toBe('Bill {amount} '); + expect(result.items.find(item => item.domain === 'bill' && item.dayOffset === -1)?.detail).toContain(new Intl.NumberFormat(locale, { style: 'currency', currency: 'USD' }).format(12.5)); + expect(result.items.find(item => item.domain === 'medication' && item.title === 'Medicine {raw}')?.detail).toBe('Sam {raw} · 08:30'); + expect(result.items.find(item => item.domain === 'warranty')?.title).toContain('Washer warranty {raw}'); + expect(result.items.find(item => item.domain === 'trip' && item.dayOffset === 0)?.detail).toContain('Porto {raw}'); + expect(result.items.find(item => item.domain === 'trip' && item.dayOffset === 3)?.detail).toContain('Roma '); + for (const item of result.items.filter(item => !(item.domain === 'trip' && item.dayOffset === 0))) expect(item.dueLabel).toBe(new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(item.dayOffset, 'day')); + expect(JSON.stringify(result)).not.toMatch(/conciergeDisplay\./); + }); + + it.each([undefined, { version: 99 }, { version: 1, kind: 'unknown', name: 'Wrong', dayOffset: -1 }, + { version: 1, kind: 'bill', name: 'Wrong', amount: 12.5, dayOffset: -1 }, + { version: 1, kind: 'bill', name: 'Bill {amount} ', amount: Infinity, dayOffset: -1 }, + { version: 1, kind: 'bill', name: 'Bill {amount} ', amount: 12.5, dayOffset: 0 }, + { version: 1, kind: 'bill', name: 'Bill {amount} ', amount: 12.5, dayOffset: -1, extra: true }, + ])('missing/malformed/inconsistent item metadata falls back locally: %j', facts => { + const digest = buildConciergeDigest(snapshot), index = digest.items.findIndex(item => item.domain === 'bill' && item.dayOffset === -1); + Object.assign(digest.items[index], { display: facts }); const before = structuredClone(digest); + const result = view(digest, 'de-DE'); + expect(result.items[index]).toEqual(withoutFacts(digest).items[index]); + expect(result.items.find(item => item.domain === 'warranty')?.title).toBe('Garantie für Washer warranty {raw}'); + expect(digest).toEqual(before); + }); + + it('legacy items with no facts are kept verbatim, including text that resembles a generated label', () => { + const digest = withoutFacts(buildConciergeDigest(snapshot)), before = structuredClone(digest); + expect(view(digest, 'fr-FR').items).toEqual(digest.items); expect(digest).toEqual(before); + }); + + it.each(['warranty', 'medication'] as const)('keeps legacy %s content when redundant raw facts disagree', domain => { + const digest = buildConciergeDigest(snapshot), index = digest.items.findIndex(item => item.domain === domain); + const item = digest.items[index]; + if (item.display?.kind === 'warranty') item.display.name = 'Different private item'; + if (item.display?.kind === 'medication') item.display.member = 'Different member'; + const before = structuredClone(digest), result = view(digest, 'de-DE'); + expect(result.items[index]).toEqual(withoutFacts(digest).items[index]); + expect(result.items.find(entry => entry.domain === 'maintenance')?.detail).toBe('Fällig übermorgen'); + expect(digest).toEqual(before); + }); + + it.each(['amount', 'destination', 'time', 'phase'] as const)('keeps the affected item when stale %s facts contradict canonical text', field => { + const digest = buildConciergeDigest(snapshot); + const index = digest.items.findIndex(item => field === 'amount' ? item.domain === 'bill' && item.dayOffset === -1 + : field === 'time' ? item.domain === 'medication' && !!item.member : item.domain === 'trip' && item.dayOffset === 0); + const facts = digest.items[index].display!; + if (facts.kind === 'bill') facts.amount = 950; + if (facts.kind === 'medication') facts.timeOfDay = '20:30'; + if (facts.kind === 'trip' && field === 'destination') facts.destination = 'Rome'; + if (facts.kind === 'trip' && field === 'phase') facts.phase = 'departure'; + const before = structuredClone(digest), result = view(digest, 'fr-FR'); + expect(result.items[index]).toEqual(withoutFacts(digest).items[index]); + expect(result.items.find(item => item.domain === 'warranty')?.title).toBe('Garantie de Washer warranty {raw}'); + expect(digest).toEqual(before); + }); + + it('does not localize inconsistent urgency or ongoing-trip day metadata', () => { + const digest = buildConciergeDigest(snapshot); + digest.items[0].urgency = 'soon'; + const trip = digest.items.find(item => item.domain === 'trip' && item.dayOffset === 0)!; + trip.dayOffset = 2; if (trip.display) trip.display.dayOffset = 2; + const result = view(digest, 'pt-PT'); + expect(result.items[0]).toEqual(withoutFacts(digest).items[0]); + expect(result.items.find(item => item.title === trip.title)).toEqual(withoutFacts(digest).items.find(item => item.title === trip.title)); + }); + + it.each(locales)('%s translates empty state without changing the empty canonical digest', locale => { + const digest = buildConciergeDigest({ now: snapshot.now }); + expect(view(digest, locale)).toEqual({ ...digest, headline: translate(getMessages(locale), 'conciergeDisplay.empty') }); + expect(digest.headline).toBe("You're all caught up — nothing needs attention right now."); + }); +}); From 16111a48a9bc75b10d04e5457aabb05d7c8e5afe Mon Sep 17 00:00:00 2001 From: NewWorldVenture Date: Thu, 10 Sep 2026 08:16:22 -0400 Subject: [PATCH 2/2] Use count-neutral Spanish digest labels --- lib/i18n/messages/es-ES.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/i18n/messages/es-ES.json b/lib/i18n/messages/es-ES.json index c087d3ceb..dbdd0b311 100644 --- a/lib/i18n/messages/es-ES.json +++ b/lib/i18n/messages/es-ES.json @@ -12776,9 +12776,9 @@ "conciergeDisplay.inProgress": "En curso", "conciergeDisplay.departs": "Salida {when}", "conciergeDisplay.destination": "{detail} · {destination}", - "conciergeDisplay.overdueCount": "{count} vencidos", + "conciergeDisplay.overdueCount": "Fuera de plazo: {count}", "conciergeDisplay.todayCount": "{count} para hoy", - "conciergeDisplay.soonCount": "{count} próximos", + "conciergeDisplay.soonCount": "Próximamente: {count}", "conciergeDisplay.headline": "{parts}.", "conciergeDisplay.empty": "Todo está al día. Nada requiere atención ahora.", "briefingGenerated.domain.bill.one": "{count} factura requiere atención",