mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Merge remote-tracking branch 'origin/work/v131-slice2-today' into release/v1.31.0
This commit is contained in:
commit
c1f583b114
25 files changed
+1641
-19
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.`;
|
||||
}
|
||||
@@ -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([]) },
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user