fix(daily): write the briefing for the reader's own morning and readings

With signals of the day resting on today's readings only, the briefing
had to be written after those readings, and it was only written at the
04:30 Berlin warm, the post-sleep morning refresh, or a page open on a
day-old cache. A morning blood pressure or weigh-in never became a
signal, and since an earlier day's briefing is no longer served, a
reader in Los Angeles or Seoul had none for most of the day: the Berlin
warm lands the previous evening there, or at noon.

The snapshot read every surface goes through (dashboard, iOS, digest,
morning push) now asks for a briefing for today at two moments: the
first read of the reader's local day with no briefing written today,
and the first read after a signal metric's first reading of the day
that the briefing has not read. Each reason is claimed once per user
per local day in a Postgres bucket and remembered in-process, so later
reads cost no query; no write path needs a hook.

A claim enqueues one today warm: immediately for a new day, after a
five-minute window for readings, so a burst (weigh-in, then blood
pressure) folds into one job. The warm writes the comprehensive
briefing only, past the one-hour freshness gate but under the failure
backoff, the daily forced-warm cap (two per 24 h) and the content-hash
gate.

Worst case per user per local day: one enqueue per reason, at most
eight (the day plus seven signal reading types, measured over a day of
reads every two minutes); at most two forced comprehensive attempts
under the existing cap, each at most three completions (the answer, a
JSON retry, a grounding retry), so six provider calls at the ceiling.
A warm whose data has not changed costs at most the existing
once-a-day paragraph re-roll, inside the same cap.
This commit is contained in:
Marc-André Bombeck committed 2026-10-05 22:09:41 +02:00
1 parent f6b0131731
commit 43361bd74e
8 files changed
+533 -10

No files matched your search

@@ -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>): 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<string, string> = {
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<string, number>();
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);
});
});
+126
View File
@@ -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<string, true>();
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:<TYPE>` 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<boolean> {
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<void> {
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();
}
@@ -6,7 +6,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { User } from "@/generated/prisma/client";
import { AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
import {
AI_AVAILABLE,
aiUnavailable,
} from "@/__tests__/helpers/ai-capability-fixtures";
const BRIEFING = {
paragraph: "Your pulse is well above its usual level today.",
@@ -52,8 +55,15 @@ vi.mock("@/lib/dashboard/snapshot", async (importOriginal) => ({
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 () => AI_AVAILABLE,
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");
@@ -75,6 +85,8 @@ function user(generatedAt: string | null): User {
}
beforeEach(() => {
capability.current = AI_AVAILABLE;
requestTodayBriefingWarm.mockClear();
__resetAllCachesForTests();
current = body("2026-10-04T06:50:00.000Z");
});
@@ -123,3 +135,32 @@ describe("readDashboardSnapshotCached — the briefing's own day", () => {
);
});
});
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();
});
});
+20 -6
View File
@@ -38,6 +38,7 @@ 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";
@@ -175,15 +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 now = options.now ?? new Date();
const generatedAt = readBriefingGeneratedAt(user.insightsCachedText);
const body = applyBriefingForToday(
applyBriefingCapability(cached, briefingAi),
{
generatedAt: readBriefingGeneratedAt(user.insightsCachedText),
timezone: user.timezone,
language: locale,
now: options.now ?? new Date(),
},
{ 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 };
}
@@ -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([]);
@@ -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<void> {
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
+12 -1
View File
@@ -898,6 +898,14 @@ export async function forceWarmUser(
) => Promise<number>;
/** 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<ForceWarmResult> {
// 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
+3 -1
View File
@@ -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}`,