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,9 +23,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
const locale = resolveBloodPressureStatusLocale(resolved);
|
||||
|
||||
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
|
||||
// a miss. The GET never awaits the provider, so opening /insights/<metric>
|
||||
// can no longer pin the main thread behind a cold LLM round-trip.
|
||||
const result = await generateBloodPressureStatusForUser(user.id, {
|
||||
locale,
|
||||
force: false,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.blood-pressure-status" } });
|
||||
|
||||
@@ -23,9 +23,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
const locale = resolveBmiStatusLocale(resolved);
|
||||
|
||||
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
|
||||
// a miss. The GET never awaits the provider, so opening /insights/<metric>
|
||||
// can no longer pin the main thread behind a cold LLM round-trip.
|
||||
const result = await generateBmiStatusForUser(user.id, {
|
||||
locale,
|
||||
force: false,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.bmi-status" } });
|
||||
|
||||
@@ -23,9 +23,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
const locale = resolveMedicationComplianceStatusLocale(resolved);
|
||||
|
||||
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
|
||||
// a miss. The GET never awaits the provider, so opening /insights/<metric>
|
||||
// can no longer pin the main thread behind a cold LLM round-trip.
|
||||
const result = await generateMedicationComplianceStatusForUser(user.id, {
|
||||
locale,
|
||||
force: false,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.medication-compliance-status" } });
|
||||
|
||||
@@ -23,9 +23,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
const locale = resolveMoodStatusLocale(resolved);
|
||||
|
||||
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
|
||||
// a miss. The GET never awaits the provider, so opening /insights/<metric>
|
||||
// can no longer pin the main thread behind a cold LLM round-trip.
|
||||
const result = await generateMoodStatusForUser(user.id, {
|
||||
locale,
|
||||
force: false,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.mood-status" } });
|
||||
|
||||
@@ -23,9 +23,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
const locale = resolvePulseStatusLocale(resolved);
|
||||
|
||||
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
|
||||
// a miss. The GET never awaits the provider, so opening /insights/<metric>
|
||||
// can no longer pin the main thread behind a cold LLM round-trip.
|
||||
const result = await generatePulseStatusForUser(user.id, {
|
||||
locale,
|
||||
force: false,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.pulse-status" } });
|
||||
|
||||
@@ -23,9 +23,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
const locale = resolveWeightStatusLocale(resolved);
|
||||
|
||||
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
|
||||
// a miss. The GET never awaits the provider, so opening /insights/<metric>
|
||||
// can no longer pin the main thread behind a cold LLM round-trip.
|
||||
const result = await generateWeightStatusForUser(user.id, {
|
||||
locale,
|
||||
force: false,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.weight-status" } });
|
||||
|
||||
@@ -124,6 +124,7 @@ export default function InsightsBlutdruckPage() {
|
||||
cached={status?.cached ?? false}
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
preparing={status?.preparing ?? false}
|
||||
/>
|
||||
|
||||
<CoachLaunchButton />
|
||||
|
||||
@@ -133,6 +133,7 @@ export default function InsightsBmiPage() {
|
||||
cached={status?.cached ?? false}
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
preparing={status?.preparing ?? false}
|
||||
/>
|
||||
|
||||
<CoachLaunchButton />
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* v1.8.3 — route-level loading skeleton for every `/insights/*` segment.
|
||||
*
|
||||
* Defense-in-depth for the navigation-freeze class: any RSC await on an
|
||||
* insights route (now or in a future refactor) streams this skeleton
|
||||
* instead of blocking the transition. The geometry mirrors `SubPageShell`
|
||||
* (heading + chart card + assessment card) so the post-load swap reflows
|
||||
* by less than a row. Pure server component — no client hooks, no data
|
||||
* fetch; Next.js renders it instantly while the segment streams.
|
||||
*/
|
||||
export default function InsightsLoading() {
|
||||
return (
|
||||
<div data-slot="insights-subpage-loading" className="space-y-6">
|
||||
<header className="space-y-2">
|
||||
<div className="bg-muted h-7 w-48 animate-pulse rounded motion-reduce:animate-none" />
|
||||
<div className="bg-muted/70 h-4 w-72 animate-pulse rounded motion-reduce:animate-none" />
|
||||
</header>
|
||||
|
||||
{/* Chart card */}
|
||||
<div className="bg-card rounded-xl border p-4">
|
||||
<div className="bg-muted mb-4 h-4 w-32 animate-pulse rounded motion-reduce:animate-none" />
|
||||
<div className="bg-muted/60 h-56 w-full animate-pulse rounded-lg motion-reduce:animate-none" />
|
||||
</div>
|
||||
|
||||
{/* Assessment card */}
|
||||
<div className="bg-card space-y-2 rounded-xl border p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted h-5 w-5 animate-pulse rounded motion-reduce:animate-none" />
|
||||
<div className="bg-muted h-4 w-40 animate-pulse rounded motion-reduce:animate-none" />
|
||||
</div>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,8 +58,18 @@ interface MedicationComplianceStatusData {
|
||||
medications: Array<{ medicationId: string; text: string }>;
|
||||
cached: boolean;
|
||||
updatedAt: string | null;
|
||||
// v1.8.3 — read-only route returns preparing:true with summary:null on a
|
||||
// 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.8.3 — client ceiling + preparing-poll cadence, mirroring the shared
|
||||
// `use-insight-status` hook so the medication-compliance card never awaits
|
||||
// an uncapped LLM round-trip on navigation.
|
||||
const MED_STATUS_TIMEOUT_MS = 8_000;
|
||||
const MED_STATUS_POLL_MS = 4_000;
|
||||
|
||||
interface MedicationDailyData {
|
||||
expected: number;
|
||||
taken: number;
|
||||
@@ -91,15 +101,28 @@ export default function InsightsMedikamentePage() {
|
||||
const { data: status, isLoading: isStatusLoading } = useQuery({
|
||||
queryKey: queryKeys.insightsMedicationComplianceStatus(locale),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`/api/insights/medication-compliance-status?locale=${locale}`,
|
||||
const controller = new AbortController();
|
||||
const timeoutHandle = setTimeout(
|
||||
() => controller.abort(),
|
||||
MED_STATUS_TIMEOUT_MS,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as MedicationComplianceStatusData;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/insights/medication-compliance-status?locale=${locale}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as MedicationComplianceStatusData;
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60 * 1000,
|
||||
retry: 0,
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.preparing ? MED_STATUS_POLL_MS : false,
|
||||
});
|
||||
|
||||
const medications = comprehensive?.medications ?? [];
|
||||
@@ -253,6 +276,7 @@ export default function InsightsMedikamentePage() {
|
||||
cached={status?.cached ?? false}
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
preparing={status?.preparing ?? false}
|
||||
/>
|
||||
|
||||
{/* v1.4.25 W4d — GLP-1 therapy timeline. Self-hides for users
|
||||
|
||||
@@ -115,6 +115,7 @@ export default function InsightsStimmungPage() {
|
||||
cached={status?.cached ?? false}
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
preparing={status?.preparing ?? false}
|
||||
/>
|
||||
|
||||
<CoachLaunchButton />
|
||||
|
||||
@@ -136,6 +136,7 @@ export default function InsightsPulsPage() {
|
||||
cached={status?.cached ?? false}
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
preparing={status?.preparing ?? false}
|
||||
/>
|
||||
|
||||
{/* v1.4.25 W16a — VO2 max sits on the cardio sub-page because it
|
||||
|
||||
@@ -97,6 +97,7 @@ export default function InsightsGewichtPage() {
|
||||
cached={status?.cached ?? false}
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
preparing={status?.preparing ?? false}
|
||||
/>
|
||||
|
||||
<CoachLaunchButton />
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* v1.8.3 — root-level loading skeleton (dashboard segment + any top-level
|
||||
* route without its own `loading.tsx`).
|
||||
*
|
||||
* Defense-in-depth for the navigation-freeze class: a segment that streams
|
||||
* paints this tile-grid skeleton instead of a blank frame. Pure server
|
||||
* component — no client hooks, no data fetch. More specific segments (e.g.
|
||||
* `/insights`) provide their own skeleton and take precedence.
|
||||
*/
|
||||
export default function RootLoading() {
|
||||
return (
|
||||
<div data-slot="dashboard-loading" className="space-y-6 p-4">
|
||||
<div className="bg-muted h-7 w-44 animate-pulse rounded motion-reduce:animate-none" />
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="bg-card rounded-xl border p-4">
|
||||
<div className="bg-muted mb-3 h-4 w-24 animate-pulse rounded motion-reduce:animate-none" />
|
||||
<div className="bg-muted/60 h-24 w-full animate-pulse rounded-lg motion-reduce:animate-none" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user