diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index fde964456..41a985a94 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -57957,9 +57957,12 @@ components: type: string additionalProperties: {} - type: "null" - description: The cached insight payload, verbatim from the account's cache row. Null when nothing is cached or the - stored row failed to parse — the two are indistinguishable here, and the second also silently enqueues a - repair. + description: "The cached insight payload from the account's cache row, with its `dailyBriefing` resolved for today: null + when the text was generated on an earlier calendar day in the account's timezone (or carries no + `briefingGeneratedAt`, the moment it was generated), otherwise without the signals of the day whose + reading-backed metric has no reading today, and with each signal delta at its metric's display precision in + the reader's number format. Null when nothing is cached or the stored row failed to parse — the two are + indistinguishable here, and the second also silently enqueues a repair." cached: type: boolean description: True when a payload was served from the cache. @@ -58037,7 +58040,10 @@ components: propertyNames: type: string additionalProperties: {} - description: The insight payload, fresh or cached depending on `cached`. + description: "The insight payload, fresh or cached depending on `cached`, with its `dailyBriefing` resolved for today + exactly as on the read GET. A cached briefing generated on an earlier calendar day never short-circuits an + account with a provider: like a briefingless cache, it regenerates, and degrades to the cached payload on an + exhausted quota." cached: type: boolean cachedAt: diff --git a/e2e/setup/ai-optional-fixture.ts b/e2e/setup/ai-optional-fixture.ts index bea502931..4a417d3e1 100644 --- a/e2e/setup/ai-optional-fixture.ts +++ b/e2e/setup/ai-optional-fixture.ts @@ -77,6 +77,8 @@ export async function resetAiOptionalAccount(): Promise { export async function seedStoredBriefing(marker: string): Promise { const insights = { dailyBriefing: { paragraph: marker, keyFindings: [] }, + // Generated now: only text written today is served as today's read. + briefingGeneratedAt: new Date().toISOString(), }; await withAccount(async (pool, userId) => { await pool.query( diff --git a/e2e/today-overview.spec.ts b/e2e/today-overview.spec.ts index 4216f5b82..1b7782963 100644 --- a/e2e/today-overview.spec.ts +++ b/e2e/today-overview.spec.ts @@ -435,3 +435,157 @@ test.describe("Today overview geometry", () => { expect(Math.abs(after.width - before.width)).toBeLessThanOrEqual(1); }); }); + +/** + * The phone in German, where the score's meta lines are longest. In the + * dial's 80 px column "−6 vs. dein Mittel" and the basis sentence broke into + * three to five rows each; a short lead then left a gap above the facts as + * tall as that column. The lines now run on their own row under the dial. + */ +const GERMAN_SCORE: DailyDigest["score"] = { + value: 63, + band: "yellow", + delta: -6, + deltaReason: null, + steadyWeeks: null, + scoreBasis: { + domains: 2, + recommended: 3, + tier: "partial", + physiological: true, + }, +}; + +const GERMAN_FACTS: DailyDigest["today"] = [ + { + kind: "medications", + label: "Medikamente", + value: "1 von 2 genommen", + href: "/medications", + moduleKey: "medications", + }, + { + kind: "sleep", + label: "Letzte Nacht", + value: "5 Std. 45 Min., 42 Min. weniger als sonst", + href: "/insights/sleep", + moduleKey: "sleep", + }, + { + kind: "vitals", + label: "Vitalwerte", + value: "Puls über deinem Bereich", + href: "/insights", + }, +]; + +const GERMAN_STATES: Record = { + "de-short": { + ...BASE, + score: GERMAN_SCORE, + lead: { text: "Dein Puls liegt heute erhöht.", source: "signal" }, + today: GERMAN_FACTS, + }, + "de-long": { + ...BASE, + score: GERMAN_SCORE, + lead: { + text: "Dein Puls liegt heute Abend deutlich über seinem üblichen Niveau, nach einer kürzeren Nacht als sonst; ein ruhiger Abend täte ihm gut.", + source: "briefing", + }, + signalLine: { headline: null, delta: "+34 bpm vs. dein 30-Tage-Mittel" }, + today: GERMAN_FACTS, + }, +}; + +test.describe("Today overview on a German phone", () => { + test.beforeEach(async ({ context }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "viewport-driven spec; desktop project only", + ); + const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000"; + await context.addCookies([ + { name: "healthlog-locale", value: "de", url: baseURL }, + ]); + }); + + for (const viewport of VIEWPORTS.filter((v) => v.width < 768)) { + for (const [state, digest] of Object.entries(GERMAN_STATES)) { + test(`${state} at ${viewport.width}px`, async ({ page }) => { + await page.emulateMedia({ + colorScheme: "dark", + reducedMotion: "reduce", + }); + await page.addInitScript(() => { + window.localStorage.setItem("healthlog-theme", "dark"); + }); + await page.setViewportSize(viewport); + const hero = await openDashboard(page, digest); + await page.waitForLoadState("networkidle"); + mkdirSync(SHOTS, { recursive: true }); + await hero.screenshot({ + path: join(SHOTS, `${viewport.name}-dark-${state}.png`), + }); + + const box = async (slot: string) => + (await hero.locator(`[data-slot="${slot}"]`).first().boundingBox())!; + const ring = await box("score-ring"); + const lead = await box("today-hero-lead"); + const meta = await box("today-hero-score-meta"); + const block = await box("today-hero-today"); + + // The dial keeps its size and its place beside the lead. + expect(Math.round(ring.width)).toBe(RING_PX.phone); + expect(lead.width).toBeGreaterThanOrEqual(MIN_LEAD_WIDTH); + + // Each meta line reads on one line, flush with the dial's edge. + const lines = await hero + .locator('[data-slot="today-hero-score-meta"] > span') + .evaluateAll((spans) => + spans.map((span) => { + const rect = span.getBoundingClientRect(); + const lineHeight = parseFloat(getComputedStyle(span).lineHeight); + return { + rows: Math.round(rect.height / lineHeight), + right: rect.right, + }; + }), + ); + expect(lines.length).toBe(2); + for (const line of lines) { + expect(line.rows).toBe(1); + expect( + Math.abs(line.right - (ring.x + ring.width)), + ).toBeLessThanOrEqual(1); + } + + // The meta row follows the read row at once: right under the dial + // beside a short lead, right under a long lead's last line. + // The lead's cell ends with its signal line when there is one. + const signalLine = hero.locator('[data-slot="today-hero-signal"]'); + const signal = + (await signalLine.count()) > 0 + ? await signalLine.boundingBox() + : null; + const leadBottom = signal + ? signal.y + signal.height + : lead.y + lead.height; + const readBottom = Math.max(ring.y + ring.height, leadBottom); + expect(meta.y - readBottom).toBeGreaterThanOrEqual(0); + expect(meta.y - readBottom).toBeLessThanOrEqual(6); + + // No gap above the facts beyond one section step: they start one + // row gap (16 px) under whatever ends lowest, the lead or the meta. + const above = Math.max(leadBottom, meta.y + meta.height); + expect(block.y - above).toBeGreaterThanOrEqual(15); + expect(block.y - above).toBeLessThanOrEqual(17); + + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); + }); + } + } +}); diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index f77381dad..5b6ca3890 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -49,6 +49,8 @@ vi.mock("@/lib/db", () => ({ })), updateMany: vi.fn(async () => ({ count: 1 })), }, + // The newest reading per signal type (`readSignalLastSeen`). + measurement: { groupBy: vi.fn(async () => []) }, auditLog: { // v1.4.16 A7: route now evicts stale per-status cache rows // (`insights.-status.`) on every successful @@ -593,6 +595,7 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => { insightsCachedText: JSON.stringify({ changed: "still fresh", dailyBriefing: { paragraph: "ok", keyFindings: [] }, + briefingGeneratedAt: new Date().toISOString(), }), locale: "en", } as never); @@ -632,6 +635,7 @@ describe("POST /api/insights/generate — cache write (v1.16.8)", () => { insightsCachedText: JSON.stringify({ changed: "still fresh", dailyBriefing: { paragraph: "ok", keyFindings: [] }, + briefingGeneratedAt: new Date().toISOString(), }), locale: "en", } as never); @@ -680,6 +684,7 @@ describe("POST /api/insights/generate — briefingless fresh cache (v1.28.30)", insightsCachedAt: new Date(Date.now() - 6 * 60 * 60 * 1000), insightsCachedText: JSON.stringify({ dailyBriefing: { paragraph: "today", keyFindings: [] }, + briefingGeneratedAt: new Date().toISOString(), }), locale: "en", } as never); @@ -802,7 +807,10 @@ describe("POST /api/insights/generate — payload-size hard downgrade (H1)", () describe("GET /api/insights/generate — read-only advisor read", () => { it("serves the cached briefing without ever calling the provider", async () => { - const cached = { dailyBriefing: { paragraph: "ok", keyFindings: [] } }; + const cached = { + dailyBriefing: { paragraph: "ok", keyFindings: [] }, + briefingGeneratedAt: new Date().toISOString(), + }; vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsCachedAt: new Date(), insightsCachedText: JSON.stringify(cached), @@ -878,7 +886,10 @@ describe("GET /api/insights/generate — read-only advisor read", () => { }); it("serves a fresh cache whose tag matches the reader's language", async () => { - const cached = { dailyBriefing: { paragraph: "English", keyFindings: [] } }; + const cached = { + dailyBriefing: { paragraph: "English", keyFindings: [] }, + briefingGeneratedAt: new Date().toISOString(), + }; vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ insightsCachedAt: new Date(), insightsCachedText: JSON.stringify(cached), @@ -898,6 +909,87 @@ describe("GET /api/insights/generate — read-only advisor read", () => { expect(enqueueForceWarm).not.toHaveBeenCalled(); }); + it("treats yesterday's text that a warm only re-stamped as stale, and asks for a warm", async () => { + // Stamped a minute ago, generated yesterday in the reader's zone. + const yesterday = new Date(Date.now() - 30 * 60 * 60 * 1000); + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + insightsCachedAt: new Date(Date.now() - 60_000), + insightsCachedText: JSON.stringify({ + dailyBriefing: { paragraph: "Yesterday's read.", keyFindings: [] }, + briefingGeneratedAt: yesterday.toISOString(), + }), + insightsCachedLocale: "en", + locale: "en", + timezone: "UTC", + } as never); + + const res = await (GET as unknown as (req: Request) => Promise)( + new Request("http://localhost/api/insights/generate"), + ); + const body = (await res.json()) as { + data: { + insights: { dailyBriefing: unknown }; + revalidating: boolean; + }; + }; + expect(body.data.insights.dailyBriefing).toBeNull(); + expect(body.data.revalidating).toBe(true); + expect(enqueueForceWarm).toHaveBeenCalledTimes(1); + }); + + it("serves today's signals with deltas at display precision", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + insightsCachedAt: new Date(), + insightsCachedText: JSON.stringify({ + dailyBriefing: { + paragraph: "ok", + keyFindings: [], + signalsOfDay: [ + { + sourceMetric: "sleep", + tone: "info", + headline: "Shorter night", + nudge: "An earlier night.", + delta: null, + }, + { + sourceMetric: "pulse", + tone: "watch", + headline: "Pulse up", + nudge: "Rest.", + delta: "+33.72 bpm vs your 30-day average", + }, + ], + }, + briefingGeneratedAt: new Date().toISOString(), + }), + insightsCachedLocale: "en", + locale: "en", + timezone: "UTC", + } as never); + // The pulse was measured yesterday: its signal is not today's. + vi.mocked(prisma.measurement.groupBy).mockResolvedValueOnce([ + { + type: "PULSE", + _max: { measuredAt: new Date(Date.now() - 36 * 60 * 60 * 1000) }, + }, + ] as never); + + const res = await (GET as unknown as (req: Request) => Promise)( + new Request("http://localhost/api/insights/generate"), + ); + const body = (await res.json()) as { + data: { + insights: { + dailyBriefing: { signalsOfDay: Array<{ sourceMetric: string }> }; + }; + }; + }; + expect( + body.data.insights.dailyBriefing.signalsOfDay.map((s) => s.sourceMetric), + ).toEqual(["sleep"]); + }); + it("returns an empty payload (no warm) on a cold cache without a provider", async () => { vi.mocked(hasUsableStatusProvider).mockResolvedValueOnce(false); vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 2180ace0c..f19736286 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -11,6 +11,13 @@ * write breaks that convergence — the settling seam tests pin the client * half, this comment is the server half of the contract. */ +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; +import { + generatedOnLocalToday, + insightsPayloadForToday, +} from "@/lib/daily/briefing-today-read"; +import { withBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { @@ -167,6 +174,7 @@ export const GET = apiHandler(async (request: NextRequest) => { insightsCachedText: true, insightsCachedLocale: true, locale: true, + timezone: true, }, }); @@ -193,9 +201,19 @@ export const GET = apiHandler(async (request: NextRequest) => { dbUser.insightsCachedLocale !== readerLocale; const cachedAt = dbUser?.insightsCachedAt ?? null; + const timezone = dbUser?.timezone ?? DEFAULT_TIMEZONE; + // Fresh means written today on the reader's calendar, by the moment the + // text was generated: `insightsCachedAt` also moves when an unchanged + // warm only re-stamps yesterday's text. + const generatedToday = generatedOnLocalToday( + readBriefingGeneratedAt(dbUser?.insightsCachedText ?? null), + timezone, + new Date(), + ); const isFresh = !languageMismatch && cachedAt !== null && + generatedToday && Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS; // v1.18.9 (#4) — resolve provider availability once, up front. The @@ -253,7 +271,13 @@ export const GET = apiHandler(async (request: NextRequest) => { meta: { cached: true, legacyPayload, revalidating, hasProvider }, }); return apiSuccess({ - insights: cached, + // The briefing as every surface serves it: today's text only, its + // signals measured today, its deltas at display precision. + insights: await insightsPayloadForToday(cached, { + userId, + timezone, + language: readerLocale, + }), cached: true, cachedAt, legacyPayload, @@ -328,6 +352,7 @@ export const POST = apiHandler((request: NextRequest) => insightsCachedAt: true, insightsCachedText: true, insightsCachedLocale: true, + timezone: true, // v1.4.36 W3 T3 — per-user opt-out list mirroring the Coach // settings. Filtered off `features` before serialisation so the // LLM never sees the excluded blocks. @@ -375,6 +400,7 @@ export const POST = apiHandler((request: NextRequest) => request, userLocale: dbUser?.locale ?? user.locale ?? null, }); + const postTimezone = dbUser?.timezone ?? DEFAULT_TIMEZONE; const { data: body, error: jsonError } = await safeJson<{ force?: unknown; @@ -419,9 +445,16 @@ export const POST = apiHandler((request: NextRequest) => // We don't auto-regenerate — that would burn rate-limit tokens // on a cache-hit silently. User-initiated only. const legacyPayload = isLegacyInsightPayload(cached); - const carriesBriefing = cachedPayloadCarriesBriefing( - dbUser.insightsCachedText, - ); + // A briefing generated on an earlier day is not today's read: like + // a briefingless cache, it does not short-circuit a provider-backed + // account, and degrades to the cached payload on a 429 below. + const carriesBriefing = + cachedPayloadCarriesBriefing(dbUser.insightsCachedText) && + generatedOnLocalToday( + readBriefingGeneratedAt(dbUser.insightsCachedText), + postTimezone, + new Date(), + ); if (carriesBriefing || !(await hasUsableStatusProvider(userId))) { annotate({ action: { name: "insights.generate" }, @@ -432,7 +465,11 @@ export const POST = apiHandler((request: NextRequest) => }, }); return apiSuccess({ - insights: cached, + insights: await insightsPayloadForToday(cached, { + userId, + timezone: postTimezone, + language: locale, + }), cached: true, cachedAt: dbUser.insightsCachedAt, legacyPayload, @@ -479,7 +516,10 @@ export const POST = apiHandler((request: NextRequest) => meta: { cached: true, rate_limited_fallback: true }, }); return apiSuccess({ - insights: briefinglessCacheFallback.cached, + insights: await insightsPayloadForToday( + briefinglessCacheFallback.cached, + { userId, timezone: postTimezone, language: locale }, + ), cached: true, cachedAt: briefinglessCacheFallback.cachedAt, legacyPayload: briefinglessCacheFallback.legacyPayload, @@ -1038,6 +1078,7 @@ export const POST = apiHandler((request: NextRequest) => errorCode: "insights.generate.profileScopeChanged", }); } + const stored = withBriefingGeneratedAt(insights, new Date()); const committed = await prisma.user.updateMany({ where: { id: userId, @@ -1051,7 +1092,7 @@ export const POST = apiHandler((request: NextRequest) => }, data: { insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify(insights), + insightsCachedText: JSON.stringify(stored), insightsCachedLocale: locale, insightsSnapshotHash: hashInsightSnapshot({ features: compactFeatures, @@ -1131,7 +1172,11 @@ export const POST = apiHandler((request: NextRequest) => }); return apiSuccess({ - insights, + insights: await insightsPayloadForToday(stored, { + userId, + timezone: postTimezone, + language: locale, + }), cached: false, legacyPayload: false, // v1.28.28 (#470) — additive: non-null when the grounding gate stripped diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index d8d8640cf..a334795ed 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -607,8 +607,45 @@ describe(" Today overview", () => { // Full width under lead and ring below md, the leading column from md. expect(cellClass("today-hero-today")).toContain("col-span-2"); expect(cellClass("today-hero-today")).toContain("md:col-span-1"); - expect(cellClass("today-hero-score")).toContain("col-start-2"); - expect(cellClass("today-hero-score")).toContain("md:row-span-2"); + // The slot's own classes, for elements that are grid items themselves. + const slotClass = (slot: string) => + new RegExp(`data-slot="${slot}" class="([^"]*)"`).exec(read)?.[1] ?? ""; + // Below md the dial is the top-right cell and its meta lines a row of + // their own under it; from md up the column spans both rows as before. + expect(slotClass("today-hero-score")).toContain("col-start-2"); + expect(slotClass("today-hero-score")).toContain("row-start-1"); + expect(slotClass("today-hero-score-column")).toContain("contents"); + expect(slotClass("today-hero-score-column")).toContain("md:row-span-2"); + }); + + it("gives the score's meta lines a full-width row on a phone, not the dial's width", () => { + const html = render( + , + "de", + ); + const meta = /data-slot="today-hero-score-meta" class="([^"]*)"/.exec( + html, + )?.[1]; + expect(meta).toContain("col-span-2"); + expect(meta).toContain("md:contents"); + // No line is capped to the 80 px dial any more. + expect(html).not.toContain("max-w-20"); + // The facts move one row down to make room for it. + expect(html).toMatch( + /class="[^"]*row-start-3[^"]*">]*data-slot="today-hero-today"/, + ); }); it("stacks each fact's label above its value, both on the reading edge", () => { diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index 65bbd4f4e..d2325fd0c 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -322,76 +322,77 @@ export function TodayHero({ null score paints the ring's honest provisional face at the same footprint, so the column never collapses. The meta lines under it are capped to the dial's width on a phone so the column stays compact. */ - const scoreColumn = (size: "sm" | "responsive") => { - const phoneMeta = size === "responsive" && "max-w-20 md:max-w-none"; - return ( -
( +
+ {/* The ring opens the Insights overview — the destination the + health-score card owns — so every hero ring is a door, not a + poster. */} + -
- {/* The ring opens the Insights overview — the destination the - health-score card owns — so every hero ring is a door, not a - poster. */} - - - -
- {deltaShown && digest.score?.delta != null ? ( - - {formatDelta(digest.score.delta, t)} - - ) : null} - {steadyLine ? ( - - {steadyLine} - - ) : null} - {basisLine ? ( - - {basisLine} - - ) : null} -
- ); - }; + + +
+ ); + + /* The score's meta lines: its delta, how long it has held, and what it + rests on. On the phone's full-width row the basis sentence keeps one + line; beside a dial it wraps at the dial column's width. */ + const scoreMetaLines = (size: "sm" | "responsive") => ( + <> + {deltaShown && digest.score?.delta != null ? ( + + {formatDelta(digest.score.delta, t)} + + ) : null} + {steadyLine ? ( + + {steadyLine} + + ) : null} + {basisLine ? ( + + {basisLine} + + ) : null} + + ); + + /* Health score ring — `flat` (no sweep/bloom), server-computed band. A + null score paints the ring's honest provisional face at the same + footprint, so the column never collapses. The compact all-clear keeps + the ring and its meta lines in one centred column. */ + const compactScoreColumn = ( +
+ {scoreRing("sm")} + {scoreMetaLines("sm")} +
+ ); const heroShellClassName = cn( // The tile strip's surface plus the ONE sanctioned Today atmosphere: @@ -460,7 +461,7 @@ export function TodayHero({ second row below the dial. */} {sleepPendingNote} - {scoreColumn("sm")} + {compactScoreColumn} ) : ( /* The day's read: the narrative lead on the reading edge, the @@ -496,20 +497,43 @@ export function TodayHero({ className={cn( "min-w-0", leadBlock - ? "col-span-2 row-start-2 md:col-span-1 md:col-start-1" + ? cn( + "col-span-2 md:col-span-1 md:col-start-1 md:row-start-2", + hasScoreMeta ? "row-start-3" : "row-start-2", + ) : "col-start-1 row-start-1", )} > {todayBlock} ) : null} + {/* One column from md up (the ring, its meta lines under it, + trailing edge), as before. Below md the column dissolves + (`contents`) into two grid items: the dial in the top-right + cell beside the lead, and the meta lines on a full-width row + right after the read row, flush with the dial's edge: under + the dial beside a short lead, under a long lead's last line. + In the dial's 80 px column the German lines broke into three + to five rows each and pushed the facts down. */}
- {scoreColumn("responsive")} + {scoreRing( + "responsive", + "col-start-2 row-start-1 self-start md:self-auto", + )} + {hasScoreMeta ? ( +
+ {scoreMetaLines("responsive")} +
+ ) : null}
)} diff --git a/src/lib/daily/__tests__/briefing-today-warm.test.ts b/src/lib/daily/__tests__/briefing-today-warm.test.ts new file mode 100644 index 000000000..164cc1a96 --- /dev/null +++ b/src/lib/daily/__tests__/briefing-today-warm.test.ts @@ -0,0 +1,207 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const checkRateLimit = vi.hoisted(() => vi.fn()); +const enqueueTodayBriefingWarm = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit })); +vi.mock("@/lib/jobs/insight-pregenerate-shared", () => ({ + enqueueTodayBriefingWarm, +})); + +import { + __resetTodayWarmClaimsForTests, + requestTodayBriefingWarm, + todayWarmReasons, + type TodayWarmInput, +} from "@/lib/daily/briefing-today-warm"; + +/** The nightly warm: 04:30 in Berlin on 2026-10-05. */ +const NIGHTLY = "2026-10-05T02:30:00.000Z"; + +function input(over: Partial): TodayWarmInput { + return { + userId: "u1", + locale: "en", + timezone: "Europe/Berlin", + generatedAt: NIGHTLY, + lastSeenAt: () => null, + now: new Date("2026-10-05T10:00:00.000Z"), + ...over, + }; +} + +beforeEach(() => { + __resetTodayWarmClaimsForTests(); + checkRateLimit.mockReset().mockResolvedValue({ allowed: true }); + enqueueTodayBriefingWarm.mockReset().mockResolvedValue(undefined); +}); + +describe("todayWarmReasons — the reader's own morning", () => { + it("Berlin: the nightly briefing covers the day", () => { + expect(todayWarmReasons(input({}))).toEqual([]); + }); + + it("Los Angeles: the Berlin nightly is yesterday evening, so the morning asks", () => { + // 07:00 on 5 October in Los Angeles; the warm ran 19:30 on the 4th. + expect( + todayWarmReasons( + input({ + timezone: "America/Los_Angeles", + now: new Date("2026-10-05T14:00:00.000Z"), + }), + ), + ).toEqual(["day"]); + }); + + it("Seoul: after local midnight the 11:30 briefing is yesterday's", () => { + // 01:00 on 6 October in Seoul; the warm ran 11:30 on the 5th. + expect( + todayWarmReasons( + input({ + timezone: "Asia/Seoul", + now: new Date("2026-10-05T16:00:00.000Z"), + }), + ), + ).toEqual(["day"]); + // 14:00 on the 5th, after the warm: covered. + expect( + todayWarmReasons( + input({ + timezone: "Asia/Seoul", + now: new Date("2026-10-05T05:00:00.000Z"), + }), + ), + ).toEqual([]); + }); + + it("asks when a signal metric's first reading lands after the briefing", () => { + // Blood pressure at 07:10 Berlin, weight at 07:12, after the 04:30 warm. + const seen: Record = { + BLOOD_PRESSURE_SYS: "2026-10-05T05:10:00.000Z", + WEIGHT: "2026-10-05T05:12:00.000Z", + // Yesterday's pulse is not today's and asks for nothing. + PULSE: "2026-10-04T06:50:00.000Z", + }; + expect( + todayWarmReasons(input({ lastSeenAt: (t) => seen[t] ?? null })), + ).toEqual(["reading:BLOOD_PRESSURE_SYS", "reading:WEIGHT"]); + }); + + it("does not ask for a reading the briefing already read", () => { + expect( + todayWarmReasons( + input({ + lastSeenAt: (t) => + t === "WEIGHT" ? "2026-10-05T02:00:00.000Z" : null, + }), + ), + ).toEqual([]); + }); + + it("asks when the briefing's generation moment is unknown", () => { + expect(todayWarmReasons(input({ generatedAt: null }))).toEqual(["day"]); + }); +}); + +describe("requestTodayBriefingWarm — once per reason per day", () => { + it("enqueues an immediate warm for the day's first read with no briefing", async () => { + await requestTodayBriefingWarm( + input({ + timezone: "America/Los_Angeles", + now: new Date("2026-10-05T14:00:00.000Z"), + }), + ); + expect(checkRateLimit).toHaveBeenCalledWith( + "briefing-today:u1:2026-10-05:day", + 1, + expect.any(Number), + ); + expect(enqueueTodayBriefingWarm).toHaveBeenCalledWith({ + userId: "u1", + locale: "en", + immediate: true, + }); + }); + + it("waits out a burst of readings before the warm runs", async () => { + await requestTodayBriefingWarm( + input({ + lastSeenAt: (t) => + t === "BLOOD_PRESSURE_SYS" ? "2026-10-05T05:10:00.000Z" : null, + }), + ); + expect(enqueueTodayBriefingWarm).toHaveBeenCalledWith( + expect.objectContaining({ immediate: false }), + ); + }); + + it("asks once: a second read in the same day neither queries nor enqueues", async () => { + const la = input({ + timezone: "America/Los_Angeles", + now: new Date("2026-10-05T14:00:00.000Z"), + }); + await requestTodayBriefingWarm(la); + await requestTodayBriefingWarm(la); + expect(checkRateLimit).toHaveBeenCalledTimes(1); + expect(enqueueTodayBriefingWarm).toHaveBeenCalledTimes(1); + }); + + it("does not enqueue when another process already claimed the reason", async () => { + checkRateLimit.mockResolvedValue({ allowed: false }); + await requestTodayBriefingWarm(input({ generatedAt: null })); + expect(enqueueTodayBriefingWarm).not.toHaveBeenCalled(); + }); + + it("asks again on the next local day", async () => { + await requestTodayBriefingWarm(input({ generatedAt: null })); + await requestTodayBriefingWarm( + input({ + generatedAt: null, + now: new Date("2026-10-06T10:00:00.000Z"), + }), + ); + expect(enqueueTodayBriefingWarm).toHaveBeenCalledTimes(2); + }); +}); + +describe("requestTodayBriefingWarm — the worst day", () => { + it("enqueues at most one warm per reason per local day, however often it is read", async () => { + // A Postgres bucket in memory: one claim per key. + const buckets = new Map(); + checkRateLimit.mockImplementation(async (key: string) => { + const n = (buckets.get(key) ?? 0) + 1; + buckets.set(key, n); + return { allowed: n <= 1 }; + }); + // Every signal type gets its first reading at a different hour, and the + // dashboard is read every two minutes from midnight to midnight, with + // the briefing never refreshed (the worst case: every warm capped). + const types = [ + "BLOOD_PRESSURE_SYS", + "WEIGHT", + "PULSE", + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "BODY_TEMPERATURE", + "BLOOD_GLUCOSE", + ]; + const dayStart = Date.parse("2026-10-04T22:00:00.000Z"); // 00:00 Berlin + for (let minute = 0; minute < 24 * 60; minute += 2) { + const now = new Date(dayStart + minute * 60_000); + await requestTodayBriefingWarm( + input({ + generatedAt: "2026-10-04T02:30:00.000Z", + now, + lastSeenAt: (type) => { + const hour = 6 + types.indexOf(type) * 2; + const at = dayStart + hour * 3_600_000; + return types.includes(type) && at <= now.getTime() + ? new Date(at).toISOString() + : null; + }, + }), + ); + } + // One for the day, one per signal reading type. + expect(enqueueTodayBriefingWarm).toHaveBeenCalledTimes(1 + types.length); + }); +}); diff --git a/src/lib/daily/__tests__/briefing-today.test.ts b/src/lib/daily/__tests__/briefing-today.test.ts index 7a9ff8f00..a3b877539 100644 --- a/src/lib/daily/__tests__/briefing-today.test.ts +++ b/src/lib/daily/__tests__/briefing-today.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import type { DailyBriefing } from "@/lib/ai/schema"; +import { locales } from "@/lib/i18n/config"; +import { resolveIntlLocale } from "@/lib/format-locale"; import { briefingForToday, formatSignalDelta, @@ -37,9 +39,28 @@ describe("formatSignalDelta", () => { expect(formatSignalDelta("1.234 bpm", "pulse", "de")).toBe("1.234 bpm"); }); - it("drops the sign of a difference that rounds to nothing", () => { + it("says ±0 for a difference that rounds to nothing, never an unsigned zero", () => { expect(formatSignalDelta("+0.3 bpm vs your normal", "pulse", "en")).toBe( - "0 bpm vs your normal", + "±0 bpm vs your normal", + ); + expect(formatSignalDelta("+0.04 kg", "weight", "en")).toBe("±0.0 kg"); + }); + + it("re-writes a figure in the reader's own number format", () => { + // An English-formatted figure inside German text was cut at the comma + // into "+1,2.5 kg". + expect(formatSignalDelta("+1,234.5 kg", "weight", "de")).toBe( + "+1.234,5 kg", + ); + expect(formatSignalDelta("+1.234,56 kg", "weight", "de")).toBe( + "+1.234,6 kg", + ); + }); + + it("reads glucose at the precision of the unit it is written in", () => { + expect(formatSignalDelta("+12.4 mg/dL", "glucose", "en")).toBe("+12 mg/dL"); + expect(formatSignalDelta("+0,74 mmol/L", "glucose", "de")).toBe( + "+0,7 mmol/L", ); }); @@ -48,6 +69,47 @@ describe("formatSignalDelta", () => { }); }); +describe.each(locales)("formatSignalDelta in %s", (locale) => { + const intl = resolveIntlLocale(locale); + // The locale's own grouping convention: Spanish and Polish leave a + // four-digit figure ungrouped, French groups with a thin space. + const fmt = (value: number, digits: number) => + new Intl.NumberFormat(intl, { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }).format(value); + + it("rounds a pulse difference to whole beats", () => { + expect(formatSignalDelta(`+${fmt(33.72, 2)} bpm`, "pulse", locale)).toBe( + "+34 bpm", + ); + }); + + it("reads weight at one decimal, grouping included", () => { + expect(formatSignalDelta(`−${fmt(1234.56, 2)} kg`, "weight", locale)).toBe( + `−${fmt(1234.6, 1)} kg`, + ); + }); + + it("keeps the sign of a small difference as ±0 at weight precision", () => { + expect(formatSignalDelta(`+${fmt(0.04, 2)} kg`, "weight", locale)).toBe( + `±${fmt(0, 1)} kg`, + ); + }); + + it("re-reads an English-formatted figure in the reader's format", () => { + expect(formatSignalDelta("+1,234.5 kg", "weight", locale)).toBe( + `+${fmt(1234.5, 1)} kg`, + ); + }); + + it("leaves plain integers and window words as written", () => { + expect(formatSignalDelta("+6 mmHg vs 30-day", "bp", locale)).toBe( + "+6 mmHg vs 30-day", + ); + }); +}); + const BRIEFING: DailyBriefing = { paragraph: "Dein Puls ist heute deutlich erhöht.", signalsOfDay: [ @@ -80,7 +142,7 @@ describe("briefingForToday", () => { it("drops a pulse signal whose last reading was yesterday at 08:50", () => { const out = briefingForToday(BRIEFING, { ...BERLIN, - updatedAt: "2026-10-05T04:00:00Z", + generatedAt: "2026-10-05T04:00:00Z", lastSeenAt: (type) => (type === "PULSE" ? "2026-10-04T06:50:00Z" : null), }); expect(out?.signalsOfDay?.map((s) => s.sourceMetric)).toEqual(["sleep"]); @@ -89,7 +151,7 @@ describe("briefingForToday", () => { it("keeps a pulse signal measured today, with its delta in whole beats", () => { const out = briefingForToday(BRIEFING, { ...BERLIN, - updatedAt: "2026-10-05T04:00:00Z", + generatedAt: "2026-10-05T04:00:00Z", lastSeenAt: (type) => (type === "PULSE" ? "2026-10-05T06:50:00Z" : null), }); expect(out?.signalsOfDay?.[0]?.delta).toBe( @@ -102,16 +164,26 @@ describe("briefingForToday", () => { expect( briefingForToday(BRIEFING, { ...BERLIN, - updatedAt: "2026-10-04T21:30:00Z", + generatedAt: "2026-10-04T21:30:00Z", lastSeenAt: () => "2026-10-04T21:00:00Z", }), ).toBeNull(); }); + it("serves no briefing whose generation moment is unknown", () => { + expect( + briefingForToday(BRIEFING, { + ...BERLIN, + generatedAt: null, + lastSeenAt: () => "2026-10-05T06:50:00Z", + }), + ).toBeNull(); + }); + it("decides the day in the reader's zone", () => { // 05:00 UTC on the 5th is the evening of the 4th in Los Angeles. const ctx = { - updatedAt: "2026-10-05T05:00:00Z", + generatedAt: "2026-10-05T05:00:00Z", lastSeenAt: () => "2026-10-05T05:00:00Z", language: "en" as const, }; diff --git a/src/lib/daily/__tests__/load-digest.test.ts b/src/lib/daily/__tests__/load-digest.test.ts index 6544fd576..dc7773590 100644 --- a/src/lib/daily/__tests__/load-digest.test.ts +++ b/src/lib/daily/__tests__/load-digest.test.ts @@ -363,6 +363,15 @@ describe("loadDailyDigest — AI parts", () => { }); }); +describe("loadDailyDigest — the briefing as the snapshot read resolved it", () => { + it("reads the snapshot on its own clock, so both agree on today", async () => { + await loadDailyDigest(USER, NOW); + expect(vi.mocked(readDashboardSnapshotCached).mock.calls[0][2]).toEqual( + expect.objectContaining({ now: NOW }), + ); + }); +}); + /** A reminder row as the preventive read selects it; due this morning. */ function reminderRow(over: Record = {}) { return { diff --git a/src/lib/daily/briefing-today-read.ts b/src/lib/daily/briefing-today-read.ts new file mode 100644 index 000000000..f9d74bb20 --- /dev/null +++ b/src/lib/daily/briefing-today-read.ts @@ -0,0 +1,101 @@ +/** + * The briefing as every surface serves it: the digest, the dashboard + * snapshot and the `/insights` advisor payload all go through + * `briefingForToday`, so a client never decides for itself whether + * yesterday's text or a raw "+33.72 bpm" may be shown. + * + * The snapshot already carries each type's newest reading and passes it in; + * the advisor route has no snapshot at hand and reads the few reading types + * a signal can rest on here, in one grouped query, and only when the payload + * actually carries such a signal. + */ +import { dailyBriefingSchema, type DailyBriefing } from "@/lib/ai/schema"; +import { prisma } from "@/lib/db"; +import type { Locale } from "@/lib/i18n/config"; +import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; +import { userDayKey } from "@/lib/tz/format"; +import { + SIGNAL_READING_TYPE, + briefingForToday, +} from "@/lib/daily/briefing-today"; +import type { MeasurementType } from "@/generated/prisma/client"; + +/** Whether a generation moment falls on today's calendar day in `timezone`. */ +export function generatedOnLocalToday( + generatedAt: string | null, + timezone: string, + now: Date, +): boolean { + return ( + generatedAt !== null && + userDayKey(new Date(generatedAt), timezone) === userDayKey(now, timezone) + ); +} + +/** + * The newest reading of every type a briefing signal can rest on. A failed + * read answers "no reading" for every type, which drops reading-backed + * signals: the quiet side of the rule, never a stale claim. + */ +export async function readSignalLastSeen( + userId: string, +): Promise<(type: string) => string | null> { + try { + const rows = await prisma.measurement.groupBy({ + by: ["type"], + where: { + userId, + deletedAt: null, + type: { + in: Object.values(SIGNAL_READING_TYPE) as MeasurementType[], + }, + }, + _max: { measuredAt: true }, + }); + const byType = new Map( + rows.map((row) => [ + String(row.type), + row._max.measuredAt?.toISOString() ?? null, + ]), + ); + return (type) => byType.get(type) ?? null; + } catch { + return () => null; + } +} + +/** + * A cached advisor payload with its `dailyBriefing` resolved for today: + * null when the text was generated on an earlier day (or at an unknown + * time), otherwise without signals whose metric was not measured today and + * with every delta at its metric's precision. Anything that is not an + * advisor payload with a valid briefing passes through untouched. + */ +export async function insightsPayloadForToday( + payload: unknown, + ctx: { userId: string; timezone: string; language: Locale; now?: Date }, +): Promise { + if (payload === null || typeof payload !== "object") return payload; + const record = payload as Record; + if (record.dailyBriefing == null) return payload; + const parsed = dailyBriefingSchema.safeParse(record.dailyBriefing); + if (!parsed.success) return payload; + const briefing: DailyBriefing = parsed.data; + const now = ctx.now ?? new Date(); + const needsReadings = (briefing.signalsOfDay ?? []).some( + (s) => SIGNAL_READING_TYPE[s.sourceMetric] !== undefined, + ); + const lastSeenAt = needsReadings + ? await readSignalLastSeen(ctx.userId) + : () => null; + return { + ...record, + dailyBriefing: briefingForToday(briefing, { + generatedAt: readBriefingGeneratedAt(record), + lastSeenAt, + timezone: ctx.timezone, + todayLocalDate: userDayKey(now, ctx.timezone), + language: ctx.language, + }), + }; +} diff --git a/src/lib/daily/briefing-today-warm.ts b/src/lib/daily/briefing-today-warm.ts new file mode 100644 index 000000000..885df5ecf --- /dev/null +++ b/src/lib/daily/briefing-today-warm.ts @@ -0,0 +1,126 @@ +/** + * Ask for a briefing written today, at the two moments the rule "a signal + * of the day rests on today's readings" needs one. + * + * - The day has no briefing yet. The nightly warm runs at 04:30 in Berlin, + * which is the previous evening in Los Angeles and the middle of the day + * in Seoul; a briefing written then is from an earlier day on the + * reader's calendar, and is not served. The first read of the reader's + * day asks for one. + * - A signal metric got its first reading of the day after the briefing + * was written. The briefing is otherwise only written at the nightly + * warm, the post-sleep morning refresh or a day-old page open, so a + * morning blood pressure or weigh-in would never become a signal. + * + * Both are read off the snapshot every surface already reads (the dashboard, + * iOS, the digest and the morning push), so no write path needs a hook. Each + * reason is claimed once per user per local day in a Postgres bucket (and + * remembered in-process, so a claimed reason costs no query on later reads); + * a newly claimed reason enqueues one today warm. What that warm may spend is + * bounded where every forced warm is bounded: the singleton window, the + * failure backoff, the daily forced-warm cap, and the generation's + * content-hash gate, which writes no new text without new data. + */ +import { checkRateLimit } from "@/lib/rate-limit"; +import { annotate } from "@/lib/logging/context"; +import type { Locale } from "@/lib/i18n/config"; +import { userDayKey } from "@/lib/tz/format"; +import { SIGNAL_READING_TYPE } from "@/lib/daily/briefing-today"; +import { enqueueTodayBriefingWarm } from "@/lib/jobs/insight-pregenerate-shared"; + +/** A claim outlives the longest local day (a 25 h DST day) with margin. */ +const CLAIM_WINDOW_MS = 36 * 60 * 60 * 1000; + +/** In-process memory of claimed reasons, so a claimed one costs no query. */ +const claimed = new Map(); +const CLAIMED_MAX = 10_000; + +export interface TodayWarmInput { + userId: string; + locale: Locale; + timezone: string; + /** When the cached briefing's text was generated, or null when unknown. */ + generatedAt: string | null; + /** The newest reading of a measurement type (ISO), or null when none. */ + lastSeenAt: (type: string) => string | null; + now: Date; +} + +/** + * Why a briefing for today is due, if it is: `day` when no briefing was + * written today, `reading:` for each signal reading type measured + * today after the briefing was written. Empty when the briefing already + * covers the day. + */ +export function todayWarmReasons(input: TodayWarmInput): string[] { + const today = userDayKey(input.now, input.timezone); + const generatedMs = + input.generatedAt === null ? null : Date.parse(input.generatedAt); + const reasons: string[] = []; + if ( + generatedMs === null || + userDayKey(new Date(generatedMs), input.timezone) !== today + ) { + reasons.push("day"); + } + for (const type of new Set(Object.values(SIGNAL_READING_TYPE))) { + const at = input.lastSeenAt(type); + if (at === null) continue; + const atMs = Date.parse(at); + if (userDayKey(new Date(atMs), input.timezone) !== today) continue; + if (generatedMs !== null && atMs <= generatedMs) continue; + reasons.push(`reading:${type}`); + } + return reasons; +} + +/** Claim a reason for the day; true only for the first claim. */ +async function claimOnce(key: string): Promise { + if (claimed.has(key)) return false; + if (claimed.size >= CLAIMED_MAX) claimed.clear(); + claimed.set(key, true); + try { + const result = await checkRateLimit(key, 1, CLAIM_WINDOW_MS); + return result.allowed; + } catch { + // A failed claim asks again on a later read rather than never. + claimed.delete(key); + return false; + } +} + +/** + * Enqueue a today warm when a reason is due and not yet claimed today. + * Fire-and-forget from the read path: it never throws and never delays the + * read beyond the claim query. + */ +export async function requestTodayBriefingWarm( + input: TodayWarmInput, +): Promise { + const reasons = todayWarmReasons(input); + if (reasons.length === 0) return; + const today = userDayKey(input.now, input.timezone); + const fresh: string[] = []; + for (const reason of reasons) { + if (await claimOnce(`briefing-today:${input.userId}:${today}:${reason}`)) { + fresh.push(reason); + } + } + if (fresh.length === 0) return; + annotate({ + action: { name: "daily.briefing.today_warm_requested" }, + meta: { reasons: fresh.join(","), timezone: input.timezone }, + }); + await enqueueTodayBriefingWarm({ + userId: input.userId, + locale: input.locale, + // Nobody waits on a burst of readings when the day simply has no + // briefing yet; a reading waits out its burst so one warm reads it all. + immediate: fresh.every((reason) => reason === "day"), + }); +} + +/** Test seam: forget the in-process claims. */ +export function __resetTodayWarmClaimsForTests(): void { + claimed.clear(); +} diff --git a/src/lib/daily/briefing-today.ts b/src/lib/daily/briefing-today.ts index bce8c7be8..4ca2b7720 100644 --- a/src/lib/daily/briefing-today.ts +++ b/src/lib/daily/briefing-today.ts @@ -19,7 +19,7 @@ * metric's display precision, so a pulse difference reads "+34 bpm", never * "+33.72 bpm". */ -import type { DailyBriefing, DailyBriefingSignal } from "@/lib/ai/schema"; +import type { DailyBriefing } from "@/lib/ai/schema"; import type { Locale } from "@/lib/i18n/config"; import { resolveIntlLocale } from "@/lib/format-locale"; import { vitalDisplayDecimals } from "@/lib/measurements/vital-precision"; @@ -30,72 +30,144 @@ import { userDayKey } from "@/lib/tz/format"; * here (sleep, steps, mood, compliance, derived scores) are not a single * dated reading and keep their signal; the prompt input already decides * those. + * + * Keyed by string rather than by the briefing's `sourceMetric` enum: the + * signals-of-the-day block does carry glucose, and the published enum + * (which the iOS client decodes) does not yet. A glucose signal is covered + * here the day the enum gains it, without a contract change today. */ -export const SIGNAL_READING_TYPE: Partial< - Record -> = { +export const SIGNAL_READING_TYPE: Readonly> = { bp: "BLOOD_PRESSURE_SYS", weight: "WEIGHT", pulse: "PULSE", resting_hr: "RESTING_HEART_RATE", hrv: "HEART_RATE_VARIABILITY", body_temp: "BODY_TEMPERATURE", + glucose: "BLOOD_GLUCOSE", }; -/** The locale's decimal separator ("," for de, "." for en). */ -function decimalSeparator(locale: Locale): string { - return ( - new Intl.NumberFormat(resolveIntlLocale(locale)) - .formatToParts(1.5) - .find((part) => part.type === "decimal")?.value ?? "." +/** The locale's decimal and grouping separators ("," and "." for de). */ +function separators(locale: Locale): { decimal: string; group: string } { + const parts = new Intl.NumberFormat(resolveIntlLocale(locale)).formatToParts( + 12345.5, ); + return { + decimal: parts.find((p) => p.type === "decimal")?.value ?? ".", + group: parts.find((p) => p.type === "group")?.value ?? ",", + }; } -function escapeRegExp(text: string): string { - return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +/** Separators a figure may carry: point, comma, and the two thin spaces. */ +const SEP = "[.,\\u00a0\\u202f]"; + +/** + * A figure with at least one separator in it. Plain integers ("+6 mmHg", + * "30-day") are never rewritten, so the window words stay as written. + */ +const FIGURE = new RegExp( + `(? m[0]); + const points = marks.filter((m) => m === "." || m === ","); + const spaced = points.length < marks.length; + let decimal: string | null = null; + if (new Set(points).size === 2) { + decimal = raw.lastIndexOf(".") > raw.lastIndexOf(",") ? "." : ","; + } else if (points.length === 1) { + // Beside thin-space grouping ("1 234,5") the one mark is the decimal. + const mark = points[0]; + const after = raw.slice(raw.indexOf(mark) + 1); + const asGroup = !spaced && mark === localeGroup && after.length === 3; + decimal = asGroup ? null : mark; + } + // Repeated marks of one kind, and thin spaces, are grouping. + const [intPart, fracPart = ""] = + decimal === null + ? [raw] + : [ + raw.slice(0, raw.lastIndexOf(decimal)), + raw.slice(raw.lastIndexOf(decimal) + 1), + ]; + const groups = intPart.split(/[.,\u00a0\u202f]/); + if (groups.length > 1 && groups.slice(1).some((g) => g.length !== 3)) { + return null; + } + const value = Number(`${groups.join("")}.${fracPart || "0"}`); + if (!Number.isFinite(value)) return null; + // Written the way the locale writes it: decimal mark and grouping both. + const native = + (decimal === null || decimal === localeDecimal) && + marks.every((m) => m === decimal || m === localeGroup); + return { value, fractionDigits: fracPart.length, native }; } /** * Re-read the figures in a signal's delta string at the metric's display - * precision. + * precision, in the reader's number format. * - * Only a number written with the locale's own decimal separator is touched, - * and only when it carries more fraction digits than the metric is shown - * with: "1.234" in German is a thousand, not a decimal, and is left alone. - * A figure that rounds to zero loses its sign ("+0.3 bpm" reads "0 bpm"). - * A metric with no reading type keeps its delta verbatim. + * Only figures with a decimal or grouping mark are touched; a plain integer + * stays as written. A figure already at the metric's precision and written + * the locale's way is left alone; anything else is re-rounded and re-written + * with the locale's own marks ("+1,234.5 kg" in German reads "+1.234,5 kg"). + * A non-zero difference that rounds to nothing reads "±0" at the metric's + * precision rather than an unsigned zero that looks like a measurement. A + * metric with no reading type keeps its delta verbatim. */ export function formatSignalDelta( delta: string, - sourceMetric: DailyBriefingSignal["sourceMetric"], + sourceMetric: string, locale: Locale, ): string { const type = SIGNAL_READING_TYPE[sourceMetric]; if (!type) return delta; - // Every reading type above reads at the same precision in both unit - // systems (whole bpm / mmHg / ms, one decimal of kg / lb / °C / °F). - const decimals = vitalDisplayDecimals(type, 1); - const sep = decimalSeparator(locale); + // Every reading type reads at one precision in both unit systems (whole + // bpm / mmHg / ms, one decimal of kg / lb / °C / °F), except glucose, + // whose precision follows the unit the delta is written in. + const decimals = vitalDisplayDecimals( + type, + type === "BLOOD_GLUCOSE" && /mmol/i.test(delta) ? 1 : 0, + ); const number = new Intl.NumberFormat(resolveIntlLocale(locale), { minimumFractionDigits: decimals, maximumFractionDigits: decimals, - useGrouping: false, }); - const pattern = new RegExp( - `([+\\-−±]?)(\\d+)${escapeRegExp(sep)}(\\d+)(?![\\d])`, - "g", - ); - return delta.replace(pattern, (match, sign: string, int: string, frac) => { - if ((frac as string).length <= decimals) return match; - const value = Number(`${int}.${frac as string}`); - const rounded = Math.round(value * 10 ** decimals) / 10 ** decimals; - return `${rounded === 0 ? "" : sign}${number.format(rounded)}`; + return delta.replace(FIGURE, (match, sign: string, raw: string) => { + const figure = readFigure(raw, locale); + if (!figure) return match; + if (figure.native && figure.fractionDigits <= decimals) return match; + const scale = 10 ** decimals; + const rounded = Math.round(figure.value * scale) / scale; + if (rounded === 0 && figure.value !== 0) return `±${number.format(0)}`; + return `${sign}${number.format(rounded)}`; }); } export interface BriefingForTodayContext { - /** When the cached briefing was written (ISO), or null when unknown. */ - updatedAt: string | null; + /** + * When the briefing's content was generated (ISO, from the payload's + * `briefingGeneratedAt`), or null when the payload predates the field. + * Never the cache row's `insightsCachedAt`: that moves on every warm, + * including the unchanged-hash refresh that keeps yesterday's text. + */ + generatedAt: string | null; /** The newest reading of a measurement type (ISO), or null when none. */ lastSeenAt: (type: string) => string | null; /** The reader's zone and today's day key in it. */ @@ -106,8 +178,8 @@ export interface BriefingForTodayContext { } /** - * The briefing as the Today surfaces may use it: null when it was written on - * an earlier calendar day, otherwise with every reading-backed signal whose + * The briefing as the Today surfaces may use it: null when its content was + * generated on an earlier calendar day, or at an unknown time, otherwise with every reading-backed signal whose * metric was not measured today removed and every remaining delta re-read at * its metric's precision. */ @@ -117,8 +189,8 @@ export function briefingForToday( ): DailyBriefing | null { if (!briefing) return null; if ( - ctx.updatedAt !== null && - userDayKey(new Date(ctx.updatedAt), ctx.timezone) !== ctx.todayLocalDate + ctx.generatedAt === null || + userDayKey(new Date(ctx.generatedAt), ctx.timezone) !== ctx.todayLocalDate ) { return null; } diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index a786cbbf1..52db327b0 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -83,7 +83,6 @@ import { readTodayCycle, type TodayCycleRead } from "@/lib/cycle/today-verdict"; import { addDays, dayDiff } from "@/lib/cycle/day-math"; import { makeFormatters, resolveIntlLocale } from "@/lib/format-locale"; import { vitalDisplayDecimals } from "@/lib/measurements/vital-precision"; -import { briefingForToday } from "@/lib/daily/briefing-today"; import type { DateFormatPreference, TimeFormatPreference, @@ -572,7 +571,10 @@ export async function loadDailyDigest( coachAi, reactionLinesAi, ] = await Promise.all([ - readDashboardSnapshotCached(user, undefined, { locale: options.locale }), + readDashboardSnapshotCached(user, undefined, { + locale: options.locale, + now, + }), resolveModuleMap(user.id), prisma.integrationStatus.findMany({ where: { userId: user.id, state: { in: [...SYNC_ISSUE_STATES] } }, @@ -934,17 +936,9 @@ export async function loadDailyDigest( snapshot.layout.enabledHeroItemKinds ?? PRIORITY_ITEM_KINDS, score, - // Only what is still true about today: a briefing written on an - // earlier day is not served as today's read, and a signal whose - // metric has no reading today is dropped (`briefing-today.ts`). - briefing: briefingForToday(snapshot.briefing, { - updatedAt: snapshot.briefingUpdatedAt, - lastSeenAt: (type) => - snapshot.tiles.lastSeenByType[type]?.lastSeenAt ?? null, - timezone: user.timezone, - todayLocalDate, - language: resolvedLocale, - }), + // Already resolved for today by the snapshot read (`briefing-today.ts` + // via `applyBriefingForToday`), as every surface serves it. + briefing: snapshot.briefing, medsToday: snapshot.medsToday, sleepLastSeenDaysAgo, morningRefreshedToday, diff --git a/src/lib/dashboard/__tests__/snapshot-read-briefing-capability.test.ts b/src/lib/dashboard/__tests__/snapshot-read-briefing-capability.test.ts index 0a2ab0f38..5c16fa762 100644 --- a/src/lib/dashboard/__tests__/snapshot-read-briefing-capability.test.ts +++ b/src/lib/dashboard/__tests__/snapshot-read-briefing-capability.test.ts @@ -44,7 +44,16 @@ vi.mock("@/lib/ai/capabilities/gate", () => ({ const { readDashboardSnapshotCached } = await import("../snapshot-read"); const { __resetAllCachesForTests } = await import("@/lib/cache/server-cache"); -const USER = { id: "user-briefing-gate", locale: "en" } as unknown as User; +// Generated a moment ago, so the today rule keeps it and these tests see +// only the capability decision. +const USER = { + id: "user-briefing-gate", + locale: "en", + timezone: "UTC", + insightsCachedText: JSON.stringify({ + briefingGeneratedAt: new Date().toISOString(), + }), +} as unknown as User; beforeEach(() => { __resetAllCachesForTests(); diff --git a/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts b/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts new file mode 100644 index 000000000..9be475763 --- /dev/null +++ b/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts @@ -0,0 +1,166 @@ +/** + * The snapshot read serves the briefing as it stands today, for every + * client: the dashboard, the RSC prefetch, iOS and the digest all read + * through `readDashboardSnapshotCached`. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { User } from "@/generated/prisma/client"; +import { + AI_AVAILABLE, + aiUnavailable, +} from "@/__tests__/helpers/ai-capability-fixtures"; + +const BRIEFING = { + paragraph: "Your pulse is well above its usual level today.", + signalsOfDay: [ + { + sourceMetric: "pulse", + tone: "watch", + headline: "Pulse is up today", + nudge: "Take it easy this evening.", + delta: "+33.72 bpm vs your 30-day average", + }, + { + sourceMetric: "sleep", + tone: "info", + headline: "Shorter night", + nudge: "An earlier night would suit it.", + delta: null, + }, + ], + keyFindings: [], +}; + +const body = (pulseLastSeen: string) => ({ + tiles: { + lastSeenByType: { PULSE: { lastSeenAt: pulseLastSeen, daysAgo: 0 } }, + }, + briefing: BRIEFING, + briefingMemory: null, + briefingState: "ready", + // Stamped this morning, as every warm stamps the row. + briefingUpdatedAt: "2026-10-05T02:30:00.000Z", + briefingStale: false, + briefingAi: null, +}); +let current = body("2026-10-04T06:50:00.000Z"); +const buildDashboardSnapshot = vi.fn(async () => current); + +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/dashboard/snapshot", async (importOriginal) => ({ + ...(await importOriginal()), + buildDashboardSnapshot: () => buildDashboardSnapshot(), +})); +vi.mock("@/lib/i18n/server-locale", () => ({ + resolveServerLocale: async () => "en", +})); +const capability = vi.hoisted(() => ({ current: null as unknown })); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityToServe: async () => capability.current, +})); +const requestTodayBriefingWarm = vi.hoisted(() => + vi.fn(async (_input: unknown) => undefined), +); +vi.mock("@/lib/daily/briefing-today-warm", () => ({ + requestTodayBriefingWarm, +})); + +const { readDashboardSnapshotCached } = await import("../snapshot-read"); +const { __resetAllCachesForTests } = await import("@/lib/cache/server-cache"); + +/** 2026-10-05 21:00 in Berlin. */ +const NOW = new Date("2026-10-05T19:00:00.000Z"); + +function user(generatedAt: string | null): User { + return { + id: "user-briefing-today", + locale: "en", + timezone: "Europe/Berlin", + insightsCachedText: JSON.stringify({ + dailyBriefing: BRIEFING, + ...(generatedAt ? { briefingGeneratedAt: generatedAt } : {}), + }), + } as unknown as User; +} + +beforeEach(() => { + capability.current = AI_AVAILABLE; + requestTodayBriefingWarm.mockClear(); + __resetAllCachesForTests(); + current = body("2026-10-04T06:50:00.000Z"); +}); + +describe("readDashboardSnapshotCached — the briefing's own day", () => { + it("does not serve yesterday's text that a warm only re-stamped", async () => { + const { body: read } = await readDashboardSnapshotCached( + user("2026-10-04T02:30:00.000Z"), + undefined, + { now: NOW }, + ); + expect(read.briefing).toBeNull(); + expect(read.briefingState).toBe("preparing"); + }); + + it("does not serve a payload with no generation moment", async () => { + const { body: read } = await readDashboardSnapshotCached( + user(null), + undefined, + { now: NOW }, + ); + expect(read.briefing).toBeNull(); + }); + + it("drops a pulse signal last measured yesterday and keeps the rest", async () => { + const { body: read } = await readDashboardSnapshotCached( + user("2026-10-05T02:30:00.000Z"), + undefined, + { now: NOW }, + ); + expect(read.briefing?.signalsOfDay?.map((s) => s.sourceMetric)).toEqual([ + "sleep", + ]); + expect(read.briefingState).toBe("ready"); + }); + + it("serves today's pulse signal with its delta at display precision", async () => { + current = body("2026-10-05T06:50:00.000Z"); + const { body: read } = await readDashboardSnapshotCached( + user("2026-10-05T02:30:00.000Z"), + undefined, + { now: NOW }, + ); + expect(read.briefing?.signalsOfDay?.[0]?.delta).toBe( + "+34 bpm vs your 30-day average", + ); + }); +}); + +describe("readDashboardSnapshotCached — asking for today's briefing", () => { + it("hands the read's view of the day to the today warm", async () => { + await readDashboardSnapshotCached( + user("2026-10-04T02:30:00.000Z"), + undefined, + { + now: NOW, + }, + ); + expect(requestTodayBriefingWarm).toHaveBeenCalledTimes(1); + const arg = requestTodayBriefingWarm.mock.calls[0][0] as { + generatedAt: string; + timezone: string; + now: Date; + lastSeenAt: (type: string) => string | null; + }; + expect(arg.generatedAt).toBe("2026-10-04T02:30:00.000Z"); + expect(arg.timezone).toBe("Europe/Berlin"); + expect(arg.now).toBe(NOW); + expect(arg.lastSeenAt("PULSE")).toBe("2026-10-04T06:50:00.000Z"); + }); + + it("asks for nothing while the briefing capability is unavailable", async () => { + capability.current = aiUnavailable("consent_required"); + await readDashboardSnapshotCached(user(null), undefined, { now: NOW }); + expect(requestTodayBriefingWarm).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/dashboard/snapshot-read.ts b/src/lib/dashboard/snapshot-read.ts index 41cfb684c..6095f7d45 100644 --- a/src/lib/dashboard/snapshot-read.ts +++ b/src/lib/dashboard/snapshot-read.ts @@ -37,6 +37,10 @@ import { import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import type { Locale } from "@/lib/i18n/config"; +import { briefingForToday } from "@/lib/daily/briefing-today"; +import { requestTodayBriefingWarm } from "@/lib/daily/briefing-today-warm"; +import { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; +import { userDayKey } from "@/lib/tz/format"; /** * Per-key TTL for the snapshot cache entry. Strictly greater than the @@ -50,6 +54,46 @@ import type { Locale } from "@/lib/i18n/config"; */ export const SNAPSHOT_CACHE_TTL_MS = DASHBOARD_REFETCH_INTERVAL_MS + 60_000; +/** + * Resolve the snapshot's briefing for today, on every read. + * + * The same rule the digest applies, here so the dashboard, the RSC prefetch, + * the iOS snapshot read and the digest all serve one answer: text generated + * on an earlier calendar day (or at an unknown time) is not served, a + * reading-backed signal whose metric has no reading today is dropped, and + * every delta reads at its metric's precision. Applied after the cache like + * the capability, so a body cached at 23:58 does not serve yesterday's text + * at 00:01. A withheld briefing reads "preparing" while a warm is due, never + * "ready" with nothing in it. + */ +export function applyBriefingForToday( + body: DashboardSnapshot, + ctx: { + generatedAt: string | null; + timezone: string; + language: Locale; + now: Date; + }, +): DashboardSnapshot { + if (!body.briefing) return body; + const lastSeen = body.tiles?.lastSeenByType ?? {}; + const briefing = briefingForToday(body.briefing, { + generatedAt: ctx.generatedAt, + lastSeenAt: (type) => lastSeen[type]?.lastSeenAt ?? null, + timezone: ctx.timezone, + todayLocalDate: userDayKey(ctx.now, ctx.timezone), + language: ctx.language, + }); + if (briefing) return { ...body, briefing }; + return { + ...body, + briefing: null, + briefingStale: false, + briefingState: + body.briefingState === "ready" ? "preparing" : body.briefingState, + }; +} + export interface SnapshotReadResult { body: DashboardSnapshot; /** The locale the snapshot was resolved (and cache-keyed) under. */ @@ -63,6 +107,8 @@ export interface SnapshotReadOptions { * resolution here so the digest and the push copy agree on a language. */ locale?: Locale; + /** The read's clock; the digest passes its own so both agree on today. */ + now?: Date; } /** @@ -130,7 +176,28 @@ export async function readDashboardSnapshotCached( // capability is available, decided on every read rather than baked into // the cached body. The record's own state decides, whoever is reading, so a // delegate sees the owner's briefing exactly when the owner would. - const body = applyBriefingCapability(cached, briefingAi); + const now = options.now ?? new Date(); + const generatedAt = readBriefingGeneratedAt(user.insightsCachedText); + const body = applyBriefingForToday( + applyBriefingCapability(cached, briefingAi), + { generatedAt, timezone: user.timezone, language: locale, now }, + ); + + // The day's first read with no briefing written today, or the first read + // after a signal metric's first reading of the day, asks for a briefing + // for today (`briefing-today-warm.ts`). Never awaited by the read beyond + // its claim, never thrown. + if (briefingAi.available) { + const lastSeen = cached.tiles?.lastSeenByType ?? {}; + void requestTodayBriefingWarm({ + userId: user.id, + locale, + timezone: user.timezone, + generatedAt, + lastSeenAt: (type) => lastSeen[type]?.lastSeenAt ?? null, + now, + }).catch(() => undefined); + } return { body, locale }; } diff --git a/src/lib/insights/__tests__/briefing-generated-at.test.ts b/src/lib/insights/__tests__/briefing-generated-at.test.ts new file mode 100644 index 000000000..21517e1ee --- /dev/null +++ b/src/lib/insights/__tests__/briefing-generated-at.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { + readBriefingGeneratedAt, + withBriefingGeneratedAt, +} from "@/lib/insights/briefing-generated-at"; + +describe("briefing generation moment", () => { + it("round-trips through the stored JSON text", () => { + const at = new Date("2026-10-05T04:30:00.000Z"); + const text = JSON.stringify( + withBriefingGeneratedAt({ dailyBriefing: { paragraph: "x" } }, at), + ); + expect(readBriefingGeneratedAt(text)).toBe(at.toISOString()); + }); + + it("is unknown for a payload that predates it, or does not parse", () => { + expect(readBriefingGeneratedAt(JSON.stringify({ dailyBriefing: {} }))).toBe( + null, + ); + expect(readBriefingGeneratedAt("not json")).toBeNull(); + expect(readBriefingGeneratedAt(null)).toBeNull(); + expect( + readBriefingGeneratedAt({ briefingGeneratedAt: "yesterday-ish" }), + ).toBeNull(); + }); +}); diff --git a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts index b805f1e75..4d83ec073 100644 --- a/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts +++ b/src/lib/insights/__tests__/comprehensive-briefing-reroll.test.ts @@ -178,6 +178,10 @@ describe("daily-briefing re-roll on unchanged data", () => { { headline: "BP steady", tone: "good" }, ]); expect(stored.summary).toBe("stable summary"); + // New text, so a new generation moment. + expect(Date.parse(stored.briefingGeneratedAt)).toBeGreaterThan( + Date.now() - 60_000, + ); expect(data.insightsBriefingRerollDate).toBe(todayKey); expect(invalidateUserInsights).toHaveBeenCalledWith("u1"); }); @@ -223,5 +227,14 @@ describe("daily-briefing re-roll on unchanged data", () => { .insightsBriefingRerollDate !== undefined, ); expect(write).toBeTruthy(); + // The stamp refresh keeps yesterday's text and its generation moment: + // it writes no text at all. + expect( + userUpdate.mock.calls.some( + (c) => + (c[0] as { data: Record }).data + .insightsCachedText !== undefined, + ), + ).toBe(false); }); }); diff --git a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts index 1bf266d3f..5eb22ab81 100644 --- a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts +++ b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts @@ -310,6 +310,12 @@ describe("generateComprehensiveInsight — content-hash gate (v1.16.8)", () => { expect(data.insightsSnapshotHash).toBe(FEATURES_HASH); // The cache row records the language it was generated in. expect(data.insightsCachedLocale).toBe("de"); + // New text carries the moment it was generated, which the "is this + // briefing from today" check reads instead of insightsCachedAt. + const stored = JSON.parse(data.insightsCachedText as string); + expect(Date.parse(stored.briefingGeneratedAt)).toBeGreaterThan( + Date.now() - 60_000, + ); // v1.16.8 — the blanket per-status eviction is gone. expect(auditDeleteMany).not.toHaveBeenCalled(); }); diff --git a/src/lib/insights/briefing-generated-at.ts b/src/lib/insights/briefing-generated-at.ts new file mode 100644 index 000000000..903b7626f --- /dev/null +++ b/src/lib/insights/briefing-generated-at.ts @@ -0,0 +1,45 @@ +/** + * When the cached briefing's content was written, as opposed to when the + * cache row was last touched. + * + * `User.insightsCachedAt` moves on every warm, including the content-hash + * short-circuit that keeps yesterday's text and only refreshes the stamp. + * A "is this briefing from today?" check read off that column therefore + * passed for text written the day before. The moment the content itself was + * produced rides inside the cached payload instead, written only where new + * text is written: a full generation (the warm and the on-demand route) and + * the daily paragraph re-roll. The plain stamp refresh leaves it alone. + * + * A payload without the field predates it, and its age is unknown: the + * readers treat it as not from today, which is the safe side (the hero falls + * back to its own lead and a warm is asked for). + */ + +/** The top-level key on the cached insight payload. */ +export const BRIEFING_GENERATED_AT_KEY = "briefingGeneratedAt"; + +/** The payload with the generation moment set. */ +export function withBriefingGeneratedAt( + payload: T, + at: Date, +): T & { [BRIEFING_GENERATED_AT_KEY]: string } { + return { ...payload, [BRIEFING_GENERATED_AT_KEY]: at.toISOString() }; +} + +/** + * The generation moment of a cached payload (an object or its stored JSON + * text), or null when it carries none or does not parse. + */ +export function readBriefingGeneratedAt(payload: unknown): string | null { + let value: unknown = payload; + if (typeof payload === "string") { + try { + value = JSON.parse(payload); + } catch { + return null; + } + } + if (value === null || typeof value !== "object") return null; + const at = (value as Record)[BRIEFING_GENERATED_AT_KEY]; + return typeof at === "string" && !Number.isNaN(Date.parse(at)) ? at : null; +} diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 5469072e9..c6d4c3162 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -18,6 +18,7 @@ * NO synchronous request lifecycle: the cron path runs entirely on * pg-boss, never inside an HTTP handler. */ +import { withBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; import pLimit from "p-limit"; import { prisma } from "@/lib/db"; import { @@ -298,7 +299,12 @@ async function rerollBriefingParagraph(args: { ...(cachedBriefing as Record), paragraph: freshParagraph, }; - const merged = { ...cached, dailyBriefing: mergedBriefing }; + // A re-rolled paragraph is new text, so it carries a new generation + // moment; the plain stamp refresh that a miss falls back to does not. + const merged = withBriefingGeneratedAt( + { ...cached, dailyBriefing: mergedBriefing }, + new Date(), + ); return { text: JSON.stringify(merged), providerType }; } @@ -1443,7 +1449,9 @@ export async function generateComprehensiveInsight( workingProviderType, { insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify(insights), + insightsCachedText: JSON.stringify( + withBriefingGeneratedAt(insights, new Date()), + ), insightsCachedLocale: locale, insightsSnapshotHash: snapshotHash, }, diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index 163d0d209..846055bc1 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -1007,6 +1007,62 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => { } }); + it("a today warm runs past the freshness window, under the daily cap, and warms no card", async () => { + // The weigh-in at 07:05 after the blood-pressure warm at 07:00: the + // briefing is minutes old, and still has to read the new reading. + const now = new Date("2026-06-10T12:00:00.000Z"); + const { prisma, findUnique } = makePrisma([]); + findUnique.mockResolvedValue({ + insightsCachedAt: new Date(now.getTime() - 5 * 60 * 1000), + insightsWarmFailedAt: null, + }); + const generate = vi + .fn() + .mockResolvedValue({ status: "generated", providerType: "openai" }); + const statusGenerators = Array.from({ length: 7 }, () => + vi.fn().mockResolvedValue({ hasProvider: true, cached: true }), + ); + const warmGenericMetrics = vi.fn().mockResolvedValue(0); + + const result = await forceWarmUser(prisma as never, "u1", "de", { + generate, + statusGenerators, + warmGenericMetrics, + now, + today: true, + }); + + expect(generate).toHaveBeenCalledTimes(1); + expect(result.comprehensive).toBe("generated"); + expect(checkRateLimit).toHaveBeenCalledWith( + "insight-pregenerate-daily:u1", + FORCE_WARM_DAILY_LIMIT, + expect.any(Number), + ); + for (const g of statusGenerators) expect(g).not.toHaveBeenCalled(); + expect(warmGenericMetrics).not.toHaveBeenCalled(); + }); + + it("a today warm stays inside the daily forced-warm cap", async () => { + const now = new Date("2026-06-10T12:00:00.000Z"); + const { prisma, findUnique } = makePrisma([]); + findUnique.mockResolvedValue({ + insightsCachedAt: new Date(now.getTime() - 5 * 60 * 1000), + insightsWarmFailedAt: null, + }); + checkRateLimit.mockResolvedValue({ allowed: false }); + const generate = vi.fn(); + const result = await forceWarmUser(prisma as never, "u1", "de", { + generate, + statusGenerators: [], + warmGenericMetrics: vi.fn().mockResolvedValue(0), + now, + today: true, + }); + expect(generate).not.toHaveBeenCalled(); + expect(result.comprehensive).toBe("capped"); + }); + it("backs off after a recent failed attempt instead of re-driving the provider chain", async () => { const now = new Date("2026-06-10T12:00:00.000Z"); const { prisma, findUnique } = makePrisma([]); diff --git a/src/lib/jobs/insight-pregenerate-shared.ts b/src/lib/jobs/insight-pregenerate-shared.ts index e6353b37a..95e5aee07 100644 --- a/src/lib/jobs/insight-pregenerate-shared.ts +++ b/src/lib/jobs/insight-pregenerate-shared.ts @@ -45,6 +45,15 @@ export interface InsightPregeneratePayload { * unrecognised value to English. */ locale?: SupportedLocale; + /** + * A briefing-for-today warm (`enqueueTodayBriefingWarm`): the day started + * with no briefing written today, or a signal metric got its first + * reading of the day after the briefing was written. Runs the + * comprehensive briefing only, past the one-hour freshness gate (a + * reading at 07:05 after a warm at 07:00 is exactly what it is for), and + * under the same failure backoff and daily forced-warm cap. + */ + today?: boolean; } /** @@ -108,6 +117,63 @@ export async function enqueueForceWarm(payload: { } } +/** + * How long a today warm waits before it runs, and the window that folds + * every request inside it into one job. A morning weigh-in, the blood + * pressure five minutes later and the sync that brings both arrive as one + * burst; one warm after the burst reads all of it. + */ +export const TODAY_WARM_DELAY_SECONDS = 5 * 60; + +/** + * Enqueue the briefing-for-today warm. `immediate` is for the day's first + * read with no briefing written today, where nobody is waiting on a burst + * of readings; otherwise the job starts after the burst window. The + * singleton per user folds requests in the window into one job; what runs + * is still bounded by the warm's own gates (failure backoff, the daily + * forced-warm cap) and the generation's content-hash gate. + */ +export async function enqueueTodayBriefingWarm(payload: { + userId: string; + locale: SupportedLocale; + immediate: boolean; +}): Promise { + const boss = getGlobalBoss(); + if (!boss) { + annotate({ + action: { name: "insights.pregenerate.today.no_boss" }, + meta: { locale: payload.locale }, + }); + return; + } + try { + await boss.send( + INSIGHT_PREGENERATE_QUEUE, + { + userId: payload.userId, + force: true, + today: true, + locale: payload.locale, + } satisfies InsightPregeneratePayload, + { + singletonKey: `today:${payload.userId}`, + singletonSeconds: TODAY_WARM_DELAY_SECONDS, + startAfter: payload.immediate ? 0 : TODAY_WARM_DELAY_SECONDS, + expireInSeconds: INSIGHT_PREGENERATE_EXPIRE_SECONDS, + retryLimit: 2, + retryDelay: 60, + retryBackoff: true, + }, + ); + annotate({ + action: { name: "insights.pregenerate.today.enqueued" }, + meta: { locale: payload.locale, immediate: payload.immediate }, + }); + } catch { + // Best-effort, like every warm enqueue: the next read asks again. + } +} + /** * Delay before the single bounded retry of a failed nightly comprehensive * warm. 45 minutes: far enough out that a transient 02:30–04:30 provider diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index cdf911576..5e37cea90 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -898,6 +898,14 @@ export async function forceWarmUser( ) => Promise; /** Injected for the test — defaults to `new Date()`. */ now?: Date; + /** + * A briefing-for-today warm (`enqueueTodayBriefingWarm`): the + * comprehensive briefing only, past the one-hour freshness gate. The + * failure backoff, the daily forced-warm cap and the generation's own + * content-hash gate still hold, so a warm with nothing new to read + * costs no provider call. + */ + today?: boolean; } = {}, ): Promise { // v1.18.11 P3 — one feature-cache scope for the whole forced warm of this @@ -1001,6 +1009,7 @@ export async function forceWarmUser( const cachedAt = freshness?.insightsCachedAt ?? null; const failedAt = freshness?.insightsWarmFailedAt ?? null; const isFresh = + !options.today && cachedAt !== null && now.getTime() - cachedAt.getTime() < FORCE_WARM_FRESH_MS; const inBackoff = @@ -1125,7 +1134,9 @@ export async function forceWarmUser( // comprehensive is safe and bounded by their own per-card budgets. The only // outcome that has nothing to warm is a missing provider, and the // generators detect that themselves at near-zero cost. - if (statusText.available) { + // A today warm is about the briefing alone; the cards refill through + // their own read path when someone opens them. + if (statusText.available && !options.today) { // Refill-only (v1.16.8): a card already generated today is a cheap // cache read; a cold card runs its generator, whose content-hash gate // skips the LLM when the underlying data did not change. There is no diff --git a/src/lib/jobs/reminder/insights-handlers.ts b/src/lib/jobs/reminder/insights-handlers.ts index 4f529f204..c0443ac43 100644 --- a/src/lib/jobs/reminder/insights-handlers.ts +++ b/src/lib/jobs/reminder/insights-handlers.ts @@ -334,7 +334,9 @@ export async function handleInsightPregenerateJob( // GERMAN, so a forced warm produced German prose for a French reader. const locale = normalizeLocale(job.data.locale); try { - const summary = await forceWarmUser(getWorkerPrisma(), userId, locale); + const summary = await forceWarmUser(getWorkerPrisma(), userId, locale, { + today: job.data.today === true, + }); evt.addMeta( "force_warm", `${summary.comprehensive}:${summary.assessmentsWarmed}+${summary.metricAssessmentsWarmed}`, diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 16b65fd5b..af86c029f 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -2545,7 +2545,7 @@ export const insightsGenerateReadResponse = z .record(z.string(), z.unknown()) .nullable() .describe( - "The cached insight payload, verbatim from the account's cache row. Null when nothing is cached or the stored row failed to parse — the two are indistinguishable here, and the second also silently enqueues a repair.", + "The cached insight payload from the account's cache row, with its `dailyBriefing` resolved for today: null when the text was generated on an earlier calendar day in the account's timezone (or carries no `briefingGeneratedAt`, the moment it was generated), otherwise without the signals of the day whose reading-backed metric has no reading today, and with each signal delta at its metric's display precision in the reader's number format. Null when nothing is cached or the stored row failed to parse — the two are indistinguishable here, and the second also silently enqueues a repair.", ), cached: z .boolean() @@ -2615,7 +2615,9 @@ export const insightsGeneratePostResponse = z .object({ insights: z .record(z.string(), z.unknown()) - .describe("The insight payload, fresh or cached depending on `cached`."), + .describe( + "The insight payload, fresh or cached depending on `cached`, with its `dailyBriefing` resolved for today exactly as on the read GET. A cached briefing generated on an earlier calendar day never short-circuits an account with a provider: like a briefingless cache, it regenerates, and degrades to the cached payload on an exhausted quota.", + ), cached: z.boolean(), cachedAt: z.iso .datetime({ offset: true }) diff --git a/tests/integration/ai-optional-mixed-routes.test.ts b/tests/integration/ai-optional-mixed-routes.test.ts index e45966dc6..4529cae12 100644 --- a/tests/integration/ai-optional-mixed-routes.test.ts +++ b/tests/integration/ai-optional-mixed-routes.test.ts @@ -93,7 +93,11 @@ async function seed(recordId: string): Promise { await prisma.user.update({ where: { id: recordId }, data: { - insightsCachedText: JSON.stringify(BRIEFING), + // Generated now: only text written today is served as today's read. + insightsCachedText: JSON.stringify({ + ...BRIEFING, + briefingGeneratedAt: new Date().toISOString(), + }), insightsCachedAt: new Date(), insightsCachedLocale: "en", }, diff --git a/tests/integration/briefing-cache-locale.test.ts b/tests/integration/briefing-cache-locale.test.ts index b1073e961..0469710b9 100644 --- a/tests/integration/briefing-cache-locale.test.ts +++ b/tests/integration/briefing-cache-locale.test.ts @@ -66,7 +66,11 @@ async function seedUserWithCachedBriefing(cachedLocale: string | null) { // snapshot read resolves the reader's locale from. locale: "de", insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify({ dailyBriefing: BRIEFING }), + // Generated now: only text written today is served as today's read. + insightsCachedText: JSON.stringify({ + dailyBriefing: BRIEFING, + briefingGeneratedAt: new Date().toISOString(), + }), insightsCachedLocale: cachedLocale, // The briefing is shown only while its AI capability is available, so // the account has its own provider (presence only) and a receipt. diff --git a/tests/integration/sharing-front-door.test.ts b/tests/integration/sharing-front-door.test.ts index 301a918da..04ebedfe0 100644 --- a/tests/integration/sharing-front-door.test.ts +++ b/tests/integration/sharing-front-door.test.ts @@ -279,6 +279,8 @@ function cachedBriefing(marker: string): string { paragraph: `Briefing for the ${marker}. Second sentence.`, keyFindings: [], }, + // Generated now: only text written today is served as today's read. + briefingGeneratedAt: new Date().toISOString(), }); }