Merge remote-tracking branch 'origin/work/v131-slice2-today' into release/v1.31.0

This commit is contained in:
Marc-André Bombeck committed 2026-07-19 15:40:50 +02:00
commit c1f583b114
25 files changed
+1641 -19

No files matched your search

+33
View File
@@ -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.
+145
View File
@@ -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();
}
});
+2
View File
@@ -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"
+2
View File
@@ -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"
+2
View File
@@ -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}"
+2
View File
@@ -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}"
+2
View File
@@ -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}"
+2
View File
@@ -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}"
@@ -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<Response>;
@@ -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(
<QueryClientProvider client={client}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
const ARRIVED_AT = "2026-07-16T05:41:00.000Z";
function digest(over: Partial<DailyDigest> = {}): 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(<TodayHero digest={digest()} />);
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(
<TodayHero
digest={digest({ justIn: { kind: "sleep_night", at: ARRIVED_AT } })}
/>,
);
// 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(
<TodayHero
digest={digest({ justIn: { kind: "weight", at: ARRIVED_AT } })}
/>,
);
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(
<TodayHero
digest={digest({ justIn: { kind: "workout", at: ARRIVED_AT } })}
/>,
);
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(
<TodayHero
digest={digest({
phase: "provisional",
sleepPending: true,
justIn: { kind: "weight", at: ARRIVED_AT },
})}
/>,
);
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(
<TodayHero
digest={digest({ justIn: { kind: "sleep_night", at: ARRIVED_AT } })}
/>,
"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(
<TodayHero
digest={digest({
reactionLine: REACTION,
briefingLead: "Your week is trending steady.",
})}
/>,
);
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(
<TodayHero
digest={digest({
reactionLine: REACTION,
briefingLead: "Your week is trending steady.",
})}
/>,
);
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(
<TodayHero
digest={digest({
reactionLine: null,
briefingLead: "Your week is trending steady.",
})}
/>,
);
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(
<TodayHero
digest={digest({
score: null,
briefingLead: null,
worthALook: [],
reactionLine: REACTION,
justIn: { kind: "weight", at: ARRIVED_AT },
})}
/>,
);
expect(html).toContain('data-slot="today-hero"');
expect(html).toContain(REACTION);
});
});
@@ -63,6 +63,8 @@ function digest(over: Partial<DailyDigest> = {}): DailyDigest {
briefingLead: "Your week is trending steady.",
line: "Your week is trending steady.",
worthALook: [doseItem, syncItem],
justIn: null,
reactionLine: null,
...over,
};
}
+85 -15
View File
@@ -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 }) {
</div>
</div>
{/* 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 ? (
<p
data-slot="today-hero-sleep-pending"
className="text-muted-foreground flex items-center gap-1.5 text-xs"
>
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
{t("daily.today.sleepPending")}
</p>
{/* 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 ? (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{/* 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 ? (
<p
data-slot="today-hero-sleep-pending"
className="flex items-center gap-1.5"
>
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
{t("daily.today.sleepPending")}
</p>
) : 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 ? (
<p
data-slot="today-hero-just-in"
data-just-in-kind={digest.justIn.kind}
className="flex items-center gap-1.5"
>
{mounted && justInTime
? t("daily.today.justInAt", { time: justInTime })
: t("daily.today.justIn")}
</p>
) : null}
</div>
) : null}
{/* Worth-a-look rail — S1's `PriorityCard`s, bounded 0–3. When the
+12
View File
@@ -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.
+99
View File
@@ -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<ArrivalKind, string> = {
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.`;
}
+78
View File
@@ -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<void> {
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.
}
}
@@ -34,6 +34,8 @@ function makeDigest(over: Partial<DailyDigest> = {}): DailyDigest {
briefingLead: "Sleep looked solid last night.",
line: "Sleep looked solid last night.",
worthALook: [],
justIn: null,
reactionLine: null,
...over,
};
}
@@ -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> = {}): 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<string>(),
...over,
};
}
/** An arrival `agoMs` before NOW. */
function arrival(
agoMs: number,
over: Partial<DailyDigestArrival> = {},
): 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);
});
});
@@ -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([]) },
},
}));
+82
View File
@@ -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<string>;
/**
* 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<DailyDigestArrival | null>(
(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,
};
}
+60 -1
View File
@@ -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<DailyDigest> {
// 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,
},
});
@@ -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<string>(),
// 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,
);
});
});
+11 -3
View File
@@ -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");
}
+308
View File
@@ -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<ReactionLineOutcome> {
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<ReactionLineJob>[],
): Promise<void> {
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;
}
}
});
}
+27
View File
@@ -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:<user>:<kind>:<localDate>` — 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<ReactionLineJob>(
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
+22
View File
@@ -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",