diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e85bd0c36..b390028a8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -31397,6 +31397,37 @@ components: items: $ref: "#/components/schemas/DailyPriorityItem" description: Bounded 0–3 rail items, never padded. + justIn: + anyOf: + - type: object + properties: + kind: + type: string + enum: + - sleep_night + - workout + - weight + - blood_pressure + - labs_panel + description: Closed arrival kind that landed. + at: + type: string + description: ISO-8601 instant of the newest sample. NEVER pre-formatted server-side — the client formats it in its own + locale and timezone. + required: + - kind + - at + additionalProperties: false + - type: "null" + description: The day's newest data arrival while it is still news (under three hours old), else null. Additive since + v1.31.0. + reactionLine: + anyOf: + - type: string + - type: "null" + description: One-sentence generated reaction to that arrival, standing for the rest of the local day. Null whenever no + line was generated (no provider, no consent, budget exhausted, or generation failed) — consumers fall back + to `briefingLead` / `line`. Additive since v1.31.0. required: - generatedAt - phase @@ -31406,6 +31437,8 @@ components: - briefingLead - line - worthALook + - justIn + - reactionLine additionalProperties: false description: The one data spine of the daily-value system — composed from already-cached data, never a fresh AI call. Consumed by the Today surface, the daily push, and a future iOS widget. diff --git a/e2e/today-just-in.spec.ts b/e2e/today-just-in.spec.ts new file mode 100644 index 000000000..49e00ba81 --- /dev/null +++ b/e2e/today-just-in.spec.ts @@ -0,0 +1,145 @@ +import { expect, test } from "@playwright/test"; +import pg from "pg"; + +import { E2E_USER, STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * v1.31.0 — Today reacts in place. + * + * The milestone's actual moment: new data lands, and the ALREADY-OPEN + * dashboard starts reading differently on the cadence it already runs. No new + * poll, no push, no socket — a state change, not a notification. + * + * The spec drives that end-to-end against the real read path: + * + * 1. load the dashboard with nothing fresh — no chip, day is provisional; + * 2. land last night's sleep through the REAL batch endpoint, and the + * arrival marker the spine's worker would have written; + * 3. bring the tab back to focus — which is one of the three refetch + * triggers the digest hook ALREADY declares (`refetchOnWindowFocus`, + * alongside `staleTime` and the 120 s foreground poll). Using the focus + * trigger keeps the test inside the real cadence while staying well + * under the poll interval, which no test timeout would survive; + * 4. assert the chip appeared and the phase flipped — in place, no reload. + * + * The marker is seeded directly rather than waited for from pg-boss: whether + * the queue drains inside the e2e web server is the SPINE's contract and has + * its own tests. What this spec owns is everything downstream of a marker + * existing — the read path, the DTO, and the surface. + * + * Assertions anchor on `data-slot` / `data-phase`, never on visible text: + * responsive `sm:hidden` classes have broken `getByText` in this repo before. + */ +test.use({ storageState: STORAGE_STATE_PATH }); + +/** The user's local day, in the same profile-tz space the digest files under. */ +function localDayKey(at: Date, timeZone: string): string { + // en-CA renders ISO-ordered YYYY-MM-DD, which is exactly the key shape. + return new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(at); +} + +test("a fresh arrival surfaces the just-in chip and flips the day in place", async ({ + page, +}) => { + const dbUrl = process.env.DATABASE_URL; + test.skip(!dbUrl, "DATABASE_URL is required to seed the arrival marker"); + + const pool = new pg.Pool({ connectionString: dbUrl }); + + try { + const { rows } = await pool.query<{ id: string; timezone: string | null }>( + "SELECT id, timezone FROM users WHERE username = $1", + [E2E_USER.username], + ); + expect(rows.length, "seeded e2e user must exist").toBe(1); + const userId = rows[0].id; + const timezone = rows[0].timezone ?? "UTC"; + + // A clean slate: no marker, and last night's sleep not yet in. Both are + // preconditions for the "before" assertion to mean anything. + await pool.query("DELETE FROM arrival_reactions WHERE user_id = $1", [ + userId, + ]); + await pool.query( + "UPDATE users SET morning_digest_refreshed_on = NULL WHERE id = $1", + [userId], + ); + + // ── BEFORE ──────────────────────────────────────────────────────────── + await page.goto("/"); + const hero = page.locator('[data-slot="today-hero"]'); + await expect(hero).toBeVisible({ timeout: 20_000 }); + await expect(hero.locator('[data-slot="today-hero-just-in"]')).toHaveCount( + 0, + ); + + // ── NEW DATA LANDS ──────────────────────────────────────────────────── + // Last night's sleep through the real ingest route, on the session the + // storage state already carries. + const now = new Date(); + const wokeAt = new Date(now.getTime() - 60 * 60_000); + + const batch = await page.request.post("/api/measurements/batch", { + data: { + measurements: [ + { + type: "SLEEP_DURATION", + value: 7, + unit: "h", + measuredAt: wokeAt.toISOString(), + externalId: `e2e-just-in-${now.getTime()}`, + source: "manual", + }, + ], + }, + }); + expect( + batch.ok(), + `sleep batch must be accepted (got ${batch.status()})`, + ).toBe(true); + + // The marker the spine's worker writes on a salient arrival, plus the + // morning-refresh stamp it rides alongside — the two rows that together + // make the day final and the arrival news. + const localDate = localDayKey(now, timezone); + await pool.query( + `INSERT INTO arrival_reactions + (id, user_id, kind, local_date, occurred_at, created_at) + VALUES ($1, $2, 'sleep_night', $3, $4, NOW()) + ON CONFLICT (user_id, kind, local_date) DO UPDATE + SET occurred_at = EXCLUDED.occurred_at`, + [`c${now.getTime()}justin000000`, userId, localDate, wokeAt], + ); + await pool.query( + "UPDATE users SET morning_digest_refreshed_on = $2 WHERE id = $1", + [userId, localDate], + ); + + // ── THE MOMENT ──────────────────────────────────────────────────────── + // The tab regains focus — one of the digest hook's existing refetch + // triggers. Nothing new is introduced here and the page never reloads. + await page.evaluate(() => window.dispatchEvent(new Event("focus"))); + + await expect(hero.locator('[data-slot="today-hero-just-in"]')).toBeVisible({ + timeout: 20_000, + }); + await expect(hero).toHaveAttribute("data-phase", "final", { + timeout: 20_000, + }); + // Sleep is in, so the provisional freshness note is gone with it. + await expect( + hero.locator('[data-slot="today-hero-sleep-pending"]'), + ).toHaveCount(0); + // The chip names the arrival kind — the stable attribute, not the copy. + await expect( + hero.locator('[data-slot="today-hero-just-in"]'), + ).toHaveAttribute("data-just-in-kind", "sleep_night"); + } finally { + await pool.end(); + } +}); diff --git a/messages/de.json b/messages/de.json index 7bbc97d04..eab8a504b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8856,6 +8856,8 @@ "deltaVsBaseline": "{delta} ggü. deinem Ausgangswert", "deltaFlat": "Im Bereich deines Ausgangswerts", "sleepPending": "Der Schlaf von letzter Nacht fehlt noch – der heutige Wert ist vorläufig.", + "justIn": "Gerade eingetroffen", + "justInAt": "Gerade eingetroffen · {time}", "worthALook": "Einen Blick wert", "allClear": "Heute braucht nichts deine Aufmerksamkeit.", "ringLink": "{metric}-Details öffnen" diff --git a/messages/en.json b/messages/en.json index 2995936fd..6bb0fec0a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8856,6 +8856,8 @@ "deltaVsBaseline": "{delta} vs your baseline", "deltaFlat": "In line with your baseline", "sleepPending": "Last night's sleep isn't in yet — today's read is provisional.", + "justIn": "Just in", + "justInAt": "Just in · {time}", "worthALook": "Worth a look", "allClear": "Nothing needs your attention today.", "ringLink": "Open {metric} details" diff --git a/messages/es.json b/messages/es.json index e3934d442..5e1dd6363 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8856,6 +8856,8 @@ "deltaVsBaseline": "{delta} frente a tu valor de referencia", "deltaFlat": "En línea con tu valor de referencia", "sleepPending": "El sueño de anoche aún no ha llegado; la lectura de hoy es provisional.", + "justIn": "Recién llegado", + "justInAt": "Recién llegado · {time}", "worthALook": "Merece un vistazo", "allClear": "Hoy nada necesita tu atención.", "ringLink": "Abrir detalles de {metric}" diff --git a/messages/fr.json b/messages/fr.json index 9c57bb085..e550fcc29 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8856,6 +8856,8 @@ "deltaVsBaseline": "{delta} par rapport à ta référence", "deltaFlat": "Conforme à ta référence", "sleepPending": "Le sommeil de la nuit dernière n'est pas encore arrivé — la lecture du jour est provisoire.", + "justIn": "Vient d'arriver", + "justInAt": "Vient d'arriver · {time}", "worthALook": "À regarder", "allClear": "Rien ne requiert ton attention aujourd'hui.", "ringLink": "Ouvrir les détails de {metric}" diff --git a/messages/it.json b/messages/it.json index ca31566c5..5719591f9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8856,6 +8856,8 @@ "deltaVsBaseline": "{delta} rispetto al tuo valore di riferimento", "deltaFlat": "In linea con il tuo valore di riferimento", "sleepPending": "Il sonno di stanotte non è ancora arrivato: la lettura di oggi è provvisoria.", + "justIn": "Appena arrivato", + "justInAt": "Appena arrivato · {time}", "worthALook": "Da tenere d'occhio", "allClear": "Oggi nulla richiede la tua attenzione.", "ringLink": "Apri i dettagli di {metric}" diff --git a/messages/pl.json b/messages/pl.json index 2635cfae4..531bbb37e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8856,6 +8856,8 @@ "deltaVsBaseline": "{delta} wzgl. Twojej wartości bazowej", "deltaFlat": "Zgodnie z Twoją wartością bazową", "sleepPending": "Sen z ostatniej nocy jeszcze nie dotarł — dzisiejszy odczyt jest wstępny.", + "justIn": "Właśnie dotarło", + "justInAt": "Właśnie dotarło · {time}", "worthALook": "Warto sprawdzić", "allClear": "Dziś nic nie wymaga Twojej uwagi.", "ringLink": "Otwórz szczegóły: {metric}" diff --git a/src/app/api/daily/digest/__tests__/route.test.ts b/src/app/api/daily/digest/__tests__/route.test.ts index cac7f1bd8..80a297833 100644 --- a/src/app/api/daily/digest/__tests__/route.test.ts +++ b/src/app/api/daily/digest/__tests__/route.test.ts @@ -63,6 +63,8 @@ const DIGEST: DailyDigest = { briefingLead: "Blood pressure is holding steady.", line: "Blood pressure is holding steady.", worthALook: [], + justIn: null, + reactionLine: null, }; const callGet = GET as unknown as (req: NextRequest) => Promise; diff --git a/src/components/daily/__tests__/today-hero-just-in.test.tsx b/src/components/daily/__tests__/today-hero-just-in.test.tsx new file mode 100644 index 000000000..afb978cb1 --- /dev/null +++ b/src/components/daily/__tests__/today-hero-just-in.test.tsx @@ -0,0 +1,202 @@ +/** + * The Today hero's "just in" chip and the reaction line's lead replacement. + * + * Two invariants, both of which have a specific failure this project has + * already paid for: + * + * 1. HYDRATION. The hero is deliberately NOT mount-gated — it paints from the + * server-dehydrated digest on the SSR pass because it is the LCP element + * (v1.30.9). So anything inside it that differs between the server and the + * browser is a React #418 mismatch. A wall-clock time is exactly that: the + * server's locale and timezone are not the reader's. The chip therefore + * renders its SLOT on both passes and fills the time in only after mount. + * These tests render through `renderToStaticMarkup`, which takes the SERVER + * snapshot of `useSyncExternalStore` — i.e. precisely the pass that must + * carry no formatted time. + * + * 2. NO LAYOUT SHIFT. The reaction line REPLACES the lead; it is never a second + * paragraph. The hero stays one lead line tall, so nothing below it moves + * when the line arrives on a poll. + */ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { TodayHero } from "../today-hero"; +import type { DailyDigest } from "@/lib/daily/digest"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + const client = new QueryClient(); + return renderToStaticMarkup( + + {node} + , + ); +} + +const ARRIVED_AT = "2026-07-16T05:41:00.000Z"; + +function digest(over: Partial = {}): DailyDigest { + return { + generatedAt: "2026-07-16T06:00:00.000Z", + phase: "final", + sleepPending: false, + score: { value: 82, band: "green", delta: 3 }, + topSignal: null, + briefingLead: "Your week is trending steady.", + line: "Your week is trending steady.", + worthALook: [], + justIn: null, + reactionLine: null, + ...over, + }; +} + +/** Any hh:mm clock face — the thing the server must never emit. */ +const CLOCK_FACE = /\d{1,2}:\d{2}/; + +describe("TodayHero — the just-in chip", () => { + it("renders no chip when nothing landed", () => { + const html = render(); + expect(html).not.toContain('data-slot="today-hero-just-in"'); + }); + + it("renders the chip slot on the server pass, carrying the kind", () => { + const html = render( + , + ); + + // The slot is present on the SSR pass, so the hydration render — which + // takes the same server snapshot — produces identical HTML. + expect(html).toContain('data-slot="today-hero-just-in"'); + expect(html).toContain('data-just-in-kind="sleep_night"'); + expect(html).toContain("Just in"); + }); + + it("emits NO formatted time and no raw ISO instant on the server pass", () => { + const html = render( + , + ); + + const chip = html.slice(html.indexOf('data-slot="today-hero-just-in"')); + // The mismatch itself: a wall clock the browser would render differently. + expect(chip).not.toMatch(CLOCK_FACE); + // And the raw instant must not leak either — it is machine data, not copy. + expect(html).not.toContain(ARRIVED_AT); + }); + + it("keeps the chip muted meta — never an accent, never an opacity modifier", () => { + const html = render( + , + ); + + const row = html.slice( + 0, + html.indexOf('data-slot="today-hero-just-in"') + 400, + ); + expect(row).toContain("text-muted-foreground"); + expect(row).toContain("text-xs"); + // UI-STANDARDS §text: alpha on muted text drops below AA contrast. + expect(html).not.toContain("text-muted-foreground/"); + expect(html).not.toContain("text-primary"); + }); + + it("shows the chip alongside a still-pending night", () => { + // Legitimately co-occurring: a weight landed this morning while last + // night's sleep has not arrived yet. + const html = render( + , + ); + + expect(html).toContain('data-slot="today-hero-sleep-pending"'); + expect(html).toContain('data-slot="today-hero-just-in"'); + }); + + it("localises the chip", () => { + const html = render( + , + "de", + ); + expect(html).toContain("Gerade eingetroffen"); + expect(html).not.toContain("Just in"); + }); +}); + +describe("TodayHero — the reaction line replaces the lead", () => { + const REACTION = "A solid night, deeper than your recent stretch."; + + it("renders the reaction line instead of the briefing lead", () => { + const html = render( + , + ); + + expect(html).toContain(REACTION); + // The lead it replaced must be GONE, not pushed down into a second block. + expect(html).not.toContain("Your week is trending steady."); + }); + + it("stays exactly one lead line tall — no second paragraph, no shift", () => { + const html = render( + , + ); + + const leadSlots = html.split('data-slot="today-hero-lead"').length - 1; + expect(leadSlots).toBe(1); + }); + + it("falls back to the briefing lead when no line was generated", () => { + const html = render( + , + ); + expect(html).toContain("Your week is trending steady."); + }); + + it("still renders for an otherwise-bare account when a line exists", () => { + // The one moment this surface exists for must not be swallowed by the + // empty-account degrade. + const html = render( + , + ); + + expect(html).toContain('data-slot="today-hero"'); + expect(html).toContain(REACTION); + }); +}); diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index 5a7539b68..9d5949ab6 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -63,6 +63,8 @@ function digest(over: Partial = {}): DailyDigest { briefingLead: "Your week is trending steady.", line: "Your week is trending steady.", worthALook: [doseItem, syncItem], + justIn: null, + reactionLine: null, ...over, }; } diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index 9550dd18d..8a19e25f0 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -37,6 +37,7 @@ import Link from "next/link"; import { Moon } from "lucide-react"; +import { useMounted } from "@/hooks/use-mounted"; import { ScoreRing } from "@/components/insights/derived/score-ring"; import type { ScoreBand } from "@/components/insights/derived/band-tokens"; import { ProseBlocks } from "@/components/insights/prose-blocks"; @@ -62,8 +63,40 @@ function formatDelta( return t("daily.today.deltaVsBaseline", { delta: signed }); } +/** + * The "just in" chip's clock face. + * + * Formatted CLIENT-side, and only after mount. `justIn.at` crosses the wire as + * an ISO instant precisely so this stays a client concern: the server has no + * business rendering a local wall-clock time it would format under its OWN + * locale and timezone, and any such string would differ from the browser's on + * the hydration render — React #418, a class of bug this project has already + * paid for once. + * + * `undefined` locale lets the browser pick the user's own regional format + * (24-hour here, 12-hour there); the chip is a timestamp, not copy. + */ +function formatJustInTime(iso: string): string | null { + const at = new Date(iso); + if (Number.isNaN(at.getTime())) return null; + try { + return at.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return null; + } +} + export function TodayHero({ digest }: { digest: DailyDigest }) { const { t } = useTranslations(); + // Gates ONLY the chip's time string — never the chip's presence. The hero + // itself is deliberately NOT mount-gated (v1.30.9: it is the LCP element and + // paints from the server-dehydrated digest on both the SSR and the hydration + // render). So the slot renders on both passes and the formatted time fills in + // on the first client re-render, keeping those two passes byte-identical. + const mounted = useMounted(); const { keep, letGo } = useCoachCheckinAction(); const dismissItem = usePriorityItemDismiss(); @@ -83,17 +116,25 @@ export function TodayHero({ digest }: { digest: DailyDigest }) { const hasScore = digest.score !== null; const hasItems = digest.worthALook.length > 0; - // The briefing lead is the warmest read; the deterministic `line` is the - // floor a keyless self-hoster still gets. Prefer the lead for the hero. - const lead = digest.briefingLead ?? digest.line; + // Lead precedence, warmest first: the day's reaction line REPLACES the + // briefing lead once something landed — it never appends as a second + // paragraph, so the hero stays exactly one lead line tall and nothing below + // it shifts. The briefing lead is the standing read; the deterministic + // `line` is the floor a keyless self-hoster still gets. + const lead = digest.reactionLine ?? digest.briefingLead ?? digest.line; const topSignal = digest.topSignal; + const justInTime = digest.justIn ? formatJustInTime(digest.justIn.at) : null; // Calm degrade (plan §3): a genuinely empty account — no score, no rail // items, and no cached briefing lead — surfaces nothing here. The tile // strip below carries its own "add your first reading" empty state, so a // second alarming empty card on the hero would be noise. The all-clear // state (score present, nothing needs attention) is handled inline below. - if (!hasScore && !hasItems && !digest.briefingLead) { + // A freshly-generated reaction line counts as content in its own right: an + // otherwise-bare account whose first reading just landed has something + // honest to say, and swallowing it here would hide the one moment this + // surface exists for. + if (!hasScore && !hasItems && !digest.briefingLead && !digest.reactionLine) { return null; } @@ -184,17 +225,46 @@ export function TodayHero({ digest }: { digest: DailyDigest }) { - {/* Freshness note (plan §2.4) — provisional day, last night's sleep - not yet folded in. Muted, non-blocking, refreshes in place when - the morning job lands. */} - {digest.sleepPending ? ( -

-

+ {/* Meta row — the hero's quiet tier (UI-STANDARDS §text: `text-xs + text-muted-foreground` is the meta floor; never an accent, never an + opacity modifier). Holds the freshness note and the "just in" chip; + they can legitimately co-occur (a weight landed while last night's + sleep is still pending), so they share one wrapping row rather than + competing for the same slot. */} + {digest.sleepPending || digest.justIn ? ( +
+ {/* Freshness note (plan §2.4) — provisional day, last night's + sleep not yet folded in. Muted, non-blocking, refreshes in + place when the morning job lands. */} + {digest.sleepPending ? ( +

+

+ ) : null} + + {/* The "just in" chip — a state change, not a notification. No + badge count, no accent, no animation: the record simply reads + differently on the next refresh, and this names why. It is + present for as long as the arrival is news (the server applies + the window, so the chip expires on a poll rather than on a + client timer) and carries the arrival's local time once the + client has mounted and can format it honestly. */} + {digest.justIn ? ( +

+ {mounted && justInTime + ? t("daily.today.justInAt", { time: justInTime }) + : t("daily.today.justIn")} +

+ ) : null} +
) : null} {/* Worth-a-look rail — S1's `PriorityCard`s, bounded 0–3. When the diff --git a/src/lib/ai/ai-budgets.ts b/src/lib/ai/ai-budgets.ts index 221bfb654..45b5d30b4 100644 --- a/src/lib/ai/ai-budgets.ts +++ b/src/lib/ai/ai-budgets.ts @@ -140,6 +140,18 @@ export const AI_BUDGETS = { */ coachNudge: { temperature: 0.6, maxTokens: 160, timeoutMs: 9000 }, + /** + * v1.31.0 — the arrival reaction line. ONE sentence replacing the Today + * hero's lead for the rest of the day, written at most once per arrival kind + * per local day (the `ArrivalReaction` unique row is the throttle). 220 + * tokens is generous for a single sentence and leaves room for a model that + * warms up before it commits. Temperature between the reference surfaces and + * the conversational ones: the verdict must stay stable against the same + * evidence, but a line the user reads every day should not read as a + * template. + */ + arrivalReaction: { temperature: 0.45, maxTokens: 220, timeoutMs: 12_000 }, + /** * Coach rolling-conversation summary worker — a compact summary of the * elided older turns. 200 tokens. diff --git a/src/lib/ai/prompts/arrival-reaction.ts b/src/lib/ai/prompts/arrival-reaction.ts new file mode 100644 index 000000000..830a96aa9 --- /dev/null +++ b/src/lib/ai/prompts/arrival-reaction.ts @@ -0,0 +1,99 @@ +/** + * The arrival reaction line — one sentence, written once per arrival kind per + * local day, that replaces the Today hero's standing lead for the rest of the + * day. + * + * This is the smallest AI surface in the product and it is deliberately the + * most constrained. It says ONE thing: something landed, and here is what it + * means against this person's own history. It is not a summary, not a plan, + * and not an assessment card — those surfaces exist and this one must not + * drift into them. + * + * LOCALE — the load-bearing detail. The body composes through + * `getBaseSystemPromptBody(locale)` and closes with `withOutputLanguage`, + * which is the SIX-locale path: German readers get the German body, and + * fr/es/it/pl get the English body plus a directive, in their own language, + * naming the language to write in. Do NOT reintroduce a local + * `locale === "de" ? "German" : "English"` here — that two-locale collapse is + * exactly the defect the base-system path was fixed to remove, and it silently + * ships English prose to four locales. + */ +import type { Locale } from "@/lib/i18n/config"; +import type { ArrivalKind } from "@/lib/arrivals/types"; + +import { getBaseSystemPromptBody } from "./base-system"; +import { instructionLocale, withOutputLanguage } from "./output-language"; + +/** + * The reaction contract, appended to the shared assessment base. + * + * Every clause here is a bound on FORM, not on content — the base prompt + * already owns the safety contract (no diagnosis, no prescription, earned + * encouragement, associations never causal). What this adds is the shape that + * makes a one-line reaction readable in a hero: verdict first, the number as + * support rather than as the opener, and a hard stop at one sentence. + */ +const REACTION_CONTRACT_EN = `SURFACE — THE ARRIVAL REACTION LINE: +- Write EXACTLY ONE sentence. Not two. It renders as a single hero line and anything longer is truncated. +- Lead with the MEANING, not the measurement. "A solid night, deeper than your recent stretch — 7 h 12 min behind it" reads correctly; "Your sleep is 7 h 12 min" does not. The figure is support; it may appear, but never as the opening clause. +- Compare against THIS PERSON'S OWN recent baseline, which the evidence block carries already computed. State it; never recompute it, never invent a comparison the block does not contain. +- Never exclamatory. No exclamation marks, no "great job", no congratulation the numbers did not earn. An unfavourable reading is named plainly and without alarm. +- Never diagnostic and never prescriptive: no condition names, no risk levels, no dose or treatment suggestions, no training instructions. +- When the evidence block is too thin to support a verdict, say so plainly in that one sentence. A manufactured verdict is worse than an honest "not much to go on yet". +- Plain text only — no markdown, no quotation marks around the sentence, no emoji.`; + +const REACTION_CONTRACT_DE = `OBERFLÄCHE — DIE REAKTIONSZEILE ZUM DATENEINGANG: +- Schreibe GENAU EINEN Satz. Nicht zwei. Er erscheint als einzelne Hero-Zeile; alles Längere wird abgeschnitten. +- Führe mit der BEDEUTUNG, nicht mit dem Messwert. "Eine solide Nacht, tiefer als zuletzt — 7 h 12 min tragen sie" liest sich richtig; "Dein Schlaf beträgt 7 h 12 min" nicht. Die Zahl stützt; sie darf vorkommen, aber nie als einleitender Satzteil. +- Vergleiche gegen die EIGENE jüngste Baseline dieser Person, die der Evidenzblock bereits fertig berechnet mitführt. Nenne sie; rechne sie nicht neu und erfinde keinen Vergleich, den der Block nicht enthält. +- Nie ausrufend. Keine Ausrufezeichen, kein "super gemacht", kein Lob, das die Zahlen nicht hergeben. Ein ungünstiger Wert wird sachlich und ohne Alarm benannt. +- Nie diagnostisch und nie verordnend: keine Krankheitsnamen, keine Risikostufen, keine Dosis- oder Therapievorschläge, keine Trainingsanweisungen. +- Wenn der Evidenzblock zu dünn für ein Urteil ist, sage das in diesem einen Satz klar. Ein konstruiertes Urteil ist schlechter als ein ehrliches "dafür ist es noch zu wenig". +- Nur Fließtext — kein Markdown, keine Anführungszeichen um den Satz, keine Emojis.`; + +export function getArrivalReactionSystemPrompt(locale: Locale): string { + // fr/es/it/pl compose the ENGLISH body — the base prompt names their own + // language and `withOutputLanguage` appends their directive last, so the + // language instruction is the final thing the model reads. + const contract = + instructionLocale(locale) === "en" + ? REACTION_CONTRACT_EN + : REACTION_CONTRACT_DE; + + return withOutputLanguage( + `${getBaseSystemPromptBody(locale)} + +${contract}`, + locale, + ); +} + +/** What kind of arrival the line is reacting to, in the model's own terms. */ +const KIND_SUBJECT: Record = { + sleep_night: "last night's completed sleep", + workout: "a workout the person just finished", + weight: "the first weight reading of the day", + blood_pressure: "a fresh blood-pressure reading", + labs_panel: "a new lab panel", +}; + +export interface ArrivalReactionPromptInput { + kind: ArrivalKind; + /** + * The deterministic, already-grounded evidence the digest read path + * computed. Passed verbatim — the model is never asked to derive a figure, + * only to say what the block already establishes. + */ + evidence: string; +} + +export function getArrivalReactionUserPrompt( + input: ArrivalReactionPromptInput, +): string { + return `WHAT JUST LANDED: ${KIND_SUBJECT[input.kind]}. + +EVIDENCE BLOCK — everything below is already computed from this person's own record. Use only what is here; if it does not support a verdict, say so. +${input.evidence} + +Write the one-sentence reaction line. Return only the sentence.`; +} diff --git a/src/lib/arrivals/reaction-line-shared.ts b/src/lib/arrivals/reaction-line-shared.ts new file mode 100644 index 000000000..8c587a792 --- /dev/null +++ b/src/lib/arrivals/reaction-line-shared.ts @@ -0,0 +1,78 @@ +/** + * Generator-free enqueue contract for the `reaction-line-generate` queue. + * + * Same split as `emit-shared.ts`, and for the same reason: the spine's worker + * (`@/lib/jobs/data-arrival`) must be able to ENQUEUE a reaction line without + * importing the module that GENERATES one. The generator reaches a provider; + * the spine's central cost claim is that it structurally cannot. A shared + * queue-name-plus-payload module is what keeps both true at once, and the + * spine's module-graph guard (`data-arrival-provider-isolation.test.ts`) + * fails the moment this file grows an import that breaks it. + * + * Keep this module provider-free and worker-free. + */ +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { annotate } from "@/lib/logging/context"; + +import type { ArrivalKind } from "./types"; + +export const REACTION_LINE_QUEUE = "reaction-line-generate"; + +/** + * The generation job's payload. It carries the row's identity, not its + * content: the worker re-reads the `ArrivalReaction` row it names, so a job + * that sat in the queue while the day moved on can still decide honestly + * whether the line is still wanted. + */ +export interface ReactionLineJob { + userId: string; + kind: ArrivalKind; + /** The user's profile-tz YYYY-MM-DD the marker is filed under. */ + localDate: string; +} + +/** + * Enqueue the day's single reaction-line generation for one arrival kind. + * + * The CALLER owns the throttle, and it is not a timer: the spine enqueues this + * only on the pass that freshly INSERTED the day's `ArrivalReaction` row, so + * the row's `@@unique([userId, kind, localDate])` constraint is what bounds + * the spend to one call per kind per local day. There is deliberately no + * second entry point — a surface that wants a line asks the spine for an + * arrival, it does not call the generator. + * + * The singleton key restates that claim at the queue level so a retried spine + * job cannot stack a second generation behind the first. + * + * Never throws. A queue that is not there means no line, and no line is a + * fully-supported state. + */ +export async function enqueueReactionLine(job: ReactionLineJob): Promise { + try { + const boss = getGlobalBoss(); + if (!boss) { + annotate({ + action: { name: "arrival.reaction_line.skipped" }, + meta: { reason: "no_boss", kind: job.kind }, + }); + return; + } + + await boss.send(REACTION_LINE_QUEUE, job, { + singletonKey: `reaction-line:${job.userId}:${job.kind}:${job.localDate}`, + // The LLM retry policy, not the spine's: a provider hiccup is worth one + // patient retry, and a second failure leaves the row line-less — which + // is a first-class state, not an error to keep chasing. + retryLimit: 1, + retryDelay: 120, + retryBackoff: true, + }); + + annotate({ + action: { name: "arrival.reaction_line.enqueued" }, + meta: { kind: job.kind, local_date: job.localDate }, + }); + } catch { + // The data landed and the marker is written; the sentence is garnish. + } +} diff --git a/src/lib/daily/__tests__/daily-briefing-push.test.ts b/src/lib/daily/__tests__/daily-briefing-push.test.ts index 6aae8c602..8ef31e2b4 100644 --- a/src/lib/daily/__tests__/daily-briefing-push.test.ts +++ b/src/lib/daily/__tests__/daily-briefing-push.test.ts @@ -34,6 +34,8 @@ function makeDigest(over: Partial = {}): DailyDigest { briefingLead: "Sleep looked solid last night.", line: "Sleep looked solid last night.", worthALook: [], + justIn: null, + reactionLine: null, ...over, }; } diff --git a/src/lib/daily/__tests__/digest-just-in.test.ts b/src/lib/daily/__tests__/digest-just-in.test.ts new file mode 100644 index 000000000..54a4f401a --- /dev/null +++ b/src/lib/daily/__tests__/digest-just-in.test.ts @@ -0,0 +1,157 @@ +/** + * The "just in" fields of the daily digest. + * + * Two clocks are under test and they are deliberately different, which is the + * whole point of this file: the CHIP expires with the news (three hours), the + * LINE stands for the rest of the local day. Collapsing them onto one clock is + * the most plausible future regression here — it reads like a simplification + * and it silently deletes the day's read at lunchtime. + */ +import { describe, it, expect } from "vitest"; + +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import type { MedsTodayBlock } from "@/lib/dashboard/meds-today"; +import { + buildDailyDigest, + JUST_IN_WINDOW_MS, + type DailyDigestArrival, + type DailyDigestInput, +} from "@/lib/daily/digest"; + +const t = getServerTranslator("en").t; +const NOW = new Date("2026-07-16T09:00:00.000Z"); + +function meds(): MedsTodayBlock { + return { + activeCount: 0, + scheduledToday: 0, + takenToday: 0, + skippedToday: 0, + nextDueAt: null, + nextDueOverdue: false, + nextDueMedicationName: null, + nextDueMedicationId: null, + }; +} + +function input(over: Partial = {}): DailyDigestInput { + return { + now: NOW, + modules: {}, + score: { value: 82, band: "good", delta: 3 }, + briefing: null, + medsToday: meds(), + sleepLastSeenDaysAgo: 0, + morningRefreshedToday: false, + syncIssues: [], + preventiveDue: [], + coachPlans: [], + tensionWindow: null, + todayLocalDate: "2026-07-16", + dismissedItemKeys: new Set(), + ...over, + }; +} + +/** An arrival `agoMs` before NOW. */ +function arrival( + agoMs: number, + over: Partial = {}, +): DailyDigestArrival { + return { + kind: "sleep_night", + occurredAt: new Date(NOW.getTime() - agoMs), + line: null, + ...over, + }; +} + +describe("buildDailyDigest — justIn", () => { + it("is null when nothing landed today", () => { + const digest = buildDailyDigest(input(), t); + expect(digest.justIn).toBeNull(); + expect(digest.reactionLine).toBeNull(); + }); + + it("names the arrival and carries an ISO instant, never a formatted time", () => { + const at = new Date(NOW.getTime() - 60_000); + const digest = buildDailyDigest( + input({ arrivals: [arrival(60_000, { kind: "weight" })] }), + t, + ); + + expect(digest.justIn).toEqual({ kind: "weight", at: at.toISOString() }); + // The wire value must stay machine-readable. A server-formatted wall clock + // here is the React #418 bug in its exact original shape. + expect(digest.justIn?.at).toMatch(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/); + }); + + it("shows the chip inside the window and drops it after", () => { + const inside = buildDailyDigest( + input({ arrivals: [arrival(JUST_IN_WINDOW_MS - 60_000)] }), + t, + ); + expect(inside.justIn).not.toBeNull(); + + const outside = buildDailyDigest( + input({ arrivals: [arrival(JUST_IN_WINDOW_MS + 60_000)] }), + t, + ); + expect(outside.justIn).toBeNull(); + }); + + it("keeps the reaction line after the chip has expired", () => { + // The load-bearing asymmetry: past the window the record is no longer + // NEWS, but the sentence is still the day's READ and must survive. + const digest = buildDailyDigest( + input({ + arrivals: [ + arrival(JUST_IN_WINDOW_MS + 60 * 60_000, { + line: "A solid night, deeper than your recent stretch.", + }), + ], + }), + t, + ); + + expect(digest.justIn).toBeNull(); + expect(digest.reactionLine).toBe( + "A solid night, deeper than your recent stretch.", + ); + }); + + it("picks the newest arrival when several landed", () => { + const digest = buildDailyDigest( + input({ + arrivals: [ + arrival(2 * 60 * 60_000, { kind: "weight", line: "older" }), + arrival(10 * 60_000, { kind: "workout", line: "newer" }), + arrival(60 * 60_000, { kind: "blood_pressure", line: "middle" }), + ], + }), + t, + ); + + expect(digest.justIn?.kind).toBe("workout"); + expect(digest.reactionLine).toBe("newer"); + }); + + it("treats a blank generated line as absent rather than shipping an empty lead", () => { + const digest = buildDailyDigest( + input({ arrivals: [arrival(60_000, { line: " " })] }), + t, + ); + + expect(digest.justIn).not.toBeNull(); + expect(digest.reactionLine).toBeNull(); + }); + + it("stays pure — the composer never mutates the arrivals it was handed", () => { + const arrivals = [arrival(60_000), arrival(30_000, { kind: "weight" })]; + const snapshot = JSON.stringify(arrivals); + + buildDailyDigest(input({ arrivals }), t); + + expect(JSON.stringify(arrivals)).toBe(snapshot); + }); +}); diff --git a/src/lib/daily/__tests__/load-digest.test.ts b/src/lib/daily/__tests__/load-digest.test.ts index 5e775572c..f6c2df61a 100644 --- a/src/lib/daily/__tests__/load-digest.test.ts +++ b/src/lib/daily/__tests__/load-digest.test.ts @@ -19,6 +19,7 @@ vi.mock("@/lib/db", () => ({ ecgRecording: { findFirst: vi.fn().mockResolvedValue(null) }, dismissedPriorityItem: { findMany: vi.fn().mockResolvedValue([]) }, personalRecord: { findMany: vi.fn().mockResolvedValue([]) }, + arrivalReaction: { findMany: vi.fn().mockResolvedValue([]) }, }, })); diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 8fe0ff7b4..7f93ee277 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -20,6 +20,7 @@ * populate the same two fields the DTO already carries, so no consumer changes. */ import type { DailyBriefing, DailyBriefingSignal } from "@/lib/ai/schema"; +import type { ArrivalKind } from "@/lib/arrivals/types"; import type { MedsTodayBlock } from "@/lib/dashboard/meds-today"; import type { ModuleKey } from "@/lib/modules/registry"; import type { ServerTranslator } from "@/lib/i18n/server-translator"; @@ -151,6 +152,32 @@ export interface DailyDigestCoachPlan { planText: string | null; } +/** + * A day's arrival marker, as the IO seam read it off `ArrivalReaction`. + * + * One row per (user, kind, local date) — the spine's unique claim. The builder + * picks the NEWEST of them for the "just in" chip and takes its reaction line; + * it never sees more than the day's own rows, and it never reads a row it did + * not ask for. + */ +export interface DailyDigestArrival { + kind: ArrivalKind; + /** Timestamp of the newest sample that triggered this arrival. */ + occurredAt: Date; + /** Decrypted reaction line, or null when none was ever generated. */ + line: string | null; +} + +/** + * How long an arrival stays NEWS. + * + * Past this the record is simply current — the reading is still today's, but + * announcing it is no longer telling the user anything they don't know. The + * chip disappears on its own; the reaction LINE does not (see below), because + * the sentence is the day's read, not a notification. + */ +export const JUST_IN_WINDOW_MS = 3 * 60 * 60 * 1000; + /** The fully-resolved, IO-free input the composer folds into a digest. */ export interface DailyDigestInput { now: Date; @@ -203,6 +230,12 @@ export interface DailyDigestInput { * never rendered with its actions suppressed. */ dismissedItemKeys: ReadonlySet; + /** + * Today's arrival markers (`ArrivalReaction` rows for `todayLocalDate`), in + * any order. Optional so a consumer that predates the arrival spine stays + * valid; the builder treats a missing value as "nothing landed today". + */ + arrivals?: readonly DailyDigestArrival[]; } export interface DailyDigest { @@ -226,6 +259,26 @@ export interface DailyDigest { line: string; /** Bounded 0–3 rail items, never padded. */ worthALook: PriorityItem[]; + /** + * The day's newest arrival while it is still news (< `JUST_IN_WINDOW_MS` + * old), else null. Drives the hero's calm "just in" chip. + * + * `at` crosses the wire as an ISO-8601 string and is formatted CLIENT-side — + * a server-formatted local time would differ from the client's render and + * produce a hydration mismatch (React #418). + */ + justIn: { kind: ArrivalKind; at: string } | null; + /** + * The generated reaction line for the day's newest arrival, or null. + * + * Deliberately NOT gated on the just-in window: the chip is the "this just + * landed" moment and expires, while the sentence is the day's read and + * stands for the rest of the local day. Null whenever no line was generated + * — no provider, no consent, budget exhausted, or the generation failed — + * in which case the surface falls back to the deterministic lead. The + * sentence is garnish; its absence degrades nothing. + */ + reactionLine: string | null; } /** First sentence of a paragraph, trimmed; null when empty. */ @@ -647,6 +700,33 @@ export function buildDailyDigest( const topSignal = input.briefing?.signalsOfDay?.[0] ?? null; const briefingLead = firstSentence(input.briefing?.paragraph); + // The day's newest arrival drives both reaction fields, but on two different + // clocks: the chip expires with the news, the sentence stands for the day. + // Reducing rather than sorting keeps the composer allocation-free and total + // over an empty list. + const newestArrival = ( + input.arrivals ?? [] + ).reduce( + (newest, candidate) => + newest === null || candidate.occurredAt > newest.occurredAt + ? candidate + : newest, + null, + ); + const arrivalAgeMs = newestArrival + ? input.now.getTime() - newestArrival.occurredAt.getTime() + : null; + const justIn = + newestArrival && arrivalAgeMs !== null && arrivalAgeMs < JUST_IN_WINDOW_MS + ? { + kind: newestArrival.kind, + at: newestArrival.occurredAt.toISOString(), + } + : null; + // An empty or whitespace-only line is treated as absent rather than shipped + // as a blank lead — a degraded generation must fall through to the floor. + const reactionLine = newestArrival?.line?.trim() || null; + // Priority order: a due or overdue dose is the most time-sensitive daily // action, a broken sync next, then the calm coach check-in, a preventive // check-up least urgent. Bounded to 3 — a check-in the cap crowds out @@ -717,5 +797,7 @@ export function buildDailyDigest( briefingLead, line: composeLine(briefingLead, topSignal, input.score, t), worthALook: visible.slice(0, MAX_WORTH_A_LOOK), + justIn, + reactionLine, }; } diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index 9464470e9..1c683d6e1 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -24,9 +24,11 @@ import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { userDayKey } from "@/lib/tz/format"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { DASHBOARD_REFETCH_INTERVAL_MS } from "@/lib/queries/refetch-interval"; +import { isArrivalKind } from "@/lib/arrivals/types"; import { buildDailyDigest, type DailyDigest, + type DailyDigestArrival, type DailyDigestCoachPlan, type DailyDigestEcg, type DailyDigestPreventiveDue, @@ -239,10 +241,50 @@ function resolveLocale(locale: string | null | undefined): Locale { : defaultLocale; } +/** + * Today's arrival markers, decrypted fault-isolated. + * + * One indexed read on `[userId, localDate]` — the exact shape of the model's + * `@@index`, and at most one row per arrival kind, so the result is bounded by + * the closed `ARRIVAL_KINDS` enum rather than by anything the user can grow. + * + * A line that fails to decrypt (a key rotated out from under the row) yields a + * null line, never a throw: the digest is a hot, must-not-fail path, and the + * marker itself — which is what actually drives the "just in" chip — survives + * a lost sentence intact. Same discipline as `toCoachPlanCandidate` above. + */ +function toDigestArrival(row: { + kind: string; + occurredAt: Date; + lineEncrypted: Uint8Array | null; + generatedAt: Date | null; +}): DailyDigestArrival | null { + // A kind this build does not know about (a row written by a newer version) + // is dropped rather than widened — the DTO's kind union is closed. + if (!isArrivalKind(row.kind)) return null; + + let line: string | null = null; + // The ciphertext rides only once the generation actually COMMITTED. A row + // mid-generation carries no `generatedAt`, and its line must not surface. + if (row.generatedAt !== null && row.lineEncrypted !== null) { + try { + line = decryptFromBytes(row.lineEncrypted); + } catch { + line = null; + } + } + + return { kind: row.kind, occurredAt: row.occurredAt, line }; +} + export async function loadDailyDigest( user: User, now: Date = new Date(), ): Promise { + // Computed before the fan-out because the arrival read is keyed on it. Pure + // (a tz format of `now`), so hoisting it costs nothing. + const todayLocalDate = userDayKey(now, user.timezone); + const [ { body: snapshot, locale }, modules, @@ -250,6 +292,7 @@ export async function loadDailyDigest( dueReminders, planRows, ecgRow, + arrivalRows, ] = await Promise.all([ readDashboardSnapshotCached(user), resolveModuleMap(user.id), @@ -303,6 +346,18 @@ export async function loadDailyDigest( orderBy: { recordedAt: "desc" }, select: { recordedAt: true, rhythmClassification: true }, }), + // The arrival spine's markers for the user's CURRENT local day. At most + // one row per arrival kind by the model's unique constraint, so this is a + // bounded indexed read, not a scan. + prisma.arrivalReaction.findMany({ + where: { userId: user.id, localDate: todayLocalDate }, + select: { + kind: true, + occurredAt: true, + lineEncrypted: true, + generatedAt: true, + }, + }), ]); const score: DailyDigestScore | null = snapshot.healthScore @@ -321,7 +376,6 @@ export async function loadDailyDigest( // local date (profile tz). Read fresh off the row here, so it flips the // instant the refresh job runs — ahead of the snapshot cache that feeds // `sleepLastSeenDaysAgo`. - const todayLocalDate = userDayKey(now, user.timezone); const morningRefreshedToday = user.morningDigestRefreshedOn !== null && user.morningDigestRefreshedOn === todayLocalDate; @@ -423,6 +477,9 @@ export async function loadDailyDigest( latestEcg, todayLocalDate, dismissedItemKeys, + arrivals: arrivalRows + .map(toDigestArrival) + .filter((a): a is DailyDigestArrival => a !== null), }, t, ); @@ -440,6 +497,8 @@ export async function loadDailyDigest( daily_digest_has_milestone: digest.worthALook.some( (i) => i.kind === "milestone", ), + daily_digest_just_in_kind: digest.justIn?.kind ?? null, + daily_digest_has_reaction_line: digest.reactionLine !== null, }, }); diff --git a/src/lib/jobs/__tests__/reaction-line-degradation.test.ts b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts new file mode 100644 index 000000000..b58546842 --- /dev/null +++ b/src/lib/jobs/__tests__/reaction-line-degradation.test.ts @@ -0,0 +1,301 @@ +/** + * Degradation is the headline, not the AI. + * + * The milestone's actual moment is the STATE CHANGE: something landed, the + * record now reads differently, and the open dashboard shows it. The generated + * sentence is garnish on top of that. This file exists to keep those two + * things separable forever — because the tempting future refactor is to treat + * the line as the feature and let a provider-less install fall off the surface + * with it. + * + * So: for every reason a line can fail to exist — no provider, no consent, an + * exhausted budget, a dead provider, an unusable output — the row must stay + * line-less WITHOUT spending anything it did not reconcile, and the digest must + * still surface the chip. The last assertion of each case is the one that + * matters: `justIn` survives. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { buildDailyDigest, type DailyDigestInput } from "@/lib/daily/digest"; + +const findUnique = vi.fn(); +const update = vi.fn(); +const userFindUnique = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + arrivalReaction: { + findUnique: (...a: unknown[]) => findUnique(...a), + update: (...a: unknown[]) => update(...a), + }, + user: { findUnique: (...a: unknown[]) => userFindUnique(...a) }, + }, +})); + +const resolveProviderChain = vi.fn(); +vi.mock("@/lib/ai/provider", () => ({ + resolveProviderChain: (...a: unknown[]) => resolveProviderChain(...a), +})); + +const chainRequiresServerManagedConsent = vi.fn(); +const hasActiveConsentForSurface = vi.fn(); +vi.mock("@/lib/ai/consent-guard", () => ({ + chainRequiresServerManagedConsent: (...a: unknown[]) => + chainRequiresServerManagedConsent(...a), + hasActiveConsentForSurface: (...a: unknown[]) => + hasActiveConsentForSurface(...a), +})); + +const reserveBudget = vi.fn(); +const reconcileSpend = vi.fn(); +vi.mock("@/lib/ai/coach/budget", () => ({ + buildDateKey: () => "2026-07-16", + reserveBudget: (...a: unknown[]) => reserveBudget(...a), + reconcileSpend: (...a: unknown[]) => reconcileSpend(...a), + resolveDailyCap: () => 200_000, +})); + +const loadDailyDigest = vi.fn(); +vi.mock("@/lib/daily/load-digest", () => ({ + loadDailyDigest: (...a: unknown[]) => loadDailyDigest(...a), +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/jobs/reminder/shared", () => ({ workerLog: vi.fn() })); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + encryptToBytes: (s: string) => new TextEncoder().encode(s), +})); + +import { runReactionLine } from "@/lib/jobs/reaction-line"; + +const JOB = { + userId: "u1", + kind: "sleep_night" as const, + localDate: "2026-07-16", +}; + +const t = getServerTranslator("en").t; +const NOW = new Date("2026-07-16T09:00:00.000Z"); + +/** + * The digest a user sees when the line was never written. Built from the REAL + * composer, not a hand-shaped object, so this asserts the actual degrade path + * rather than a fixture that agrees with the test. + */ +function degradedDigest() { + const input: DailyDigestInput = { + now: NOW, + modules: {}, + score: { value: 82, band: "good", delta: 3 }, + briefing: null, + medsToday: { + activeCount: 0, + scheduledToday: 0, + takenToday: 0, + skippedToday: 0, + nextDueAt: null, + nextDueOverdue: false, + nextDueMedicationName: null, + nextDueMedicationId: null, + }, + sleepLastSeenDaysAgo: 0, + morningRefreshedToday: true, + syncIssues: [], + preventiveDue: [], + coachPlans: [], + tensionWindow: null, + todayLocalDate: "2026-07-16", + dismissedItemKeys: new Set(), + // The marker exists; the line does not. This is the provider-less shape. + arrivals: [ + { + kind: "sleep_night", + occurredAt: new Date(NOW.getTime() - 60_000), + line: null, + }, + ], + }; + return buildDailyDigest(input, t); +} + +/** Every degrade case must leave the surface fully functional. */ +function expectSurfaceStillWorks() { + const digest = degradedDigest(); + // The chip — the moment itself — is untouched by the missing sentence. + expect(digest.justIn).toEqual({ + kind: "sleep_night", + at: new Date(NOW.getTime() - 60_000).toISOString(), + }); + // The provisional→final flip is likewise unaffected. + expect(digest.phase).toBe("final"); + expect(digest.sleepPending).toBe(false); + // And the hero still has a lead to render: the deterministic floor. + expect(digest.reactionLine).toBeNull(); + expect(digest.line.length).toBeGreaterThan(0); +} + +beforeEach(() => { + vi.clearAllMocks(); + findUnique.mockResolvedValue({ id: "r1", generatedAt: null }); + userFindUnique.mockResolvedValue({ id: "u1", locale: "en", timezone: "UTC" }); + chainRequiresServerManagedConsent.mockReturnValue(false); + hasActiveConsentForSurface.mockResolvedValue(true); + reserveBudget.mockResolvedValue({ + allowed: true, + reserved: 1_400, + totalAfter: 1_400, + }); + reconcileSpend.mockResolvedValue(undefined); + loadDailyDigest.mockResolvedValue({ + score: { value: 82, band: "good", delta: 3 }, + topSignal: null, + briefingLead: "Steady week.", + }); +}); + +describe("reaction line — degradation", () => { + it("no provider: writes nothing, spends nothing, surface intact", async () => { + resolveProviderChain.mockResolvedValue([]); + + const outcome = await runReactionLine(JOB); + + expect(outcome).toEqual({ status: "skipped", reason: "no_provider" }); + expect(reserveBudget).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + expectSurfaceStillWorks(); + }); + + it("no consent on a server-managed chain: refuses before reserving", async () => { + resolveProviderChain.mockResolvedValue([ + { providerType: "admin-openai", instance: {} }, + ]); + chainRequiresServerManagedConsent.mockReturnValue(true); + hasActiveConsentForSurface.mockResolvedValue(false); + + const outcome = await runReactionLine(JOB); + + expect(outcome).toEqual({ status: "skipped", reason: "consent_required" }); + // A user without a receipt must not spend a token OR a ledger slot. + expect(reserveBudget).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + expectSurfaceStillWorks(); + }); + + it("exhausted budget: refuses, writes nothing, surface intact", async () => { + resolveProviderChain.mockResolvedValue([ + { providerType: "openai", instance: {} }, + ]); + reserveBudget.mockResolvedValue({ + allowed: false, + reserved: 1_400, + totalAfter: 999_999, + }); + + const outcome = await runReactionLine(JOB); + + expect(outcome).toEqual({ status: "skipped", reason: "budget_exceeded" }); + expect(update).not.toHaveBeenCalled(); + expectSurfaceStillWorks(); + }); + + it("provider throws: reconciles the reservation to zero and degrades", async () => { + resolveProviderChain.mockResolvedValue([ + { + providerType: "openai", + instance: { + generateCompletion: vi.fn().mockRejectedValue(new Error("timeout")), + }, + }, + ]); + + const outcome = await runReactionLine(JOB); + + expect(outcome).toEqual({ status: "skipped", reason: "provider_failed" }); + // The failure path MUST reconcile — an abandoned reservation is a silent + // over-charge against the user's own daily ceiling. + expect(reconcileSpend).toHaveBeenCalledWith("u1", 1_400, 0, "2026-07-16"); + expect(update).not.toHaveBeenCalled(); + expectSurfaceStillWorks(); + }); + + it("unusable output: reconciles the ACTUAL spend and still writes no line", async () => { + resolveProviderChain.mockResolvedValue([ + { + providerType: "openai", + instance: { + generateCompletion: vi.fn().mockResolvedValue({ + content: " ", + tokensUsed: 900, + cachedInputTokens: 0, + }), + }, + }, + ]); + + const outcome = await runReactionLine(JOB); + + expect(outcome).toEqual({ status: "skipped", reason: "unusable_output" }); + // The call happened, so the real tokens are billed — not zero. + expect(reconcileSpend).toHaveBeenCalledWith( + "u1", + 1_400, + 900, + "2026-07-16", + 0, + ); + expect(update).not.toHaveBeenCalled(); + expectSurfaceStillWorks(); + }); + + it("a committed line is never regenerated — the unique row is the throttle", async () => { + findUnique.mockResolvedValue({ id: "r1", generatedAt: new Date() }); + resolveProviderChain.mockResolvedValue([ + { providerType: "openai", instance: {} }, + ]); + + const outcome = await runReactionLine(JOB); + + expect(outcome).toEqual({ status: "skipped", reason: "already_generated" }); + // The refusal happens before the chain is even resolved: there is no code + // path to a second provider call for this kind today. + expect(resolveProviderChain).not.toHaveBeenCalled(); + expect(reserveBudget).not.toHaveBeenCalled(); + }); + + it("the happy path does write, so the degrade cases above are not vacuous", async () => { + resolveProviderChain.mockResolvedValue([ + { + providerType: "openai", + instance: { + generateCompletion: vi.fn().mockResolvedValue({ + content: "A solid night, deeper than your recent stretch.", + tokensUsed: 1_100, + cachedInputTokens: 100, + }), + }, + }, + ]); + + const outcome = await runReactionLine(JOB); + + expect(outcome).toEqual({ status: "generated" }); + expect(update).toHaveBeenCalledTimes(1); + const arg = update.mock.calls[0][0] as { + data: { generatedAt: Date; lineEncrypted: Uint8Array }; + }; + // Ciphertext and the commit stamp land together — `load-digest` requires + // both, so a half-written row can never surface a line. + expect(arg.data.generatedAt).toBeInstanceOf(Date); + expect(new TextDecoder().decode(arg.data.lineEncrypted)).toBe( + "A solid night, deeper than your recent stretch.", + ); + expect(reconcileSpend).toHaveBeenCalledWith( + "u1", + 1_400, + 1_100, + "2026-07-16", + 100, + ); + }); +}); diff --git a/src/lib/jobs/data-arrival.ts b/src/lib/jobs/data-arrival.ts index 67582c091..c4f3d33b5 100644 --- a/src/lib/jobs/data-arrival.ts +++ b/src/lib/jobs/data-arrival.ts @@ -36,6 +36,7 @@ import { annotate } from "@/lib/logging/context"; import { withBackgroundEvent } from "@/lib/logging/background"; import { DATA_ARRIVAL_QUEUE } from "@/lib/arrivals/emit-shared"; +import { enqueueReactionLine } from "@/lib/arrivals/reaction-line-shared"; import { isArrivalKind, type DataArrival } from "@/lib/arrivals/types"; import { getWorkerPrisma, workerLog } from "./reminder/shared"; @@ -142,9 +143,16 @@ export async function runDataArrival( } if (claimed) { - // DISPATCH POINT — the day's single reaction line consumes this. - // Gated on `claimed` precisely BECAUSE the unique row is the throttle: - // there is no code path to a second generation for this kind today. + // The day's single reaction line. Gated on `claimed` precisely BECAUSE the + // unique row is the throttle: this is the only pass that can have won the + // claim, so there is no code path to a second generation for this kind + // today. The enqueue helper is provider-free by construction (see its + // docblock and the module-graph guard) — this worker still spends nothing. + await enqueueReactionLine({ + userId: arrival.userId, + kind: arrival.kind, + localDate: arrival.localDate, + }); actions.push("line_pending"); } diff --git a/src/lib/jobs/reaction-line.ts b/src/lib/jobs/reaction-line.ts new file mode 100644 index 000000000..eef7925af --- /dev/null +++ b/src/lib/jobs/reaction-line.ts @@ -0,0 +1,308 @@ +/** + * The `reaction-line-generate` worker — the day's single sentence about what + * just landed. + * + * This is the ONE provider call the arrival spine can cause, and the whole + * design of the surface is arranged so that call is bounded before it is made: + * + * - The THROTTLE is a durable unique row, not a timer. The spine enqueues + * this only on the pass that freshly INSERTED today's `ArrivalReaction` + * for the kind (`data-arrival.ts`, the `claimed` branch), and this worker + * re-checks `generatedAt` before spending anything. Both ends of that + * claim are the same `@@unique([userId, kind, localDate])` constraint, so + * there is no code path to a second call for a kind on a day — not a + * racing spine job, not a retry, not a hand-sent job. + * - The SPEND is reserved before the call and reconciled on EVERY exit, + * including each failure path. A provider that times out still burned + * upstream tokens; a reservation that is never reconciled is a silent + * over-charge against the user's own daily ceiling. + * - The FLOOR is complete without any of this. No provider, no consent, an + * exhausted budget, a refused output, a dead network — every one of them + * leaves the row line-less, and a line-less row still drives the "just in" + * chip and the provisional→final flip. The reaction is a state change; the + * sentence is garnish. `reaction-line-degradation.test.ts` holds that line. + * + * The grounding is deliberately the digest itself: it is already cached, + * already deterministic, already the thing the surface will render the line + * next to, and reading it costs no new query path. A line grounded in a + * DIFFERENT snapshot than the one on screen is how a surface starts + * contradicting itself. + */ +import type { Job } from "pg-boss"; + +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { resolveProviderChain } from "@/lib/ai/provider"; +import { + chainRequiresServerManagedConsent, + hasActiveConsentForSurface, +} from "@/lib/ai/consent-guard"; +import { + buildDateKey, + reconcileSpend, + reserveBudget, + resolveDailyCap, +} from "@/lib/ai/coach/budget"; +import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { singleUserTurn } from "@/lib/ai/types"; +import { screenCoachReply } from "@/lib/ai/coach/outbound-guard"; +import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; +import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; +import { loadDailyDigest } from "@/lib/daily/load-digest"; +import { + getArrivalReactionSystemPrompt, + getArrivalReactionUserPrompt, +} from "@/lib/ai/prompts/arrival-reaction"; +import { + REACTION_LINE_QUEUE, + type ReactionLineJob, +} from "@/lib/arrivals/reaction-line-shared"; + +import { workerLog } from "./reminder/shared"; + +export { REACTION_LINE_QUEUE }; + +/** Upstream timeout. A hero line is not worth holding a worker slot for long. */ +export const REACTION_LINE_TIMEOUT_MS = 12_000; + +/** + * Hard ceiling on a shippable line. The contract asks for one sentence; a + * model that returns a paragraph has not followed it, and clamping mid-thought + * would ship a truncated verdict. Reject and keep the deterministic lead. + */ +export const REACTION_LINE_MAX_CHARS = 240; + +/** + * Tokens reserved per call. + * + * Sized to the whole call, not the output ceiling: the base assessment system + * prompt plus a compact evidence block plus the 220-token output. Reserving + * only the output would let a day's reactions spend materially more than they + * booked against the user's daily cap — the reconcile corrects the number + * afterwards, but the CAP check happens at reservation time. + */ +export const ARRIVAL_REACTION_RESERVE_TOKENS = 1_400; + +export type ReactionLineOutcome = + { status: "skipped"; reason: string } | { status: "generated" }; + +function resolveLocale(locale: string | null | undefined): Locale { + return locales.includes(locale as Locale) + ? (locale as Locale) + : defaultLocale; +} + +/** + * Normalise the model's output into a shippable sentence, or null. + * + * Rejects rather than repairs: a line that broke the shape broke the contract, + * and the deterministic lead it would replace is already good. + */ +export function sanitiseReactionLine( + raw: string, + locale: Locale, +): string | null { + let text = (raw ?? "").trim(); + if (!text) return null; + text = text + .replace(/^["“”'`]+/, "") + .replace(/["“”'`]+$/, "") + .trim() + .replace(/\s+/g, " "); + if (!text) return null; + if (text.length > REACTION_LINE_MAX_CHARS) return null; + // The same outbound content fence the Coach reply path runs — a dose + // prescription or a fabricated risk score never reaches the hero. + if (screenCoachReply(text, locale).block) return null; + return text; +} + +/** + * The deterministic evidence block. Built from the ALREADY-COMPUTED digest — + * no figure here is derived in this module, which is what keeps the line + * grounded in the same numbers the surface is rendering. + */ +function buildEvidence(digest: { + score: { value: number; band: string; delta: number | null } | null; + topSignal: { headline: string; delta: string | null } | null; + briefingLead: string | null; +}): string { + const parts: string[] = []; + if (digest.score) { + const delta = + digest.score.delta === null + ? "no baseline comparison available" + : `${digest.score.delta > 0 ? "+" : ""}${Math.round(digest.score.delta)} vs their baseline`; + parts.push( + `- Health score: ${Math.round(digest.score.value)} (${digest.score.band}), ${delta}.`, + ); + } + if (digest.topSignal) { + parts.push( + `- Today's leading signal: ${digest.topSignal.headline}${ + digest.topSignal.delta ? ` (${digest.topSignal.delta})` : "" + }.`, + ); + } + if (digest.briefingLead) { + parts.push(`- The day's standing read: ${digest.briefingLead}`); + } + return parts.length > 0 + ? parts.join("\n") + : "- (No computed comparison is available for this person yet.)"; +} + +/** + * Generate and persist one reaction line. + * + * Every refusal RETURNS a status rather than throwing — the discipline the + * spine's worker documents at length. A ceiling that does not move until the + * local day rolls over must never be retried against. + */ +export async function runReactionLine( + job: ReactionLineJob, +): Promise { + const row = await prisma.arrivalReaction.findUnique({ + where: { + userId_kind_localDate: { + userId: job.userId, + kind: job.kind, + localDate: job.localDate, + }, + }, + select: { id: true, generatedAt: true }, + }); + + // The marker is gone (retention swept it, or the account was deleted) or a + // line already committed. Either way there is nothing left to write, and the + // unique row having a `generatedAt` IS the once-per-kind-per-day claim. + if (!row) return { status: "skipped", reason: "no_marker" }; + if (row.generatedAt !== null) { + return { status: "skipped", reason: "already_generated" }; + } + + // The whole row: the locale decides the prompt, and `loadDailyDigest` takes + // a `User` (it reads the timezone and the morning-refresh marker off it), so + // one read serves both rather than two. + const user = await prisma.user.findUnique({ where: { id: job.userId } }); + if (!user) return { status: "skipped", reason: "no_user" }; + + const locale = resolveLocale(user.locale); + + const chain = await resolveProviderChain(job.userId); + // A provider-less install is the DEFAULT self-hosted shape, not an error. + if (chain.length === 0) return { status: "skipped", reason: "no_provider" }; + + // Consent gate before the reservation, so a user without a receipt never + // spends a token. This tick is unattended, so it carries the same receipt + // requirement as the interactive surfaces. BYOK / local / ChatGPT-OAuth + // chains are the user's own egress and stay ungated. + if ( + chainRequiresServerManagedConsent(chain) && + !(await hasActiveConsentForSurface(job.userId, "insights")) + ) { + return { status: "skipped", reason: "consent_required" }; + } + + const budget = AI_BUDGETS.arrivalReaction; + const maxTokens = budget.maxTokens ?? 220; + const dateKey = buildDateKey(); + const reservation = await reserveBudget( + job.userId, + // Reserve the documented ceiling for this surface, not the output cap + // alone: the reservation has to cover the prompt the call actually sends, + // or a day of these under-reports its own spend against the user's cap. + ARRIVAL_REACTION_RESERVE_TOKENS, + dateKey, + resolveDailyCap(chain), + ); + if (!reservation.allowed) { + return { status: "skipped", reason: "budget_exceeded" }; + } + + // From here every exit MUST reconcile — the reservation is already on the + // user's ledger. + let line: string | null = null; + try { + // The digest is the grounding AND the thing the line will sit inside. + const digest = await loadDailyDigest(user); + + const result = await chain[0].instance.generateCompletion( + singleUserTurn({ + system: getArrivalReactionSystemPrompt(locale), + user: getArrivalReactionUserPrompt({ + kind: job.kind, + evidence: buildEvidence(digest), + }), + temperature: budget.temperature, + maxTokens, + timeoutMs: REACTION_LINE_TIMEOUT_MS, + signal: AbortSignal.timeout(REACTION_LINE_TIMEOUT_MS + 1_000), + }), + ); + + await reconcileSpend( + job.userId, + reservation.reserved, + result.tokensUsed ?? 0, + dateKey, + result.cachedInputTokens ?? 0, + ).catch(() => {}); + + line = sanitiseReactionLine(result.content, locale); + } catch (err) { + // Timeout / network / provider / grounding failure. Reconcile against a + // zero actual so the unspent reservation is returned, then degrade. + await reconcileSpend(job.userId, reservation.reserved, 0, dateKey).catch( + () => {}, + ); + workerLog("error", "[reaction-line] generation failed", err); + return { status: "skipped", reason: "provider_failed" }; + } + + if (!line) return { status: "skipped", reason: "unusable_output" }; + + // Commit. `generatedAt` and the ciphertext land together and are read + // together (`load-digest.ts` requires both), so a half-written row can never + // surface a line. + await prisma.arrivalReaction.update({ + where: { id: row.id }, + data: { lineEncrypted: encryptToBytes(line), generatedAt: new Date() }, + }); + + return { status: "generated" }; +} + +export async function handleReactionLine( + jobs: Job[], +): Promise { + await withBackgroundEvent("job.reaction_line", async (evt) => { + for (const job of jobs) { + try { + const outcome = await runReactionLine(job.data); + + if (outcome.status === "skipped") { + annotate({ + action: { name: "arrival.reaction_line.skipped" }, + meta: { reason: outcome.reason, kind: job.data.kind }, + }); + evt.addMeta("reaction_line", `skipped:${outcome.reason}`); + continue; + } + + annotate({ + action: { name: "arrival.reaction_line.generated" }, + meta: { kind: job.data.kind, local_date: job.data.localDate }, + }); + evt.addMeta("reaction_line", "generated"); + } catch (err) { + // Only a genuine transient fault reaches here — every business refusal + // returned a status. Let the queue's single retry apply; the + // `generatedAt` check makes the retry idempotent. + workerLog("error", "[reaction-line] pass failed", err); + throw err; + } + } + }); +} diff --git a/src/lib/jobs/reminder/register-status.ts b/src/lib/jobs/reminder/register-status.ts index c8cab6683..831b6f70a 100644 --- a/src/lib/jobs/reminder/register-status.ts +++ b/src/lib/jobs/reminder/register-status.ts @@ -30,6 +30,11 @@ import { type MorningDigestRefreshPayload, } from "@/lib/jobs/morning-digest-refresh"; import { DATA_ARRIVAL_QUEUE, handleDataArrival } from "@/lib/jobs/data-arrival"; +import { + REACTION_LINE_QUEUE, + handleReactionLine, +} from "@/lib/jobs/reaction-line"; +import type { ReactionLineJob } from "@/lib/arrivals/reaction-line-shared"; import type { DataArrival } from "@/lib/arrivals/types"; import { DAILY_BRIEFING_QUEUE, @@ -179,6 +184,10 @@ const allQueues = [ // this entry pg-boss never provisions the queue and every emit silently // drops (the v1.4.37 dead-queue class). DATA_ARRIVAL_QUEUE, + // v1.31.0 — the day's single arrival reaction line. Send-only, enqueued by + // the spine worker on the pass that claimed the day's marker. Without this + // entry pg-boss never provisions the queue and every enqueue silently drops. + REACTION_LINE_QUEUE, // v1.10.0 — computed scores (WX-C / WX-E). Nightly Recovery / Stress / // Strain compute + store. The queue MUST be registered here or pg-boss // never provisions it and the nightly schedule silently never fires. @@ -302,6 +311,16 @@ const queuePolicies: QueuePolicyTable = { // id and DOES pass `singletonSeconds`, so it is constrained by pg-boss's // `job_i4` index regardless of this policy. Both shapes coexist on one // queue safely: they populate different columns. + // The reaction line's key is `reaction-line:::` — the + // same day-scoped shape, and for the same reason: a wall-clock window cannot + // express a user's local date. `exclusive` is what makes the key real, and it + // is the queue-level restatement of the durable claim the worker re-checks + // (`generatedAt`) before it spends anything. + [REACTION_LINE_QUEUE]: { + policy: "exclusive", + reason: + "Day-scoped per-kind keys carry the user's local date; under standard they coalesce nothing and a retried spine job could stack a second provider call behind the first.", + }, [DATA_ARRIVAL_QUEUE]: { policy: "exclusive", reason: @@ -532,6 +551,14 @@ export async function registerStatusQueues( { localConcurrency: 1 }, handleDataArrival, ); + // v1.31.0 — the arrival reaction line. Single-flight: this is the spine's + // ONE provider call, and a second concurrent slot would only race the same + // durable `generatedAt` claim exactly one of them can win. + await boss.work( + REACTION_LINE_QUEUE, + { localConcurrency: 1 }, + handleReactionLine, + ); // S5 — daily-briefing fallback slot. Single-flight; the `push_attempts` // frequency cap makes an overlapping tick a no-op (a second same-day attempt // is suppressed), and the tick only loads a digest for opted-in users at diff --git a/src/lib/openapi/routes/daily.ts b/src/lib/openapi/routes/daily.ts index 06c791f99..72213df18 100644 --- a/src/lib/openapi/routes/daily.ts +++ b/src/lib/openapi/routes/daily.ts @@ -11,6 +11,7 @@ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; +import { ARRIVAL_KINDS } from "@/lib/arrivals/types"; import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; import { dismissPriorityItemSchema } from "@/lib/validations/daily"; import { dataEnvelope, moduleDisabledResponse, stdResponses } from "./shared"; @@ -95,6 +96,27 @@ const dailyDigestResponse = z worthALook: z .array(priorityItemSchema) .describe("Bounded 0–3 rail items, never padded."), + justIn: z + .object({ + kind: z + .enum([...ARRIVAL_KINDS]) + .describe("Closed arrival kind that landed."), + at: z + .string() + .describe( + "ISO-8601 instant of the newest sample. NEVER pre-formatted server-side — the client formats it in its own locale and timezone.", + ), + }) + .nullable() + .describe( + "The day's newest data arrival while it is still news (under three hours old), else null. Additive since v1.31.0.", + ), + reactionLine: z + .string() + .nullable() + .describe( + "One-sentence generated reaction to that arrival, standing for the rest of the local day. Null whenever no line was generated (no provider, no consent, budget exhausted, or generation failed) — consumers fall back to `briefingLead` / `line`. Additive since v1.31.0.", + ), }) .meta({ id: "DailyDigest",