mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50: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
@@ -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,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user