fix(insights): keep the assessment card polling while a stale text revalidates

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) <noreply@anthropic.com>
(cherry picked from commit c9217534fbdb304c4df1b6eb55fb19e4360b8a10)
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 16:45:14 +02:00
1 parent 03925ddece
commit 3694efe6e8
15 files changed
+125 -10

No files matched your search

@@ -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();
});
@@ -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,
};
}
+3
View File
@@ -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,
};
}
+3
View File
@@ -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,
};
}
@@ -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,
};
}
+7
View File
@@ -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,
};
}
+3
View File
@@ -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,
};
}
+3
View File
@@ -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,
};
}
+22 -3
View File
@@ -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 };
}
/**
+3
View File
@@ -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,
};
}
+6
View File
@@ -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()