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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-05 21:57:49 +02:00
1 parent 14a2931a99
commit 491d542f58
10 files changed
+193 -14

No files matched your search

+4 -1
View File
@@ -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,
+14 -4
View File
@@ -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,
};
@@ -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<string, unknown> = {}) {
return {
+11 -6
View File
@@ -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;
}
+2 -1
View File
@@ -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,
@@ -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();
});
});
@@ -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<string, unknown> }).data
.insightsCachedText !== undefined,
),
).toBe(false);
});
});
@@ -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();
});
+45
View File
@@ -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<T extends object>(
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<string, unknown>)[BRIEFING_GENERATED_AT_KEY];
return typeof at === "string" && !Number.isNaN(Date.parse(at)) ? at : null;
}
+10 -2
View File
@@ -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<string, unknown>),
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,
},