mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
fix(insights): make per-category status cards read-only so navigation never blocks on the LLM
Opening an Insights category page froze the whole UI: the per-metric status card fired a cold, uncapped LLM fetch on mount with no client abort, so the request hung up to the server's 60s ceiling and the pending/resolve re-renders thrashed the tab strip and ate touch events. Timeouts were never negatively cached, so every visit re-blocked. Fix the class holistically: - Bound every status GET on the client. The shared use-insight-status hook and the inline medication-compliance query now wrap fetch in an 8s AbortController and stop retrying inline; navigation can no longer await an uncapped round-trip. - Make the status routes read-only. On a cache miss the generator enqueues an out-of-band pg-boss job (insight-status-generate) and returns a preparing envelope; the client polls a bounded interval until the worker warms the cache. Models the dashboard snapshot's ready/preparing pattern. The queue is registered in the worker allQueues loop + a boss.work handler, with a guard test. - Fix the nightly warm-pass regression: warm every supported locale (the client reads against the active UI locale, not the persisted User.locale) and warm on a cached comprehensive outcome too (its write still evicts the per-status caches). Add a short-TTL negative cache on the timeout path so a transient provider stall no longer makes every subsequent visit re-block or re-enqueue. - Add loading.tsx for the insights segment + the root so any future RSC await streams a skeleton instead of blocking the transition. - Stabilise the advisor regenerate callback so a status-query flip can't re-render the memoised tab strip mid-gesture. New i18n key insights.assessmentPreparing across all six locales.
This commit is contained in:
1 parent
6a603ad587
commit
35800becbb
47 files changed
+1297
-75
No files matched your search
@@ -23,8 +23,32 @@ export interface InsightStatusData {
|
||||
text: string | null;
|
||||
cached: boolean;
|
||||
updatedAt: string | null;
|
||||
/**
|
||||
* v1.8.3 — the route is read-only: a cache miss enqueues an out-of-band
|
||||
* generation and returns `preparing: true` with `text: null`. The card
|
||||
* shows a preparing state and the hook polls (bounded) until the worker
|
||||
* warms the cache. Absent / false means the payload is terminal.
|
||||
*/
|
||||
preparing?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.8.3 — client-side ceiling on the status GET. The route is now
|
||||
* read-only (sub-second cache read), so a slow response means the network
|
||||
* itself is degraded, not the LLM. Mirror the advisor hook's 8 s
|
||||
* `ADVISOR_TIMEOUT_MS`: abort and surface the empty / preparing state
|
||||
* rather than letting a hung request pin the navigation. No user
|
||||
* navigation may ever await an uncapped round-trip.
|
||||
*/
|
||||
const STATUS_TIMEOUT_MS = 8_000;
|
||||
|
||||
/**
|
||||
* Poll interval while a card is `preparing`. Bounded by `staleTime` so a
|
||||
* settled (terminal) payload never re-fetches on a timer; only the
|
||||
* preparing state keeps polling, and it stops as soon as text lands.
|
||||
*/
|
||||
const STATUS_POLL_MS = 4_000;
|
||||
|
||||
/**
|
||||
* Metric slugs the sub-pages render. Each slug is paired with the
|
||||
* Insights-status endpoint and the matching `queryKeys.*` factory so
|
||||
@@ -71,17 +95,37 @@ export function useInsightStatus(metric: InsightStatusMetric) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEY_FACTORY[metric](locale),
|
||||
queryFn: async (): Promise<InsightStatusData> => {
|
||||
const res = await fetch(`/api/insights/${metric}-status?locale=${locale}`);
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = (await res.json()) as { data: InsightStatusData };
|
||||
return json.data;
|
||||
// v1.8.3 — bound the fetch on the client. AbortController + 8 s
|
||||
// timeout so a degraded network can't pin the navigation thread.
|
||||
// The card surfaces its preparing / empty state on abort exactly as
|
||||
// it does for a `preparing` payload, and the bounded poll retries.
|
||||
const controller = new AbortController();
|
||||
const timeoutHandle = setTimeout(
|
||||
() => controller.abort(),
|
||||
STATUS_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/insights/${metric}-status?locale=${locale}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = (await res.json()) as { data: InsightStatusData };
|
||||
return json.data;
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60 * 1000,
|
||||
// v1.4.28 FB-D2 — the status route now returns a deterministic
|
||||
// cached-style envelope on provider timeout. Retrying that response
|
||||
// re-fires the same expensive upstream call and the user perceives
|
||||
// a longer hang. Drop retries to surface the fallback immediately.
|
||||
// v1.4.28 FB-D2 — the status route returns a deterministic envelope on
|
||||
// a miss/timeout. Retrying inline re-fires work and lengthens the
|
||||
// perceived hang; the bounded `preparing` poll below covers the warm-up.
|
||||
retry: 0,
|
||||
// v1.8.3 — poll only while the worker is preparing the assessment.
|
||||
// Returns false (no timer) for any terminal payload so a settled card
|
||||
// never re-fetches on an interval.
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.preparing ? STATUS_POLL_MS : false,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user