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, },