mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
03925ddece
commit
3694efe6e8
15 files changed
+125
-10
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user