From f6b0131731ca166bc1e992ee57abdf1c7ada8293 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 5 Oct 2026 22:03:58 +0200 Subject: [PATCH] fix(daily): serve the briefing as it stands today on every surface Only the digest applied briefingForToday. The dashboard snapshot (the web dashboard, its server prefetch and the iOS cold-launch read) and the /insights advisor payload still served an earlier day's text and the raw model deltas ("+33.72 bpm"). The snapshot read now resolves the briefing after the cache, beside the capability decision, from the payload's generation moment and the snapshot's own newest reading per type; a withheld briefing reads "preparing", never "ready" with nothing in it. The digest takes the resolved briefing from that read on its own clock instead of resolving again. The advisor route resolves dailyBriefing on every branch it returns, reading the newest reading per signal type in one grouped query only when a reading-backed signal is present; text generated on an earlier day no longer counts as fresh on the read GET (so the warm is enqueued) and no longer short-circuits a provider-backed POST. The OpenAPI descriptions of both advisor responses say so. --- docs/api/openapi.yaml | 14 +- .../insights/generate/__tests__/route.test.ts | 96 +++++++++++++- src/app/api/insights/generate/route.ts | 62 +++++++-- src/lib/daily/__tests__/load-digest.test.ts | 62 +-------- src/lib/daily/briefing-today-read.ts | 101 ++++++++++++++ src/lib/daily/load-digest.ts | 21 +-- .../snapshot-read-briefing-capability.test.ts | 11 +- .../snapshot-read-briefing-today.test.ts | 125 ++++++++++++++++++ src/lib/dashboard/snapshot-read.ts | 55 +++++++- src/lib/openapi/routes/insights/schemas.ts | 6 +- 10 files changed, 462 insertions(+), 91 deletions(-) create mode 100644 src/lib/daily/briefing-today-read.ts create mode 100644 src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts 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/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 a6d53d23b..f19736286 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -11,6 +11,12 @@ * 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"; @@ -168,6 +174,7 @@ export const GET = apiHandler(async (request: NextRequest) => { insightsCachedText: true, insightsCachedLocale: true, locale: true, + timezone: true, }, }); @@ -194,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 @@ -254,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, @@ -329,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. @@ -376,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; @@ -420,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" }, @@ -433,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, @@ -480,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, @@ -1039,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, @@ -1052,9 +1092,7 @@ export const POST = apiHandler((request: NextRequest) => }, data: { insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify( - withBriefingGeneratedAt(insights, new Date()), - ), + insightsCachedText: JSON.stringify(stored), insightsCachedLocale: locale, insightsSnapshotHash: hashInsightSnapshot({ features: compactFeatures, @@ -1134,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/lib/daily/__tests__/load-digest.test.ts b/src/lib/daily/__tests__/load-digest.test.ts index fb459be1e..dc7773590 100644 --- a/src/lib/daily/__tests__/load-digest.test.ts +++ b/src/lib/daily/__tests__/load-digest.test.ts @@ -363,64 +363,12 @@ 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", - }), +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 }), ); - 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"); }); }); 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/load-digest.ts b/src/lib/daily/load-digest.ts index 03dfa95ed..52db327b0 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -83,8 +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 { readBriefingGeneratedAt } from "@/lib/insights/briefing-generated-at"; import type { DateFormatPreference, TimeFormatPreference, @@ -573,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] } }, @@ -935,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, { - generatedAt: readBriefingGeneratedAt(user.insightsCachedText), - 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..25ef9d114 --- /dev/null +++ b/src/lib/dashboard/__tests__/snapshot-read-briefing-today.test.ts @@ -0,0 +1,125 @@ +/** + * 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 } 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", +})); +vi.mock("@/lib/ai/capabilities/gate", () => ({ + aiCapabilityToServe: async () => AI_AVAILABLE, +})); + +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(() => { + __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", + ); + }); +}); diff --git a/src/lib/dashboard/snapshot-read.ts b/src/lib/dashboard/snapshot-read.ts index 41cfb684c..59949a411 100644 --- a/src/lib/dashboard/snapshot-read.ts +++ b/src/lib/dashboard/snapshot-read.ts @@ -37,6 +37,9 @@ 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 { 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 +53,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 +106,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 +175,15 @@ 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 body = applyBriefingForToday( + applyBriefingCapability(cached, briefingAi), + { + generatedAt: readBriefingGeneratedAt(user.insightsCachedText), + timezone: user.timezone, + language: locale, + now: options.now ?? new Date(), + }, + ); return { body, locale }; } 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 })