From 491d542f5847a42bb444da5352d8661abece8bea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 21:57:49 +0200 Subject: [PATCH] fix(daily): judge the briefing's day by when its text was written The check that keeps an earlier day's briefing off the Today surfaces read the cache row's insightsCachedAt. Every warm moves that column, including the unchanged-hash short-circuit that keeps yesterday's text and only refreshes the stamp, so text written the day before passed as today's after the nightly warm. The cached payload now carries briefingGeneratedAt, written only where new text is written: a full generation in the warm and in the on-demand route, and the daily paragraph re-roll. The stamp refresh leaves it untouched. briefingForToday reads that moment, and a payload without it counts as not from today, so the hero falls back to its own lead until the next generation. --- src/app/api/insights/generate/route.ts | 5 +- .../daily/__tests__/briefing-today.test.ts | 18 ++++-- src/lib/daily/__tests__/load-digest.test.ts | 61 +++++++++++++++++++ src/lib/daily/briefing-today.ts | 17 ++++-- src/lib/daily/load-digest.ts | 3 +- .../__tests__/briefing-generated-at.test.ts | 27 ++++++++ .../comprehensive-briefing-reroll.test.ts | 13 ++++ .../comprehensive-generate-force.test.ts | 6 ++ src/lib/insights/briefing-generated-at.ts | 45 ++++++++++++++ src/lib/insights/comprehensive-generate.ts | 12 +++- 10 files changed, 193 insertions(+), 14 deletions(-) create mode 100644 src/lib/insights/__tests__/briefing-generated-at.test.ts create mode 100644 src/lib/insights/briefing-generated-at.ts diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 2180ace0c..a6d53d23b 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -11,6 +11,7 @@ * write breaks that convergence — the settling seam tests pin the client * half, this comment is the server half of the contract. */ +import { withBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { @@ -1051,7 +1052,9 @@ export const POST = apiHandler((request: NextRequest) => }, data: { insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify(insights), + insightsCachedText: JSON.stringify( + withBriefingGeneratedAt(insights, new Date()), + ), insightsCachedLocale: locale, insightsSnapshotHash: hashInsightSnapshot({ features: compactFeatures, diff --git a/src/lib/daily/__tests__/briefing-today.test.ts b/src/lib/daily/__tests__/briefing-today.test.ts index 0be476d9f..a3b877539 100644 --- a/src/lib/daily/__tests__/briefing-today.test.ts +++ b/src/lib/daily/__tests__/briefing-today.test.ts @@ -142,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"]); @@ -151,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( @@ -164,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..fb459be1e 100644 --- a/src/lib/daily/__tests__/load-digest.test.ts +++ b/src/lib/daily/__tests__/load-digest.test.ts @@ -363,6 +363,67 @@ describe("loadDailyDigest — AI parts", () => { }); }); +describe("loadDailyDigest — the briefing's own day", () => { + const BRIEFING = { + paragraph: "Last night's sleep came in a little short of your usual.", + signalsOfDay: [ + { + sourceMetric: "sleep", + tone: "info", + headline: "Shorter night", + nudge: "An earlier night would suit it.", + delta: null, + }, + ], + keyFindings: [], + }; + + function withBriefing(cachedText: string) { + vi.mocked(readDashboardSnapshotCached).mockResolvedValueOnce({ + ...SNAPSHOT, + // The row was stamped this morning, as every warm stamps it. + body: { + ...SNAPSHOT.body, + briefing: BRIEFING, + briefingUpdatedAt: "2026-07-17T02:30:00.000Z", + }, + } as never); + return { ...USER, insightsCachedText: cachedText } as User; + } + + it("does not serve yesterday's text that a warm only re-stamped", async () => { + // Generated yesterday; the unchanged-hash warm at 04:30 refreshed only + // insightsCachedAt. + const user = withBriefing( + JSON.stringify({ + dailyBriefing: BRIEFING, + briefingGeneratedAt: "2026-07-16T02:30:00.000Z", + }), + ); + const digest = await loadDailyDigest(user, NOW); + expect(digest.briefingLead).toBeNull(); + expect(digest.topSignal).toBeNull(); + }); + + it("does not serve a payload that predates the generation moment", async () => { + const user = withBriefing(JSON.stringify({ dailyBriefing: BRIEFING })); + const digest = await loadDailyDigest(user, NOW); + expect(digest.topSignal).toBeNull(); + }); + + it("serves text generated today", async () => { + const user = withBriefing( + JSON.stringify({ + dailyBriefing: BRIEFING, + briefingGeneratedAt: "2026-07-17T02:30:00.000Z", + }), + ); + const digest = await loadDailyDigest(user, NOW); + expect(digest.briefingLead).toBe(BRIEFING.paragraph); + expect(digest.topSignal?.sourceMetric).toBe("sleep"); + }); +}); + /** 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.ts b/src/lib/daily/briefing-today.ts index e4c2280f2..4ca2b7720 100644 --- a/src/lib/daily/briefing-today.ts +++ b/src/lib/daily/briefing-today.ts @@ -161,8 +161,13 @@ export function formatSignalDelta( } 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. */ @@ -173,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. */ @@ -184,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..03dfa95ed 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -84,6 +84,7 @@ 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 { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; import type { DateFormatPreference, TimeFormatPreference, @@ -938,7 +939,7 @@ export async function loadDailyDigest( // 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, + generatedAt: readBriefingGeneratedAt(user.insightsCachedText), lastSeenAt: (type) => snapshot.tiles.lastSeenByType[type]?.lastSeenAt ?? null, timezone: user.timezone, 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, },