From 3694efe6e8f7fecd5ae4e8e4c3b4378b522ab2aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 16:33:03 +0200 Subject: [PATCH] fix(insights): keep the assessment card polling while a stale text revalidates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stale-while-revalidate path serves the last-good assessment on a cache miss and returns a terminal payload, so the status hook stopped polling. The worker warmed a fresh assessment out of band seconds later, but the open card never picked it up — the stale text stuck until the page remounted. Add a `revalidating` flag to the read-only miss outcome, set only when last-good text is served AND a refresh was enqueued (false on the suppressed-stub branch and when no prior text exists). Thread it through the generic metric-status generator and the seven specialised generators onto the wire, type it on the client, and make `nextStatusPollInterval` keep polling on `preparing || revalidating`. The existing 12-attempt ceiling bounds both signals, so a persistently failing generation still cannot poll forever. Co-Authored-By: Claude Opus 4.8 (1M context) (cherry picked from commit c9217534fbdb304c4df1b6eb55fb19e4360b8a10) --- docs/api/openapi.yaml | 5 ++++ src/app/insights/medications/page.tsx | 4 +++ .../__tests__/use-insight-status.test.ts | 23 ++++++++++++++ src/hooks/use-insight-status.ts | 30 +++++++++++++++---- .../insights/__tests__/status-cache.test.ts | 14 ++++++++- src/lib/insights/blood-pressure-status.ts | 3 ++ src/lib/insights/bmi-status.ts | 3 ++ src/lib/insights/general-status.ts | 3 ++ .../insights/medication-compliance-status.ts | 3 ++ src/lib/insights/metric-status.ts | 7 +++++ src/lib/insights/mood-status.ts | 3 ++ src/lib/insights/pulse-status.ts | 3 ++ src/lib/insights/status-cache.ts | 25 ++++++++++++++-- src/lib/insights/weight-status.ts | 3 ++ src/lib/openapi/routes.ts | 6 ++++ 15 files changed, 125 insertions(+), 10 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6395d7cab..3b14061c0 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -6559,6 +6559,11 @@ components: description: True when a first assessment is being generated out of band and no prior text exists yet — the client polls until it lands. type: boolean + revalidating: + description: True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in + flight. The payload is otherwise terminal; the client keeps polling on `preparing || revalidating` (bounded) + so the open card upgrades to the warmed assessment without a remount. + type: boolean insufficient: description: True when the metric has no readings; no assessment is generated (no LLM call). The card shows its insufficient-data state. diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index 57cb7cadd..682d70da4 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -63,6 +63,9 @@ interface MedicationComplianceStatusData { // cache miss while the worker warms the assessment; the card polls until // it lands. See `use-insight-status.ts` for the shared rationale. preparing?: boolean; + // v1.9.0 — last-good narrative served while a refresh is in flight; the + // card keeps polling (bounded) so the open page upgrades in-session. + revalidating?: boolean; } // v1.8.3 — client ceiling on the round-trip, mirroring the shared @@ -129,6 +132,7 @@ export default function InsightsMedikamentePage() { nextStatusPollInterval( query.state.data?.preparing, query.state.dataUpdateCount, + query.state.data?.revalidating, ), }); diff --git a/src/hooks/__tests__/use-insight-status.test.ts b/src/hooks/__tests__/use-insight-status.test.ts index ce47845ad..0f352ec9d 100644 --- a/src/hooks/__tests__/use-insight-status.test.ts +++ b/src/hooks/__tests__/use-insight-status.test.ts @@ -20,6 +20,9 @@ describe("nextStatusPollInterval — v1.8.4 poll ceiling", () => { // A terminal `no-provider` / settled payload never carries preparing, // so it stops immediately regardless of attempt count. expect(nextStatusPollInterval(false, 0)).toBe(false); + // A settled payload with neither preparing nor revalidating is terminal. + expect(nextStatusPollInterval(false, 0, false)).toBe(false); + expect(nextStatusPollInterval(undefined, 0, undefined)).toBe(false); }); it("keeps polling while preparing and below the cap", () => { @@ -42,4 +45,24 @@ describe("nextStatusPollInterval — v1.8.4 poll ceiling", () => { expect(interval).not.toBe(false); expect(interval as number).toBeGreaterThan(0); }); + + // v1.9.0 — stale-while-revalidate: a terminal payload that serves last-good + // text sets `revalidating` so the open card keeps polling until the + // freshly-warmed assessment lands, without a remount. + it("keeps polling on revalidating even when not preparing", () => { + expect(nextStatusPollInterval(false, 0, true)).toBeTypeOf("number"); + expect(nextStatusPollInterval(undefined, 1, true)).toBeTypeOf("number"); + expect( + nextStatusPollInterval(false, STATUS_POLL_MAX_ATTEMPTS - 1, true), + ).toBeTypeOf("number"); + }); + + it("honours the same attempt cap for revalidating", () => { + expect( + nextStatusPollInterval(false, STATUS_POLL_MAX_ATTEMPTS, true), + ).toBe(false); + expect( + nextStatusPollInterval(false, STATUS_POLL_MAX_ATTEMPTS + 5, true), + ).toBe(false); + }); }); diff --git a/src/hooks/use-insight-status.ts b/src/hooks/use-insight-status.ts index 710aba305..db3b92279 100644 --- a/src/hooks/use-insight-status.ts +++ b/src/hooks/use-insight-status.ts @@ -31,6 +31,15 @@ export interface InsightStatusData { * warms the cache. Absent / false means the payload is terminal. */ preparing?: boolean; + /** + * v1.9.0 — true when the route serves last-good (stale) text AND a fresh + * generation is in flight. The served payload would otherwise be terminal + * (`preparing` false), so the open card would stop polling and never pick + * up the warmed assessment until a remount. The hook keeps polling on + * `preparing || revalidating` (bounded by the same attempt ceiling) so the + * card upgrades to the fresh text in the same session. + */ + revalidating?: boolean; } /** @@ -64,17 +73,24 @@ const STATUS_POLL_MS = 4_000; export const STATUS_POLL_MAX_ATTEMPTS = 12; /** - * Decide whether a `preparing` card should schedule its next poll. - * Pure and shared between the `useInsightStatus` hook and the inline - * medication-compliance query so both sites enforce the identical - * ceiling. Returns the interval in ms while polling is warranted, or - * `false` once the payload is terminal OR the attempt cap is reached. + * Decide whether a card should schedule its next poll. Pure and shared + * between the `useInsightStatus` hook and the inline medication-compliance + * query so both sites enforce the identical ceiling. Returns the interval + * in ms while polling is warranted, or `false` once the payload is terminal + * OR the attempt cap is reached. + * + * v1.9.0 — `revalidating` joins `preparing` as a "keep polling" signal: when + * the route serves last-good (stale) text it would otherwise be terminal and + * stop the poll, so the freshly-warmed assessment never reaches the open card + * until a remount. The attempt ceiling bounds both signals identically, so a + * persistently failing generation still cannot poll an open page forever. */ export function nextStatusPollInterval( preparing: boolean | undefined, dataUpdateCount: number, + revalidating?: boolean | undefined, ): number | false { - if (!preparing) return false; + if (!preparing && !revalidating) return false; if (dataUpdateCount >= STATUS_POLL_MAX_ATTEMPTS) return false; return STATUS_POLL_MS; } @@ -161,6 +177,7 @@ export function useInsightStatus(metric: InsightStatusMetric) { nextStatusPollInterval( query.state.data?.preparing, query.state.dataUpdateCount, + query.state.data?.revalidating, ), }); } @@ -225,6 +242,7 @@ export function useInsightMetricStatus( nextStatusPollInterval( query.state.data?.preparing, query.state.dataUpdateCount, + query.state.data?.revalidating, ), }); } diff --git a/src/lib/insights/__tests__/status-cache.test.ts b/src/lib/insights/__tests__/status-cache.test.ts index c516c031f..ba798514f 100644 --- a/src/lib/insights/__tests__/status-cache.test.ts +++ b/src/lib/insights/__tests__/status-cache.test.ts @@ -163,7 +163,13 @@ describe("resolveReadOnlyStatusMiss", () => { locale: "de", }); expect(outcome.kind).toBe("preparing"); - expect(outcome).toEqual({ kind: "preparing", lastGood: null }); + // No last-good text to show, so nothing to revalidate against — the card + // polls on `preparing` alone. + expect(outcome).toEqual({ + kind: "preparing", + lastGood: null, + revalidating: false, + }); expect(enqueueStatusGeneration).toHaveBeenCalledWith({ userId: "u1", metric: "pulse", @@ -189,6 +195,9 @@ describe("resolveReadOnlyStatusMiss", () => { expect(outcome.kind).toBe("preparing"); if (outcome.kind !== "preparing") throw new Error("expected preparing"); expect(outcome.lastGood?.text).toBe("Steady upward trend."); + // v1.9.0 — stale text served AND a refresh enqueued → revalidating so the + // open card keeps polling until the fresh assessment lands. + expect(outcome.revalidating).toBe(true); // A refresh is still enqueued behind the stale serve. expect(enqueueStatusGeneration).toHaveBeenCalledTimes(1); }); @@ -209,6 +218,9 @@ describe("resolveReadOnlyStatusMiss", () => { locale: "en", }); expect(outcome.kind).toBe("preparing"); + if (outcome.kind !== "preparing") throw new Error("expected preparing"); + // No enqueue on the suppressed branch → nothing in flight to revalidate. + expect(outcome.revalidating).toBe(false); expect(enqueueStatusGeneration).not.toHaveBeenCalled(); }); diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index f9d52fdcd..dfd59e8a2 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -106,6 +106,8 @@ export async function generateBloodPressureStatusForUser( cached: boolean; updatedAt: string | null; preparing?: boolean; + /** v1.9.0 — last-good text served while a refresh is in flight; keep polling. */ + revalidating?: boolean; }> { const locale = normalizeLocale(options?.locale); const force = options?.force === true; @@ -151,6 +153,7 @@ export async function generateBloodPressureStatusForUser( cached: outcome.lastGood !== null, updatedAt: outcome.lastGood?.updatedAt ?? null, preparing: outcome.lastGood === null, + revalidating: outcome.revalidating, }; } diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index a3acf743d..e30202545 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -44,6 +44,8 @@ export async function generateBmiStatusForUser( cached: boolean; updatedAt: string | null; preparing?: boolean; + /** v1.9.0 — last-good text served while a refresh is in flight; keep polling. */ + revalidating?: boolean; }> { const locale = normalizeLocale(options?.locale); const force = options?.force === true; @@ -89,6 +91,7 @@ export async function generateBmiStatusForUser( cached: outcome.lastGood !== null, updatedAt: outcome.lastGood?.updatedAt ?? null, preparing: outcome.lastGood === null, + revalidating: outcome.revalidating, }; } diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index 6c5914b91..e7b766f3d 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -59,6 +59,8 @@ export async function generateGeneralStatusForUser( cached: boolean; updatedAt: string | null; preparing?: boolean; + /** v1.9.0 — last-good text served while a refresh is in flight; keep polling. */ + revalidating?: boolean; }> { const locale = normalizeLocale(options?.locale); const force = options?.force === true; @@ -104,6 +106,7 @@ export async function generateGeneralStatusForUser( cached: outcome.lastGood !== null, updatedAt: outcome.lastGood?.updatedAt ?? null, preparing: outcome.lastGood === null, + revalidating: outcome.revalidating, }; } diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index f1cc966ee..7502a2bc6 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -59,6 +59,8 @@ export async function generateMedicationComplianceStatusForUser( cached: boolean; updatedAt: string | null; preparing?: boolean; + /** v1.9.0 — last-good text served while a refresh is in flight; keep polling. */ + revalidating?: boolean; }> { const locale = normalizeLocale(options?.locale); const force = options?.force === true; @@ -140,6 +142,7 @@ export async function generateMedicationComplianceStatusForUser( cached: outcome.lastGood !== null, updatedAt: outcome.lastGood?.updatedAt ?? null, preparing: outcome.lastGood === null, + revalidating: outcome.revalidating, }; } diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index 855d4a18c..036df7c9a 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -66,6 +66,12 @@ export interface MetricStatusResult { cached: boolean; updatedAt: string | null; preparing?: boolean; + /** + * v1.9.0 — true when last-good text is served while a fresh generation is + * in flight. The card keeps polling (bounded) so the open page upgrades to + * the warmed assessment without a remount. + */ + revalidating?: boolean; /** * v1.8.7.1 — true when the metric has no data at all. The route maps * this onto the card's insufficient-data state; no LLM was called. @@ -178,6 +184,7 @@ export async function generateMetricStatus(args: { cached: outcome.lastGood !== null, updatedAt: outcome.lastGood?.updatedAt ?? null, preparing: outcome.lastGood === null, + revalidating: outcome.revalidating, }; } diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index 2a99246de..a69bb81d1 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -55,6 +55,8 @@ export async function generateMoodStatusForUser( cached: boolean; updatedAt: string | null; preparing?: boolean; + /** v1.9.0 — last-good text served while a refresh is in flight; keep polling. */ + revalidating?: boolean; }> { const locale = normalizeLocale(options?.locale); const force = options?.force === true; @@ -100,6 +102,7 @@ export async function generateMoodStatusForUser( cached: outcome.lastGood !== null, updatedAt: outcome.lastGood?.updatedAt ?? null, preparing: outcome.lastGood === null, + revalidating: outcome.revalidating, }; } diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index 9300e66b8..c75fb0c2b 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -50,6 +50,8 @@ export async function generatePulseStatusForUser( cached: boolean; updatedAt: string | null; preparing?: boolean; + /** v1.9.0 — last-good text served while a refresh is in flight; keep polling. */ + revalidating?: boolean; }> { const locale = normalizeLocale(options?.locale); const force = options?.force === true; @@ -95,6 +97,7 @@ export async function generatePulseStatusForUser( cached: outcome.lastGood !== null, updatedAt: outcome.lastGood?.updatedAt ?? null, preparing: outcome.lastGood === null, + revalidating: outcome.revalidating, }; } diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts index f5908cf7c..fd2ce3b01 100644 --- a/src/lib/insights/status-cache.ts +++ b/src/lib/insights/status-cache.ts @@ -153,10 +153,22 @@ export async function readLastGoodStatusText(args: { * 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. + * + * v1.9.0 — `revalidating` is true only when last-good text is served AND a + * fresh generation was actually enqueued (the open-card refresh case). When + * the served text is terminal the card otherwise stops polling and never + * sees the freshly-warmed assessment until a remount; the flag keeps the + * bounded poll alive until the new row lands. It is false on the + * suppressed-stub branch (no enqueue, so nothing is in flight) and whenever + * there is no last-good text (that path already polls via `preparing`). */ export type ReadOnlyMissOutcome = | { kind: "no-provider" } - | { kind: "preparing"; lastGood: LastGoodStatusHit | null }; + | { + kind: "preparing"; + lastGood: LastGoodStatusHit | null; + revalidating: boolean; + }; /** * v1.8.3 — resolve what a read-only status generation should return on a @@ -204,7 +216,10 @@ export async function resolveReadOnlyStatusMiss(args: { action: { name: "insights.status.preparing" }, meta: { metric: args.metric, suppressed_enqueue: true }, }); - return { kind: "preparing", lastGood }; + // No enqueue on this branch — nothing is in flight, so the open card has + // nothing to revalidate against. It still polls via `preparing` when + // there is no last-good text to show. + return { kind: "preparing", lastGood, revalidating: false }; } // Enqueue out of band — do NOT await an LLM here. The enqueue itself is @@ -218,7 +233,11 @@ export async function resolveReadOnlyStatusMiss(args: { action: { name: "insights.status.preparing" }, meta: { metric: args.metric, stale_served: lastGood !== null }, }); - return { kind: "preparing", lastGood }; + // A fresh generation is now in flight. When last-good text is served the + // payload is otherwise terminal (`preparing` is false), so the open card + // would stop polling and never pick up the warmed assessment. Signal + // `revalidating` so the bounded poll stays alive until the new row lands. + return { kind: "preparing", lastGood, revalidating: lastGood !== null }; } /** diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index b34580c3a..21115b8a0 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -99,6 +99,8 @@ export async function generateWeightStatusForUser( cached: boolean; updatedAt: string | null; preparing?: boolean; + /** v1.9.0 — last-good text served while a refresh is in flight; keep polling. */ + revalidating?: boolean; }> { const locale = normalizeLocale(options?.locale); const force = options?.force === true; @@ -149,6 +151,7 @@ export async function generateWeightStatusForUser( cached: outcome.lastGood !== null, updatedAt: outcome.lastGood?.updatedAt ?? null, preparing: outcome.lastGood === null, + revalidating: outcome.revalidating, }; } diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index b963b64b5..6c433d7c2 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -1144,6 +1144,12 @@ const metricStatusResponse = z .describe( "True when a first assessment is being generated out of band and no prior text exists yet — the client polls until it lands.", ), + revalidating: z + .boolean() + .optional() + .describe( + "True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The payload is otherwise terminal; the client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the warmed assessment without a remount.", + ), insufficient: z .boolean() .optional()