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:
Marc-André Bombeck committed 2026-06-01 08:59:59 +02:00
1 parent 6a603ad587
commit 35800becbb
47 files changed
+1297 -75

No files matched your search

@@ -18,6 +18,13 @@ interface InsightStatusCardProps {
cached: boolean;
updatedAt: string | null;
loading?: boolean;
/**
* v1.8.3 — the read-only status route enqueued an out-of-band generation
* and the assessment isn't warm yet. Render the same skeleton geometry as
* `loading` but with a "preparing" caption so the user understands the
* card is being assembled, not stuck. The client polls until text lands.
*/
preparing?: boolean;
}
// ─── Main Component ───────────────────────────────────────
@@ -30,6 +37,7 @@ export function InsightStatusCard({
cached,
updatedAt,
loading = false,
preparing = false,
}: InsightStatusCardProps) {
const { t } = useTranslations();
const flags = useFeatureFlags();
@@ -38,6 +46,36 @@ export function InsightStatusCard({
// suppressed in full so the layout collapses naturally.
if (!flags.insightStatus) return null;
// v1.8.3 — preparing: the read-only route enqueued a generation and the
// client is polling. Render the loading skeleton geometry with a
// preparing caption so the card reads as "assembling", not stuck. Only
// shown when a provider is configured (the route returns the no-key
// fallback otherwise).
if (preparing && hasProvider && !text) {
return (
<Card
aria-busy="true"
aria-live="polite"
data-testid="insight-status-card-preparing"
>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
{icon}
<CardTitle className="text-base">{title}</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="bg-muted h-3.5 w-full animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted h-3.5 w-11/12 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted h-3.5 w-9/12 animate-pulse rounded motion-reduce:animate-none" />
<p className="text-muted-foreground text-xs">
{t("insights.assessmentPreparing")}
</p>
</CardContent>
</Card>
);
}
if (loading) {
// v1.4.37 — structured skeleton over the centred spinner. When the
// per-metric status route had to fall back to the 20 s provider
@@ -1,5 +1,6 @@
"use client";
import { useCallback } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { InsightResult } from "@/lib/ai/types";
import {
@@ -176,12 +177,20 @@ export function useInsightsAdvisorQuery(
},
});
// v1.8.3 — stabilise the `regenerate` callback so the memoised
// `<InsightsTabStrip>` (which receives it as `onRegenerate`) is not
// re-rendered on every shell render by a fresh arrow reference. A
// status-query flip on a sub-page that re-renders the shell would
// otherwise re-reconcile the whole strip mid-gesture and eat taps.
const { mutate } = mutation;
const regenerate = useCallback(() => mutate(), [mutate]);
return {
payload: query.data ?? null,
isLoading: query.isLoading,
isError: query.isError,
error: (query.error as Error | null) ?? null,
regenerate: () => mutation.mutate(),
regenerate,
isRegenerating: mutation.isPending,
regenerateError: (mutation.error as Error | null) ?? null,
};