From 60f347bf8df43571c837ea923c93469977d95a0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 09:57:06 +0200 Subject: [PATCH] feat(insights): serve assessments stale-while-revalidate on a cache miss A category open whose today-cache had been evicted dropped to the preparing skeleton even when a perfectly good assessment from an earlier day was still on record. Read the most recent non-stub assessment for the scope and serve it instantly while the worker re-warms the cache; only fall back to the preparing state when no assessment was ever produced. The user no longer waits on an open if an assessment ever existed. --- .../insights/__tests__/status-cache.test.ts | 38 ++++++++-- src/lib/insights/blood-pressure-status.ts | 13 ++-- src/lib/insights/bmi-status.ts | 13 ++-- src/lib/insights/general-status.ts | 13 ++-- .../insights/medication-compliance-status.ts | 13 ++-- src/lib/insights/mood-status.ts | 13 ++-- src/lib/insights/pulse-status.ts | 13 ++-- src/lib/insights/status-cache.ts | 71 +++++++++++++++++-- src/lib/insights/weight-status.ts | 13 ++-- 9 files changed, 154 insertions(+), 46 deletions(-) diff --git a/src/lib/insights/__tests__/status-cache.test.ts b/src/lib/insights/__tests__/status-cache.test.ts index fbd4e98c7..c516c031f 100644 --- a/src/lib/insights/__tests__/status-cache.test.ts +++ b/src/lib/insights/__tests__/status-cache.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ prisma: { - auditLog: { findFirst: vi.fn() }, + auditLog: { findFirst: vi.fn(), findMany: vi.fn() }, }, })); @@ -137,6 +137,11 @@ describe("readFreshStatusText", () => { }); describe("resolveReadOnlyStatusMiss", () => { + beforeEach(() => { + // Default: no prior assessment to serve stale (readLastGoodStatusText). + vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never); + }); + it("returns no-provider without enqueuing when the user has no provider", async () => { hasUsableStatusProvider.mockResolvedValue(false); const outcome = await resolveReadOnlyStatusMiss({ @@ -144,7 +149,7 @@ describe("resolveReadOnlyStatusMiss", () => { metric: "weight", locale: "en", }); - expect(outcome).toBe("no-provider"); + expect(outcome.kind).toBe("no-provider"); expect(enqueueStatusGeneration).not.toHaveBeenCalled(); }); @@ -157,7 +162,8 @@ describe("resolveReadOnlyStatusMiss", () => { metric: "pulse", locale: "de", }); - expect(outcome).toBe("preparing"); + expect(outcome.kind).toBe("preparing"); + expect(outcome).toEqual({ kind: "preparing", lastGood: null }); expect(enqueueStatusGeneration).toHaveBeenCalledWith({ userId: "u1", metric: "pulse", @@ -165,6 +171,28 @@ describe("resolveReadOnlyStatusMiss", () => { }); }); + it("serves the last good assessment stale-while-revalidate on a clean miss", async () => { + hasUsableStatusProvider.mockResolvedValue(true); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); + // A prior (e.g. yesterday's) real assessment is on record. + vi.mocked(prisma.auditLog.findMany).mockResolvedValue([ + cacheRow( + { dateKey: "2026-05-30", text: "Steady upward trend.", model: "gpt-4o-mini" }, + new Date("2026-05-30T04:30:00.000Z"), + ), + ] as never); + const outcome = await resolveReadOnlyStatusMiss({ + userId: "u1", + metric: "weight", + locale: "en", + }); + expect(outcome.kind).toBe("preparing"); + if (outcome.kind !== "preparing") throw new Error("expected preparing"); + expect(outcome.lastGood?.text).toBe("Steady upward trend."); + // A refresh is still enqueued behind the stale serve. + expect(enqueueStatusGeneration).toHaveBeenCalledTimes(1); + }); + it("suppresses re-enqueue while a fresh timeout stub exists", async () => { hasUsableStatusProvider.mockResolvedValue(true); vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( @@ -180,7 +208,7 @@ describe("resolveReadOnlyStatusMiss", () => { metric: "mood", locale: "en", }); - expect(outcome).toBe("preparing"); + expect(outcome.kind).toBe("preparing"); expect(enqueueStatusGeneration).not.toHaveBeenCalled(); }); @@ -199,7 +227,7 @@ describe("resolveReadOnlyStatusMiss", () => { metric: "bmi", locale: "de", }); - expect(outcome).toBe("preparing"); + expect(outcome.kind).toBe("preparing"); expect(enqueueStatusGeneration).toHaveBeenCalledTimes(1); }); }); diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index 2b10e3f7b..f9d52fdcd 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -134,7 +134,7 @@ export async function generateBloodPressureStatusForUser( metric: "blood-pressure", locale, }); - if (outcome === "no-provider") { + if (outcome.kind === "no-provider") { return { hasProvider: false, text: getNoKeyBloodPressureStatusText(locale), @@ -142,12 +142,15 @@ export async function generateBloodPressureStatusForUser( updatedAt: null, }; } + // v1.8.7 — stale-while-revalidate: serve the last good assessment + // (if any) instantly while the worker re-warms; only fall to the empty + // preparing skeleton when none was ever produced. return { hasProvider: true, - text: null, - cached: false, - updatedAt: null, - preparing: true, + text: outcome.lastGood?.text ?? null, + cached: outcome.lastGood !== null, + updatedAt: outcome.lastGood?.updatedAt ?? null, + preparing: outcome.lastGood === null, }; } diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index a455fc2eb..a3acf743d 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -72,7 +72,7 @@ export async function generateBmiStatusForUser( metric: "bmi", locale, }); - if (outcome === "no-provider") { + if (outcome.kind === "no-provider") { return { hasProvider: false, text: getNoKeyBmiStatusText(locale), @@ -80,12 +80,15 @@ export async function generateBmiStatusForUser( updatedAt: null, }; } + // v1.8.7 — stale-while-revalidate: serve the last good assessment + // (if any) instantly while the worker re-warms; only fall to the empty + // preparing skeleton when none was ever produced. return { hasProvider: true, - text: null, - cached: false, - updatedAt: null, - preparing: true, + text: outcome.lastGood?.text ?? null, + cached: outcome.lastGood !== null, + updatedAt: outcome.lastGood?.updatedAt ?? null, + preparing: outcome.lastGood === null, }; } diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index 1693646f1..6c5914b91 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -87,7 +87,7 @@ export async function generateGeneralStatusForUser( metric: "general", locale, }); - if (outcome === "no-provider") { + if (outcome.kind === "no-provider") { return { hasProvider: false, text: getNoKeyGeneralStatusText(locale), @@ -95,12 +95,15 @@ export async function generateGeneralStatusForUser( updatedAt: null, }; } + // v1.8.7 — stale-while-revalidate: serve the last good assessment + // (if any) instantly while the worker re-warms; only fall to the empty + // preparing skeleton when none was ever produced. return { hasProvider: true, - text: null, - cached: false, - updatedAt: null, - preparing: true, + text: outcome.lastGood?.text ?? null, + cached: outcome.lastGood !== null, + updatedAt: outcome.lastGood?.updatedAt ?? null, + preparing: outcome.lastGood === null, }; } diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 4c69084ff..f1cc966ee 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -121,7 +121,7 @@ export async function generateMedicationComplianceStatusForUser( metric: "medication-compliance", locale, }); - if (outcome === "no-provider") { + if (outcome.kind === "no-provider") { return { hasProvider: false, summary: getNoKeyMedicationComplianceStatusText(locale), @@ -130,13 +130,16 @@ export async function generateMedicationComplianceStatusForUser( updatedAt: null, }; } + // v1.8.7 — stale-while-revalidate: serve the last good narrative (if + // any) while the worker re-warms; only show the empty preparing + // skeleton when none was ever produced. return { hasProvider: true, - summary: null, + summary: outcome.lastGood?.text ?? null, medications: [], - cached: false, - updatedAt: null, - preparing: true, + cached: outcome.lastGood !== null, + updatedAt: outcome.lastGood?.updatedAt ?? null, + preparing: outcome.lastGood === null, }; } diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index d54173f17..2a99246de 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -83,7 +83,7 @@ export async function generateMoodStatusForUser( metric: "mood", locale, }); - if (outcome === "no-provider") { + if (outcome.kind === "no-provider") { return { hasProvider: false, text: getNoKeyMoodStatusText(locale), @@ -91,12 +91,15 @@ export async function generateMoodStatusForUser( updatedAt: null, }; } + // v1.8.7 — stale-while-revalidate: serve the last good assessment + // (if any) instantly while the worker re-warms; only fall to the empty + // preparing skeleton when none was ever produced. return { hasProvider: true, - text: null, - cached: false, - updatedAt: null, - preparing: true, + text: outcome.lastGood?.text ?? null, + cached: outcome.lastGood !== null, + updatedAt: outcome.lastGood?.updatedAt ?? null, + preparing: outcome.lastGood === null, }; } diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index ee52eab81..9300e66b8 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -78,7 +78,7 @@ export async function generatePulseStatusForUser( metric: "pulse", locale, }); - if (outcome === "no-provider") { + if (outcome.kind === "no-provider") { return { hasProvider: false, text: getNoKeyPulseStatusText(locale), @@ -86,12 +86,15 @@ export async function generatePulseStatusForUser( updatedAt: null, }; } + // v1.8.7 — stale-while-revalidate: serve the last good assessment (if + // any) instantly while the worker re-warms the cache; only fall to the + // empty preparing skeleton when none was ever produced. return { hasProvider: true, - text: null, - cached: false, - updatedAt: null, - preparing: true, + text: outcome.lastGood?.text ?? null, + cached: outcome.lastGood !== null, + updatedAt: outcome.lastGood?.updatedAt ?? null, + preparing: outcome.lastGood === null, }; } diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts index ca6b187a5..fc9e6e687 100644 --- a/src/lib/insights/status-cache.ts +++ b/src/lib/insights/status-cache.ts @@ -101,13 +101,62 @@ export async function readFreshStatusText(args: { } } +export interface LastGoodStatusHit { + text: string; + updatedAt: string; +} + +/** + * v1.8.7 — read the most recent NON-stub assessment for `(userId, + * cacheAction)` regardless of which day it was generated. This is the + * stale-while-revalidate source: when today's cache is a miss the + * read-only path can still serve yesterday's (or older) good text + * instantly while a fresh generation is warmed out of band, so opening a + * category never drops to the "preparing" skeleton if an assessment was + * ever produced. Returns `null` only when there is genuinely no prior + * assessment (or every prior row is a timeout stub / malformed). + */ +export async function readLastGoodStatusText(args: { + userId: string; + cacheAction: string; +}): Promise { + const { userId, cacheAction } = args; + const rows = await prisma.auditLog.findMany({ + where: { userId, action: cacheAction }, + orderBy: { createdAt: "desc" }, + take: 5, + select: { createdAt: true, details: true }, + }); + for (const row of rows) { + if (!row.details) continue; + try { + const parsed = JSON.parse(row.details) as ParsedStatusCache; + if (isTimeoutStub(parsed)) continue; + if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) { + continue; + } + return { text: parsed.text, updatedAt: row.createdAt.toISOString() }; + } catch { + // Malformed payload — skip and look further back. + continue; + } + } + return null; +} + /** * Outcome of the read-only cache-miss resolution. The generators map this * onto their public return shape: * - `no-provider` → `{ hasProvider: false, text: }` - * - `preparing` → `{ hasProvider: true, text: null, preparing: true }` + * - `preparing` → `{ hasProvider: true, text: , preparing: true }` + * + * v1.8.7 — `preparing` now carries the last good assessment (if any) so + * the card renders the previous text immediately (stale-while-revalidate) + * instead of a skeleton while the worker re-warms the cache. */ -export type ReadOnlyMissOutcome = "no-provider" | "preparing"; +export type ReadOnlyMissOutcome = + | { kind: "no-provider" } + | { kind: "preparing"; lastGood: LastGoodStatusHit | null }; /** * v1.8.3 — resolve what a read-only status generation should return on a @@ -131,10 +180,20 @@ export async function resolveReadOnlyStatusMiss(args: { locale: "de" | "en"; }): Promise { const hasProvider = await hasUsableStatusProvider(args.userId); - if (!hasProvider) return "no-provider"; + if (!hasProvider) return { kind: "no-provider" }; const cacheAction = `insights.${args.metric}-status.${args.locale}`; + // v1.8.7 — stale-while-revalidate. Surface the last good (non-stub) + // assessment for this scope so the card renders the previous text + // immediately instead of a skeleton while a refresh is warmed. Null when + // no assessment was ever produced — only then does the card show + // "preparing"/"no analysis yet". + const lastGood = await readLastGoodStatusText({ + userId: args.userId, + cacheAction, + }); + // v1.8.3 — honour the short-TTL negative cache. If the worker recently // hit a provider stall it wrote a `retryAt` stub; re-enqueuing on every // navigation while the provider is still degraded would be a storm. While @@ -145,7 +204,7 @@ export async function resolveReadOnlyStatusMiss(args: { action: { name: "insights.status.preparing" }, meta: { metric: args.metric, suppressed_enqueue: true }, }); - return "preparing"; + return { kind: "preparing", lastGood }; } // Enqueue out of band — do NOT await an LLM here. The enqueue itself is @@ -157,9 +216,9 @@ export async function resolveReadOnlyStatusMiss(args: { }); annotate({ action: { name: "insights.status.preparing" }, - meta: { metric: args.metric }, + meta: { metric: args.metric, stale_served: lastGood !== null }, }); - return "preparing"; + return { kind: "preparing", lastGood }; } /** diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index efd4d368a..b34580c3a 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -132,7 +132,7 @@ export async function generateWeightStatusForUser( metric: "weight", locale, }); - if (outcome === "no-provider") { + if (outcome.kind === "no-provider") { return { hasProvider: false, text: getNoKeyWeightStatusText(locale), @@ -140,12 +140,15 @@ export async function generateWeightStatusForUser( updatedAt: null, }; } + // v1.8.7 — stale-while-revalidate: serve the last good assessment + // (if any) instantly while the worker re-warms; only fall to the empty + // preparing skeleton when none was ever produced. return { hasProvider: true, - text: null, - cached: false, - updatedAt: null, - preparing: true, + text: outcome.lastGood?.text ?? null, + cached: outcome.lastGood !== null, + updatedAt: outcome.lastGood?.updatedAt ?? null, + preparing: outcome.lastGood === null, }; }