diff --git a/messages/de.json b/messages/de.json index dc85aa96b..1e559d5c7 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1382,6 +1382,7 @@ "bmiEmptyDescription": "Für den BMI wird deine Körpergröße benötigt. Hinterlege sie unter Einstellungen → Account.", "bmiEmptyAction": "Account-Einstellungen öffnen", "noAnalysisYet": "Noch keine Analyse vorhanden.", + "assessmentPreparing": "Einschätzung wird vorbereitet…", "noProviderConfigured": "Auswertung nicht verfügbar.", "startAnalysis": "Analyse starten", "refreshAnalysis": "Analyse aktualisieren", diff --git a/messages/en.json b/messages/en.json index e12b64641..5984d6925 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1376,6 +1376,7 @@ }, "insights": { "noAnalysisYet": "No analysis yet.", + "assessmentPreparing": "Preparing your assessment…", "emptyTitle": "Not enough data yet", "emptyDescription": "Insights light up once you've logged a handful of measurements. Start with weight or blood pressure.", "emptyAddMeasurement": "Add a measurement", diff --git a/messages/es.json b/messages/es.json index a4bb65161..2947cee83 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1376,6 +1376,7 @@ }, "insights": { "noAnalysisYet": "Aún no hay análisis.", + "assessmentPreparing": "Preparando tu evaluación…", "emptyTitle": "Aún no hay datos suficientes", "emptyDescription": "Los insights aparecen en cuanto registres algunas mediciones. Empieza por peso o tensión arterial.", "emptyAddMeasurement": "Registrar medición", diff --git a/messages/fr.json b/messages/fr.json index 7c0c29917..eb0f49d1d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1376,6 +1376,7 @@ }, "insights": { "noAnalysisYet": "Pas encore d’analyse.", + "assessmentPreparing": "Préparation de votre évaluation…", "emptyTitle": "Pas encore assez de données", "emptyDescription": "Les insights apparaissent dès que tu auras saisi quelques mesures. Commence par le poids ou la tension.", "emptyAddMeasurement": "Saisir une mesure", diff --git a/messages/it.json b/messages/it.json index bd38a6588..560cf21b2 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1376,6 +1376,7 @@ }, "insights": { "noAnalysisYet": "Ancora nessuna analisi.", + "assessmentPreparing": "Preparazione della valutazione…", "emptyTitle": "Dati ancora insufficienti", "emptyDescription": "Gli insights compaiono appena registri qualche misurazione. Inizia da peso o pressione.", "emptyAddMeasurement": "Registra misurazione", diff --git a/messages/pl.json b/messages/pl.json index 86e3ed7ea..25b3b9132 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1376,6 +1376,7 @@ }, "insights": { "noAnalysisYet": "Brak analizy.", + "assessmentPreparing": "Przygotowywanie oceny…", "emptyTitle": "Jeszcze za mało danych", "emptyDescription": "Insights pojawią się, gdy zarejestrujesz kilka pomiarów. Zacznij od wagi lub ciśnienia.", "emptyAddMeasurement": "Zarejestruj pomiar", diff --git a/src/app/api/insights/blood-pressure-status/route.ts b/src/app/api/insights/blood-pressure-status/route.ts index 4739935bd..cef396472 100644 --- a/src/app/api/insights/blood-pressure-status/route.ts +++ b/src/app/api/insights/blood-pressure-status/route.ts @@ -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/ + // 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" } }); diff --git a/src/app/api/insights/bmi-status/route.ts b/src/app/api/insights/bmi-status/route.ts index 1ea982caf..ac3620072 100644 --- a/src/app/api/insights/bmi-status/route.ts +++ b/src/app/api/insights/bmi-status/route.ts @@ -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/ + // 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" } }); diff --git a/src/app/api/insights/medication-compliance-status/route.ts b/src/app/api/insights/medication-compliance-status/route.ts index 541d5c6d9..32f54214c 100644 --- a/src/app/api/insights/medication-compliance-status/route.ts +++ b/src/app/api/insights/medication-compliance-status/route.ts @@ -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/ + // 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" } }); diff --git a/src/app/api/insights/mood-status/route.ts b/src/app/api/insights/mood-status/route.ts index 45edeac6c..c47ff442a 100644 --- a/src/app/api/insights/mood-status/route.ts +++ b/src/app/api/insights/mood-status/route.ts @@ -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/ + // 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" } }); diff --git a/src/app/api/insights/pulse-status/route.ts b/src/app/api/insights/pulse-status/route.ts index 57de1cbdc..72f67afec 100644 --- a/src/app/api/insights/pulse-status/route.ts +++ b/src/app/api/insights/pulse-status/route.ts @@ -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/ + // 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" } }); diff --git a/src/app/api/insights/weight-status/route.ts b/src/app/api/insights/weight-status/route.ts index 43d070aa8..511ade38c 100644 --- a/src/app/api/insights/weight-status/route.ts +++ b/src/app/api/insights/weight-status/route.ts @@ -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/ + // 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" } }); diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index 20c16055a..391a9f83d 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -124,6 +124,7 @@ export default function InsightsBlutdruckPage() { cached={status?.cached ?? false} updatedAt={status?.updatedAt ?? null} loading={isStatusLoading} + preparing={status?.preparing ?? false} /> diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx index 626c3d0b9..46f5abdf4 100644 --- a/src/app/insights/bmi/page.tsx +++ b/src/app/insights/bmi/page.tsx @@ -133,6 +133,7 @@ export default function InsightsBmiPage() { cached={status?.cached ?? false} updatedAt={status?.updatedAt ?? null} loading={isStatusLoading} + preparing={status?.preparing ?? false} /> diff --git a/src/app/insights/loading.tsx b/src/app/insights/loading.tsx new file mode 100644 index 000000000..5e2eabad1 --- /dev/null +++ b/src/app/insights/loading.tsx @@ -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 ( +
+
+
+
+
+ + {/* Chart card */} +
+
+
+
+ + {/* Assessment card */} +
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index db18cd044..c1712c7f5 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -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 diff --git a/src/app/insights/mood/page.tsx b/src/app/insights/mood/page.tsx index 5ef54f9ef..cdb451715 100644 --- a/src/app/insights/mood/page.tsx +++ b/src/app/insights/mood/page.tsx @@ -115,6 +115,7 @@ export default function InsightsStimmungPage() { cached={status?.cached ?? false} updatedAt={status?.updatedAt ?? null} loading={isStatusLoading} + preparing={status?.preparing ?? false} /> diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index 797654b7f..073bf3dca 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -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 diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index 59a8f37b4..c150d178f 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -97,6 +97,7 @@ export default function InsightsGewichtPage() { cached={status?.cached ?? false} updatedAt={status?.updatedAt ?? null} loading={isStatusLoading} + preparing={status?.preparing ?? false} /> diff --git a/src/app/loading.tsx b/src/app/loading.tsx new file mode 100644 index 000000000..108fc130b --- /dev/null +++ b/src/app/loading.tsx @@ -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 ( +
+
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+ ))} +
+
+ ); +} diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx index 14f45563d..c3e2b30d7 100644 --- a/src/components/insights/insight-status-card.tsx +++ b/src/components/insights/insight-status-card.tsx @@ -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 ( + + +
+ {icon} + {title} +
+
+ +
+
+
+

+ {t("insights.assessmentPreparing")} +

+ + + ); + } + 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 diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts index b8437b0ba..775077698 100644 --- a/src/components/insights/use-insights-advisor.ts +++ b/src/components/insights/use-insights-advisor.ts @@ -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 + // `` (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, }; diff --git a/src/hooks/use-insight-status.ts b/src/hooks/use-insight-status.ts index df2aa21fd..6583fa2c1 100644 --- a/src/hooks/use-insight-status.ts +++ b/src/hooks/use-insight-status.ts @@ -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 => { - 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, }); } diff --git a/src/lib/insights/__tests__/blood-pressure-status.test.ts b/src/lib/insights/__tests__/blood-pressure-status.test.ts index ee6263f12..48c831044 100644 --- a/src/lib/insights/__tests__/blood-pressure-status.test.ts +++ b/src/lib/insights/__tests__/blood-pressure-status.test.ts @@ -152,7 +152,19 @@ describe("generateBloodPressureStatusForUser — timeout/error never persists", expect(result.text).toBeTruthy(); expect(result.cached).toBe(true); expect(result.updatedAt).toBeNull(); - expect(prisma.auditLog.create).not.toHaveBeenCalled(); + // v1.8.3 — no real assessment persisted (updatedAt stays null above), + // but a short-TTL negative stub IS written so the read-only route does + // not re-enqueue on every navigation while the provider is degraded. + // The stub is a timeout marker that `readFreshStatusText` rejects. + await Promise.resolve(); + for (const call of vi.mocked(prisma.auditLog.create).mock.calls) { + const details = JSON.parse( + (call[0] as { data: { details: string } }).data.details, + ); + expect(details.timeout === true || details.model === "timeout-stub").toBe( + true, + ); + } }); }); diff --git a/src/lib/insights/__tests__/bmi-status.test.ts b/src/lib/insights/__tests__/bmi-status.test.ts index 9b23a6811..902d18b4c 100644 --- a/src/lib/insights/__tests__/bmi-status.test.ts +++ b/src/lib/insights/__tests__/bmi-status.test.ts @@ -121,7 +121,19 @@ describe("generateBmiStatusForUser — timeout/error never persists", () => { expect(result.text).toBeTruthy(); expect(result.cached).toBe(true); expect(result.updatedAt).toBeNull(); - expect(prisma.auditLog.create).not.toHaveBeenCalled(); + // v1.8.3 — no real assessment persisted (updatedAt stays null above), + // but a short-TTL negative stub IS written so the read-only route does + // not re-enqueue on every navigation while the provider is degraded. + // The stub is a timeout marker that `readFreshStatusText` rejects. + await Promise.resolve(); + for (const call of vi.mocked(prisma.auditLog.create).mock.calls) { + const details = JSON.parse( + (call[0] as { data: { details: string } }).data.details, + ); + expect(details.timeout === true || details.model === "timeout-stub").toBe( + true, + ); + } }); it("serves the fallback without writing a cache row on provider error", async () => { @@ -140,7 +152,19 @@ describe("generateBmiStatusForUser — timeout/error never persists", () => { const result = await generateBmiStatusForUser("user-1", { locale: "en" }); expect(result.text).toBeTruthy(); expect(result.updatedAt).toBeNull(); - expect(prisma.auditLog.create).not.toHaveBeenCalled(); + // v1.8.3 — no real assessment persisted (updatedAt stays null above), + // but a short-TTL negative stub IS written so the read-only route does + // not re-enqueue on every navigation while the provider is degraded. + // The stub is a timeout marker that `readFreshStatusText` rejects. + await Promise.resolve(); + for (const call of vi.mocked(prisma.auditLog.create).mock.calls) { + const details = JSON.parse( + (call[0] as { data: { details: string } }).data.details, + ); + expect(details.timeout === true || details.model === "timeout-stub").toBe( + true, + ); + } }); }); diff --git a/src/lib/insights/__tests__/general-status.test.ts b/src/lib/insights/__tests__/general-status.test.ts index afed1f0da..26500875c 100644 --- a/src/lib/insights/__tests__/general-status.test.ts +++ b/src/lib/insights/__tests__/general-status.test.ts @@ -162,7 +162,19 @@ describe("generateGeneralStatusForUser — timeout/error never persists", () => expect(result.text).toBeTruthy(); expect(result.cached).toBe(true); expect(result.updatedAt).toBeNull(); - expect(prisma.auditLog.create).not.toHaveBeenCalled(); + // v1.8.3 — no real assessment persisted (updatedAt stays null above), + // but a short-TTL negative stub IS written so the read-only route does + // not re-enqueue on every navigation while the provider is degraded. + // The stub is a timeout marker that `readFreshStatusText` rejects. + await Promise.resolve(); + for (const call of vi.mocked(prisma.auditLog.create).mock.calls) { + const details = JSON.parse( + (call[0] as { data: { details: string } }).data.details, + ); + expect(details.timeout === true || details.model === "timeout-stub").toBe( + true, + ); + } }); }); diff --git a/src/lib/insights/__tests__/medication-compliance-status.test.ts b/src/lib/insights/__tests__/medication-compliance-status.test.ts index fd3d19e16..9e73cc253 100644 --- a/src/lib/insights/__tests__/medication-compliance-status.test.ts +++ b/src/lib/insights/__tests__/medication-compliance-status.test.ts @@ -155,7 +155,19 @@ describe("generateMedicationComplianceStatusForUser — timeout never persists", expect(result.medications).toEqual([]); expect(result.cached).toBe(true); expect(result.updatedAt).toBeNull(); - expect(prisma.auditLog.create).not.toHaveBeenCalled(); + // v1.8.3 — no real assessment persisted (updatedAt stays null above), + // but a short-TTL negative stub IS written so the read-only route does + // not re-enqueue on every navigation while the provider is degraded. + // The stub is a timeout marker that `readFreshStatusText` rejects. + await Promise.resolve(); + for (const call of vi.mocked(prisma.auditLog.create).mock.calls) { + const details = JSON.parse( + (call[0] as { data: { details: string } }).data.details, + ); + expect(details.timeout === true || details.model === "timeout-stub").toBe( + true, + ); + } }); }); diff --git a/src/lib/insights/__tests__/mood-status.test.ts b/src/lib/insights/__tests__/mood-status.test.ts index cf4dc245b..5ed1cd7aa 100644 --- a/src/lib/insights/__tests__/mood-status.test.ts +++ b/src/lib/insights/__tests__/mood-status.test.ts @@ -118,7 +118,19 @@ describe("generateMoodStatusForUser — timeout/error never persists", () => { expect(result.text).toBeTruthy(); expect(result.cached).toBe(true); expect(result.updatedAt).toBeNull(); - expect(prisma.auditLog.create).not.toHaveBeenCalled(); + // v1.8.3 — no real assessment persisted (updatedAt stays null above), + // but a short-TTL negative stub IS written so the read-only route does + // not re-enqueue on every navigation while the provider is degraded. + // The stub is a timeout marker that `readFreshStatusText` rejects. + await Promise.resolve(); + for (const call of vi.mocked(prisma.auditLog.create).mock.calls) { + const details = JSON.parse( + (call[0] as { data: { details: string } }).data.details, + ); + expect(details.timeout === true || details.model === "timeout-stub").toBe( + true, + ); + } }); }); diff --git a/src/lib/insights/__tests__/pulse-status-timeout.test.ts b/src/lib/insights/__tests__/pulse-status-timeout.test.ts index 4fad2c3c9..b7336afda 100644 --- a/src/lib/insights/__tests__/pulse-status-timeout.test.ts +++ b/src/lib/insights/__tests__/pulse-status-timeout.test.ts @@ -4,12 +4,15 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; * Provider-timeout fallback for the pulse-status route. When the * provider chain does not resolve inside `STATUS_PROVIDER_TIMEOUT_MS` * the route returns the deterministic no-key fallback so the - * InsightStatusCard renders instead of spinning — but it must NOT - * persist the fallback. The earlier behaviour cached a `timeout-stub` - * row keyed to today; that stub stuck until midnight and hid the real - * data-driven assessment. The fix treats a timeout as a transient miss: - * serve the fallback for this render, write nothing, re-attempt next - * mount. + * InsightStatusCard renders instead of spinning. It must NOT persist the + * fallback AS AN ASSESSMENT (the pre-v1.4.28 stick-until-midnight bug); + * `updatedAt` stays null and the served text is never a real assessment. + * + * v1.8.3 — the timeout path now writes a *short-TTL negative stub* + * (`{ timeout:true, model:"timeout-stub", retryAt }`). It is explicitly + * rejected by `readFreshStatusText`, so it can never hide the real + * assessment; its sole purpose is to stop the read-only route re-enqueuing + * generation on every navigation while a provider is degraded. */ vi.mock("@/lib/db", () => ({ @@ -64,8 +67,23 @@ describe("generatePulseStatusForUser — provider timeout fallback", () => { expect(result.cached).toBe(true); expect(typeof result.text).toBe("string"); expect(result.text?.length ?? 0).toBeGreaterThan(0); - // No persisted row — the sticky-stub bug is gone. + // No real assessment persisted — `updatedAt` stays null so the card + // never mislabels the fallback as a fresh assessment. expect(result.updatedAt).toBeNull(); - expect(prisma.auditLog.create).not.toHaveBeenCalled(); + + // v1.8.3 — a short-TTL negative stub IS persisted (fire-and-forget) so + // the read-only route doesn't re-enqueue on every navigation while the + // provider is degraded. It is marked as a timeout stub, which + // `readFreshStatusText` rejects, so it never hides the real assessment. + // The write is fire-and-forget (`void`), so flush the microtask queue. + await Promise.resolve(); + expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); + const persisted = vi.mocked(prisma.auditLog.create).mock.calls[0][0] as { + data: { details: string }; + }; + const stub = JSON.parse(persisted.data.details); + expect(stub.timeout).toBe(true); + expect(stub.model).toBe("timeout-stub"); + expect(typeof stub.retryAt).toBe("string"); }); }); diff --git a/src/lib/insights/__tests__/status-cache.test.ts b/src/lib/insights/__tests__/status-cache.test.ts index 33f783997..fbd4e98c7 100644 --- a/src/lib/insights/__tests__/status-cache.test.ts +++ b/src/lib/insights/__tests__/status-cache.test.ts @@ -6,8 +6,22 @@ vi.mock("@/lib/db", () => ({ }, })); +const hasUsableStatusProvider = vi.fn(); +vi.mock("@/lib/insights/status-provider", () => ({ + hasUsableStatusProvider: (...a: unknown[]) => hasUsableStatusProvider(...a), +})); + +const enqueueStatusGeneration = vi.fn(); +vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({ + enqueueStatusGeneration: (...a: unknown[]) => enqueueStatusGeneration(...a), +})); + import { prisma } from "@/lib/db"; -import { isTimeoutStub, readFreshStatusText } from "../status-cache"; +import { + isTimeoutStub, + readFreshStatusText, + resolveReadOnlyStatusMiss, +} from "../status-cache"; const TODAY = "2026-05-31"; @@ -121,3 +135,71 @@ describe("readFreshStatusText", () => { expect(hit).toBeNull(); }); }); + +describe("resolveReadOnlyStatusMiss", () => { + it("returns no-provider without enqueuing when the user has no provider", async () => { + hasUsableStatusProvider.mockResolvedValue(false); + const outcome = await resolveReadOnlyStatusMiss({ + userId: "u1", + metric: "weight", + locale: "en", + }); + expect(outcome).toBe("no-provider"); + expect(enqueueStatusGeneration).not.toHaveBeenCalled(); + }); + + it("enqueues generation and returns preparing on a clean miss", async () => { + hasUsableStatusProvider.mockResolvedValue(true); + // No negative stub present. + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never); + const outcome = await resolveReadOnlyStatusMiss({ + userId: "u1", + metric: "pulse", + locale: "de", + }); + expect(outcome).toBe("preparing"); + expect(enqueueStatusGeneration).toHaveBeenCalledWith({ + userId: "u1", + metric: "pulse", + locale: "de", + }); + }); + + it("suppresses re-enqueue while a fresh timeout stub exists", async () => { + hasUsableStatusProvider.mockResolvedValue(true); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( + cacheRow({ + dateKey: TODAY, + timeout: true, + model: "timeout-stub", + retryAt: new Date(Date.now() + 60_000).toISOString(), + }) as never, + ); + const outcome = await resolveReadOnlyStatusMiss({ + userId: "u1", + metric: "mood", + locale: "en", + }); + expect(outcome).toBe("preparing"); + expect(enqueueStatusGeneration).not.toHaveBeenCalled(); + }); + + it("re-enqueues once the negative stub's retryAt has passed", async () => { + hasUsableStatusProvider.mockResolvedValue(true); + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( + cacheRow({ + dateKey: TODAY, + timeout: true, + model: "timeout-stub", + retryAt: new Date(Date.now() - 60_000).toISOString(), + }) as never, + ); + const outcome = await resolveReadOnlyStatusMiss({ + userId: "u1", + metric: "bmi", + locale: "de", + }); + expect(outcome).toBe("preparing"); + expect(enqueueStatusGeneration).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/insights/__tests__/weight-status.test.ts b/src/lib/insights/__tests__/weight-status.test.ts index cff1f31b4..878364eb0 100644 --- a/src/lib/insights/__tests__/weight-status.test.ts +++ b/src/lib/insights/__tests__/weight-status.test.ts @@ -126,7 +126,19 @@ describe("generateWeightStatusForUser — timeout/error never persists", () => { expect(result.text).toBeTruthy(); expect(result.cached).toBe(true); expect(result.updatedAt).toBeNull(); - expect(prisma.auditLog.create).not.toHaveBeenCalled(); + // v1.8.3 — no real assessment persisted (updatedAt stays null above), + // but a short-TTL negative stub IS written so the read-only route does + // not re-enqueue on every navigation while the provider is degraded. + // The stub is a timeout marker that `readFreshStatusText` rejects. + await Promise.resolve(); + for (const call of vi.mocked(prisma.auditLog.create).mock.calls) { + const details = JSON.parse( + (call[0] as { data: { details: string } }).data.details, + ); + expect(details.timeout === true || details.model === "timeout-stub").toBe( + true, + ); + } }); }); diff --git a/src/lib/insights/blood-pressure-status.ts b/src/lib/insights/blood-pressure-status.ts index a2257b251..2b10e3f7b 100644 --- a/src/lib/insights/blood-pressure-status.ts +++ b/src/lib/insights/blood-pressure-status.ts @@ -42,7 +42,10 @@ import { summarizeSeries, } from "@/lib/insights/status-shared"; import { runStatusCompletion } from "@/lib/insights/status-provider"; -import { readFreshStatusText } from "@/lib/insights/status-cache"; +import { + readFreshStatusText, + resolveReadOnlyStatusMiss, +} from "@/lib/insights/status-cache"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -94,15 +97,19 @@ export async function generateBloodPressureStatusForUser( options?: { locale?: string | null; force?: boolean; + /** v1.8.3 — read-only navigation path; see weight-status for the rationale. */ + readOnly?: boolean; }, ): Promise<{ hasProvider: boolean; text: string | null; cached: boolean; updatedAt: string | null; + preparing?: boolean; }> { const locale = normalizeLocale(options?.locale); const force = options?.force === true; + const readOnly = options?.readOnly === true; const cacheAction = `insights.blood-pressure-status.${locale}`; const todayKey = toBerlinDayKey(new Date()); @@ -121,6 +128,29 @@ export async function generateBloodPressureStatusForUser( }; } + if (readOnly) { + const outcome = await resolveReadOnlyStatusMiss({ + userId, + metric: "blood-pressure", + locale, + }); + if (outcome === "no-provider") { + return { + hasProvider: false, + text: getNoKeyBloodPressureStatusText(locale), + cached: true, + updatedAt: null, + }; + } + return { + hasProvider: true, + text: null, + cached: false, + updatedAt: null, + preparing: true, + }; + } + const user = await prisma.user.findUnique({ where: { id: userId }, select: { @@ -523,6 +553,8 @@ export async function generateBloodPressureStatusForUser( return returnTimeoutFallback({ cacheAction, reason: outcome.kind, + userId, + todayKey, stubText: getNoKeyBloodPressureStatusText(locale), }); } diff --git a/src/lib/insights/bmi-status.ts b/src/lib/insights/bmi-status.ts index 03be6fcf2..a455fc2eb 100644 --- a/src/lib/insights/bmi-status.ts +++ b/src/lib/insights/bmi-status.ts @@ -22,7 +22,10 @@ import { summarizeSeries, } from "@/lib/insights/status-shared"; import { runStatusCompletion } from "@/lib/insights/status-provider"; -import { readFreshStatusText } from "@/lib/insights/status-cache"; +import { + readFreshStatusText, + resolveReadOnlyStatusMiss, +} from "@/lib/insights/status-cache"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -32,15 +35,19 @@ export async function generateBmiStatusForUser( options?: { locale?: string | null; force?: boolean; + /** v1.8.3 — read-only navigation path; see weight-status for the rationale. */ + readOnly?: boolean; }, ): Promise<{ hasProvider: boolean; text: string | null; cached: boolean; updatedAt: string | null; + preparing?: boolean; }> { const locale = normalizeLocale(options?.locale); const force = options?.force === true; + const readOnly = options?.readOnly === true; const cacheAction = `insights.bmi-status.${locale}`; const todayKey = toBerlinDayKey(new Date()); @@ -59,6 +66,29 @@ export async function generateBmiStatusForUser( }; } + if (readOnly) { + const outcome = await resolveReadOnlyStatusMiss({ + userId, + metric: "bmi", + locale, + }); + if (outcome === "no-provider") { + return { + hasProvider: false, + text: getNoKeyBmiStatusText(locale), + cached: true, + updatedAt: null, + }; + } + return { + hasProvider: true, + text: null, + cached: false, + updatedAt: null, + preparing: true, + }; + } + const user = await prisma.user.findUnique({ where: { id: userId }, select: { @@ -227,6 +257,8 @@ export async function generateBmiStatusForUser( return returnTimeoutFallback({ cacheAction, reason: outcome.kind, + userId, + todayKey, stubText: getNoKeyBmiStatusText(locale), }); } diff --git a/src/lib/insights/general-status.ts b/src/lib/insights/general-status.ts index 2fd89efb3..1693646f1 100644 --- a/src/lib/insights/general-status.ts +++ b/src/lib/insights/general-status.ts @@ -25,7 +25,10 @@ import { summarizeSeries, } from "@/lib/insights/status-shared"; import { runStatusCompletion } from "@/lib/insights/status-provider"; -import { readFreshStatusText } from "@/lib/insights/status-cache"; +import { + readFreshStatusText, + resolveReadOnlyStatusMiss, +} from "@/lib/insights/status-cache"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -47,15 +50,19 @@ export async function generateGeneralStatusForUser( options?: { locale?: string | null; force?: boolean; + /** v1.8.3 — read-only navigation path; see weight-status for the rationale. */ + readOnly?: boolean; }, ): Promise<{ hasProvider: boolean; text: string | null; cached: boolean; updatedAt: string | null; + preparing?: boolean; }> { const locale = normalizeLocale(options?.locale); const force = options?.force === true; + const readOnly = options?.readOnly === true; const cacheAction = `insights.general-status.${locale}`; const todayKey = toBerlinDayKey(new Date()); @@ -74,6 +81,29 @@ export async function generateGeneralStatusForUser( }; } + if (readOnly) { + const outcome = await resolveReadOnlyStatusMiss({ + userId, + metric: "general", + locale, + }); + if (outcome === "no-provider") { + return { + hasProvider: false, + text: getNoKeyGeneralStatusText(locale), + cached: true, + updatedAt: null, + }; + } + return { + hasProvider: true, + text: null, + cached: false, + updatedAt: null, + preparing: true, + }; + } + const user = await prisma.user.findUnique({ where: { id: userId }, select: { @@ -348,6 +378,8 @@ export async function generateGeneralStatusForUser( return returnTimeoutFallback({ cacheAction, reason: outcome.kind, + userId, + todayKey, stubText: getNoKeyGeneralStatusText(locale), }); } diff --git a/src/lib/insights/medication-compliance-status.ts b/src/lib/insights/medication-compliance-status.ts index 04bf1e49b..4c69084ff 100644 --- a/src/lib/insights/medication-compliance-status.ts +++ b/src/lib/insights/medication-compliance-status.ts @@ -27,7 +27,10 @@ import { round, } from "@/lib/insights/status-shared"; import { runStatusCompletion } from "@/lib/insights/status-provider"; -import { isTimeoutStub } from "@/lib/insights/status-cache"; +import { + isTimeoutStub, + resolveReadOnlyStatusMiss, +} from "@/lib/insights/status-cache"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -46,6 +49,8 @@ export async function generateMedicationComplianceStatusForUser( options?: { locale?: string | null; force?: boolean; + /** v1.8.3 — read-only navigation path; see weight-status for the rationale. */ + readOnly?: boolean; }, ): Promise<{ hasProvider: boolean; @@ -53,9 +58,11 @@ export async function generateMedicationComplianceStatusForUser( medications: MedicationSummaryItem[]; cached: boolean; updatedAt: string | null; + preparing?: boolean; }> { const locale = normalizeLocale(options?.locale); const force = options?.force === true; + const readOnly = options?.readOnly === true; const cacheAction = `insights.medication-compliance-status.${locale}`; const todayKey = toBerlinDayKey(new Date()); @@ -106,6 +113,33 @@ export async function generateMedicationComplianceStatusForUser( } } + // v1.8.3 — read-only navigation path: never block on the provider. + // Enqueue generation out of band and return preparing / no-provider. + if (readOnly) { + const outcome = await resolveReadOnlyStatusMiss({ + userId, + metric: "medication-compliance", + locale, + }); + if (outcome === "no-provider") { + return { + hasProvider: false, + summary: getNoKeyMedicationComplianceStatusText(locale), + medications: [], + cached: true, + updatedAt: null, + }; + } + return { + hasProvider: true, + summary: null, + medications: [], + cached: false, + updatedAt: null, + preparing: true, + }; + } + const medications = await prisma.medication.findMany({ where: { userId, active: true }, include: { schedules: true }, @@ -338,6 +372,8 @@ export async function generateMedicationComplianceStatusForUser( returnTimeoutFallback({ cacheAction, reason: outcome.kind, + userId, + todayKey, stubText: getNoKeyMedicationComplianceStatusText(locale), }); return { diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index 4eb950e51..5c762c4aa 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -28,7 +28,10 @@ import { summarizeSeries, } from "@/lib/insights/status-shared"; import { runStatusCompletion } from "@/lib/insights/status-provider"; -import { readFreshStatusText } from "@/lib/insights/status-cache"; +import { + readFreshStatusText, + resolveReadOnlyStatusMiss, +} from "@/lib/insights/status-cache"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -70,15 +73,19 @@ export async function generateMoodStatusForUser( options?: { locale?: string | null; force?: boolean; + /** v1.8.3 — read-only navigation path; see weight-status for the rationale. */ + readOnly?: boolean; }, ): Promise<{ hasProvider: boolean; text: string | null; cached: boolean; updatedAt: string | null; + preparing?: boolean; }> { const locale = normalizeLocale(options?.locale); const force = options?.force === true; + const readOnly = options?.readOnly === true; const cacheAction = `insights.mood-status.${locale}`; const todayKey = toBerlinDayKey(new Date()); @@ -97,6 +104,29 @@ export async function generateMoodStatusForUser( }; } + if (readOnly) { + const outcome = await resolveReadOnlyStatusMiss({ + userId, + metric: "mood", + locale, + }); + if (outcome === "no-provider") { + return { + hasProvider: false, + text: getNoKeyMoodStatusText(locale), + cached: true, + updatedAt: null, + }; + } + return { + hasProvider: true, + text: null, + cached: false, + updatedAt: null, + preparing: true, + }; + } + // v1.4.28 FB-D2 — cap at 90 entries (~3 months) for the mood // surface. The downstream `applyPayloadBudget` slims further. // Order desc + reverse so the per-row consumers downstream (which @@ -382,6 +412,8 @@ export async function generateMoodStatusForUser( return returnTimeoutFallback({ cacheAction, reason: outcome.kind, + userId, + todayKey, stubText: getNoKeyMoodStatusText(locale), }); } diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index 91429acbe..ee52eab81 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -28,7 +28,10 @@ import { summarizeSeries, } from "@/lib/insights/status-shared"; import { runStatusCompletion } from "@/lib/insights/status-provider"; -import { readFreshStatusText } from "@/lib/insights/status-cache"; +import { + readFreshStatusText, + resolveReadOnlyStatusMiss, +} from "@/lib/insights/status-cache"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -38,15 +41,19 @@ export async function generatePulseStatusForUser( options?: { locale?: string | null; force?: boolean; + /** v1.8.3 — read-only navigation path; see weight-status for the rationale. */ + readOnly?: boolean; }, ): Promise<{ hasProvider: boolean; text: string | null; cached: boolean; updatedAt: string | null; + preparing?: boolean; }> { const locale = normalizeLocale(options?.locale); const force = options?.force === true; + const readOnly = options?.readOnly === true; const cacheAction = `insights.pulse-status.${locale}`; const todayKey = toBerlinDayKey(new Date()); @@ -65,6 +72,29 @@ export async function generatePulseStatusForUser( }; } + if (readOnly) { + const outcome = await resolveReadOnlyStatusMiss({ + userId, + metric: "pulse", + locale, + }); + if (outcome === "no-provider") { + return { + hasProvider: false, + text: getNoKeyPulseStatusText(locale), + cached: true, + updatedAt: null, + }; + } + return { + hasProvider: true, + text: null, + cached: false, + updatedAt: null, + preparing: true, + }; + } + const user = await prisma.user.findUnique({ where: { id: userId }, select: { @@ -270,6 +300,8 @@ export async function generatePulseStatusForUser( return returnTimeoutFallback({ cacheAction, reason: outcome.kind, + userId, + todayKey, stubText: getNoKeyPulseStatusText(locale), }); } diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts index f7e9edae2..ca6b187a5 100644 --- a/src/lib/insights/status-cache.ts +++ b/src/lib/insights/status-cache.ts @@ -1,4 +1,10 @@ import { prisma } from "@/lib/db"; +import { hasUsableStatusProvider } from "@/lib/insights/status-provider"; +import { + enqueueStatusGeneration, + type InsightStatusMetric, +} from "@/lib/jobs/insight-status-generate-shared"; +import { annotate } from "@/lib/logging/context"; /** * Shared cache-read for the seven `*-status.ts` insight generators. @@ -31,6 +37,8 @@ interface ParsedStatusCache { model?: string; tokensUsed?: number | null; timeout?: boolean; + /** v1.8.3 — ISO timestamp before which a timeout stub suppresses re-enqueue. */ + retryAt?: string; } /** @@ -92,3 +100,91 @@ export async function readFreshStatusText(args: { return null; } } + +/** + * Outcome of the read-only cache-miss resolution. The generators map this + * onto their public return shape: + * - `no-provider` → `{ hasProvider: false, text: }` + * - `preparing` → `{ hasProvider: true, text: null, preparing: true }` + */ +export type ReadOnlyMissOutcome = "no-provider" | "preparing"; + +/** + * v1.8.3 — resolve what a read-only status generation should return on a + * cache miss WITHOUT running the heavy SQL gather or the blocking LLM + * round-trip. This is the core of the holistic freeze fix: a navigation + * request must never await an uncapped provider call. + * + * On a miss the route either: + * - finds no usable provider → returns `no-provider` (the card shows the + * no-key fallback; nothing to generate), or + * - finds a provider → fire-and-forget enqueues a generation job and + * returns `preparing` (the card shows a preparing state and the client + * polls until the worker warms the cache). + * + * The provider probe is a cheap chain-resolve (no completion), so the GET + * stays sub-second even on a cold cache. + */ +export async function resolveReadOnlyStatusMiss(args: { + userId: string; + metric: InsightStatusMetric; + locale: "de" | "en"; +}): Promise { + const hasProvider = await hasUsableStatusProvider(args.userId); + if (!hasProvider) return "no-provider"; + + const cacheAction = `insights.${args.metric}-status.${args.locale}`; + + // v1.8.3 — honour the short-TTL negative cache. If the worker recently + // hit a provider stall it wrote a `retryAt` stub; re-enqueuing on every + // navigation while the provider is still degraded would be a storm. While + // the stub is fresh, stay in `preparing` without enqueuing; once it goes + // stale (or never existed) enqueue a fresh generation. + if (await hasFreshTimeoutStub({ userId: args.userId, cacheAction })) { + annotate({ + action: { name: "insights.status.preparing" }, + meta: { metric: args.metric, suppressed_enqueue: true }, + }); + return "preparing"; + } + + // Enqueue out of band — do NOT await an LLM here. The enqueue itself is + // best-effort and de-duped per (user, metric, locale). + await enqueueStatusGeneration({ + userId: args.userId, + metric: args.metric, + locale: args.locale, + }); + annotate({ + action: { name: "insights.status.preparing" }, + meta: { metric: args.metric }, + }); + return "preparing"; +} + +/** + * True when the most recent cache row for `(userId, cacheAction)` is a + * timeout stub whose `retryAt` is still in the future. Used by the + * read-only resolver to suppress a re-enqueue storm while a provider is + * degraded. A stub without `retryAt` (legacy) is treated as stale so the + * resolver retries, matching the pre-v1.8.3 "transient miss" behaviour. + */ +async function hasFreshTimeoutStub(args: { + userId: string; + cacheAction: string; +}): Promise { + const latest = await prisma.auditLog.findFirst({ + where: { userId: args.userId, action: args.cacheAction }, + orderBy: { createdAt: "desc" }, + select: { details: true }, + }); + if (!latest?.details) return false; + try { + const parsed = JSON.parse(latest.details) as ParsedStatusCache; + if (!isTimeoutStub(parsed)) return false; + if (typeof parsed.retryAt !== "string") return false; + return new Date(parsed.retryAt).getTime() > Date.now(); + } catch { + return false; + } +} diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts index 85b84b847..b69706cb6 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -64,6 +64,22 @@ async function resolveStatusChain( return [{ providerType: "admin-openai", instance: legacy }]; } +/** + * Cheap provider-availability probe for the read-only status path. + * + * v1.8.3 — when a status route runs in read-only mode (serve cache, never + * block on the LLM) it still has to tell the difference between "no + * provider — show the no-key fallback" and "provider configured but the + * assessment isn't warm yet — show preparing + enqueue a generation". This + * resolves the same chain `runStatusCompletion` would, but does NOT run a + * completion, so the navigation request never awaits an LLM round-trip. + */ +export async function hasUsableStatusProvider( + userId: string, +): Promise { + return (await resolveStatusChain(userId)) !== null; +} + /** * Run a status generation across the user's provider chain, bounded by * `STATUS_PROVIDER_TIMEOUT_MS`. The result discriminates between diff --git a/src/lib/insights/timeout-fallback.ts b/src/lib/insights/timeout-fallback.ts index 9b94b273e..fddb4c547 100644 --- a/src/lib/insights/timeout-fallback.ts +++ b/src/lib/insights/timeout-fallback.ts @@ -1,32 +1,79 @@ +import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; /** * Shared timeout / provider-error fallback for the InsightStatus routes. * * When the upstream provider call exceeds `STATUS_PROVIDER_TIMEOUT_MS` - * (or rejects outright) the route still has to return a deterministic - * envelope so the InsightStatusCard renders the no-key fallback text - * instead of spinning. It used to ALSO persist a sentinel `auditLog` - * row keyed to today's Berlin day. That stub carried the generic - * fallback under the same `text` field a real assessment uses, the - * cache-read served it as valid, and it stuck until midnight — hiding - * the real, data-driven assessment for the rest of the day on any - * account that hit a single transient stall. + * (or rejects outright) the generator still has to return a deterministic + * envelope. It used to persist a sentinel row carrying the generic + * fallback under the same `text` field a real assessment uses; the + * cache-read served it as valid and it stuck until midnight — hiding the + * real, data-driven assessment for the rest of the day on any account that + * hit a single transient stall. v1.4.28 removed that persist entirely. * - * The fix removes the persist entirely: a timeout / error is treated as - * a transient miss. The current render still gets the fallback text, but - * nothing is written, so the next mount re-attempts a real generation. - * The shared cache-read (`status-cache.ts`) additionally rejects any - * legacy stub rows still in the table by their `model:"timeout-stub"` / - * `timeout:true` markers. - * - * `cached:true` is kept so the UI does not mislabel the fallback as a - * fresh assessment, and `updatedAt:null` signals "no persisted row". + * v1.8.3 — re-introduce a *negative* cache, but a strictly bounded one. The + * status route is now read-only: a cache miss enqueues an out-of-band + * generation. If the provider stalls inside the worker, nothing stops the + * next navigation from enqueuing again, and again — a stalled provider + * turns into a re-enqueue storm. So the timeout path writes a short-TTL + * negative stub (`{ timeout:true, model:"timeout-stub", retryAt }`). The + * read-only resolver honours it: while the stub is fresh it returns + * `preparing` WITHOUT re-enqueuing; once `retryAt` passes the stub is stale + * and the next visit re-attempts. The stub is NEVER served as assessment + * text — `readFreshStatusText` still rejects every timeout stub by marker, + * so a transient stall can't hide the real assessment for the day. + */ + +/** Short retry window after a provider stall before a re-enqueue is allowed. */ +export const TIMEOUT_NEGATIVE_CACHE_MS = 5 * 60 * 1000; + +/** + * Persist a short-TTL negative-cache stub for `(userId, cacheAction)`. + * Best-effort: a failure here just means the read-only resolver may + * re-enqueue sooner than the window intended. + */ +export async function persistTimeoutNegativeStub(args: { + userId: string; + cacheAction: string; + todayKey: string; + reason: "timeout" | "error"; +}): Promise { + try { + await prisma.auditLog.create({ + data: { + userId: args.userId, + action: args.cacheAction, + details: JSON.stringify({ + dateKey: args.todayKey, + timeout: true, + model: "timeout-stub", + reason: args.reason, + retryAt: new Date( + Date.now() + TIMEOUT_NEGATIVE_CACHE_MS, + ).toISOString(), + }), + }, + }); + } catch { + // Negative cache is best-effort — never throw out of the timeout path. + } +} + +/** + * Return the deterministic fallback envelope for a timeout / error. The + * current render still gets the fallback text, marked `cached:true` so the + * UI does not mislabel it as a fresh assessment and `updatedAt:null` to + * signal "no persisted assessment". When `userId` + `todayKey` are present + * (the worker-side generation path) a short-TTL negative stub is persisted + * so a stalled provider doesn't trigger a re-enqueue storm. */ export function returnTimeoutFallback(input: { cacheAction: string; reason: "timeout" | "error"; stubText: string; + userId?: string; + todayKey?: string; }): { hasProvider: true; text: string; @@ -37,6 +84,15 @@ export function returnTimeoutFallback(input: { action: { name: "insights.status.fallback_served" }, meta: { cacheAction: input.cacheAction, reason: input.reason }, }); + if (input.userId && input.todayKey) { + // Fire-and-forget — the caller's render must not wait on the write. + void persistTimeoutNegativeStub({ + userId: input.userId, + cacheAction: input.cacheAction, + todayKey: input.todayKey, + reason: input.reason, + }); + } return { hasProvider: true, text: input.stubText, diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 179de247b..efd4d368a 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -32,7 +32,10 @@ import { summarizeSeries, } from "@/lib/insights/status-shared"; import { runStatusCompletion } from "@/lib/insights/status-provider"; -import { readFreshStatusText } from "@/lib/insights/status-cache"; +import { + readFreshStatusText, + resolveReadOnlyStatusMiss, +} from "@/lib/insights/status-cache"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { toBerlinDayKey } from "@/lib/tz/resolver"; @@ -81,15 +84,25 @@ export async function generateWeightStatusForUser( options?: { locale?: string | null; force?: boolean; + /** + * v1.8.3 — read-only mode for the navigation path. On a cache miss the + * generator does NOT run the SQL gather + blocking LLM inline; it + * enqueues an out-of-band generation and returns a `preparing` shape so + * the request never awaits the provider. The worker (and the nightly + * pre-generate cron) call with `readOnly: false` to actually generate. + */ + readOnly?: boolean; }, ): Promise<{ hasProvider: boolean; text: string | null; cached: boolean; updatedAt: string | null; + preparing?: boolean; }> { const locale = normalizeLocale(options?.locale); const force = options?.force === true; + const readOnly = options?.readOnly === true; const cacheAction = `insights.weight-status.${locale}`; const todayKey = toBerlinDayKey(new Date()); @@ -111,6 +124,31 @@ export async function generateWeightStatusForUser( }; } + // v1.8.3 — read-only navigation path: never block on the provider. + // Enqueue generation out of band and return preparing / no-provider. + if (readOnly) { + const outcome = await resolveReadOnlyStatusMiss({ + userId, + metric: "weight", + locale, + }); + if (outcome === "no-provider") { + return { + hasProvider: false, + text: getNoKeyWeightStatusText(locale), + cached: true, + updatedAt: null, + }; + } + return { + hasProvider: true, + text: null, + cached: false, + updatedAt: null, + preparing: true, + }; + } + // v1.4.28 FB-D2 — cap the snapshot input. BP captures three types // per day so 1095 = 365 d × 3 channels. Order desc + reverse so we // keep the most recent year of rows. @@ -414,6 +452,8 @@ export async function generateWeightStatusForUser( return returnTimeoutFallback({ cacheAction, reason: outcome.kind, + userId, + todayKey, stubText: getNoKeyWeightStatusText(locale), }); } diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index ab8ac000d..29b7d3e50 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -209,7 +209,7 @@ describe("runInsightPregenerate — per-metric warm pass", () => { return vi.fn().mockResolvedValue(result); } - it("forces all seven status generators after a successful comprehensive generation", async () => { + it("forces all seven status generators in BOTH locales after a successful comprehensive generation", async () => { const { prisma } = makePrisma([{ id: "u1", locale: "de" }]); const generate = vi .fn() @@ -223,13 +223,34 @@ describe("runInsightPregenerate — per-metric warm pass", () => { statusGenerators, }); - // Every status generator forced with the resolved locale. + // v1.8.3 — each generator is forced once per supported locale (de + en) + // so the cache the client reads against (active UI locale, not the + // persisted User.locale) is always warm. for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(1); + expect(g).toHaveBeenCalledTimes(2); expect(g).toHaveBeenCalledWith("u1", { locale: "de", force: true }); + expect(g).toHaveBeenCalledWith("u1", { locale: "en", force: true }); } - // Seven fresh, provider-backed assessments warmed. - expect(result.assessmentsWarmed).toBe(7); + // Seven generators × two locales = 14 fresh, provider-backed assessments. + expect(result.assessmentsWarmed).toBe(14); + }); + + it("warms when the comprehensive pass returned `cached` (its write still evicted the per-status caches)", async () => { + const { prisma } = makePrisma([{ id: "u1", locale: "de" }]); + const generate = vi.fn().mockResolvedValue({ status: "cached" }); + const statusGenerators = Array.from({ length: 7 }, () => + warmGen({ hasProvider: true, cached: false }), + ); + + const result = await runInsightPregenerate(prisma as never, { + generate, + statusGenerators, + }); + + for (const g of statusGenerators) { + expect(g).toHaveBeenCalledTimes(2); + } + expect(result.assessmentsWarmed).toBe(14); }); it("does NOT warm when the comprehensive pass skipped (no provider)", async () => { @@ -290,11 +311,13 @@ describe("runInsightPregenerate — per-metric warm pass", () => { statusGenerators, }); - // The throw must not abort the loop — every generator was attempted. + // The throw must not abort the loop — every generator was attempted + // once per supported locale (de + en). for (const g of statusGenerators) { - expect(g).toHaveBeenCalledTimes(1); + expect(g).toHaveBeenCalledTimes(2); } - expect(result.assessmentsWarmed).toBe(2); + // Two counting generators × two locales = 4 fresh assessments. + expect(result.assessmentsWarmed).toBe(4); }); }); diff --git a/src/lib/jobs/__tests__/insight-status-generate.test.ts b/src/lib/jobs/__tests__/insight-status-generate.test.ts new file mode 100644 index 000000000..c452a8f0c --- /dev/null +++ b/src/lib/jobs/__tests__/insight-status-generate.test.ts @@ -0,0 +1,156 @@ +/** + * v1.8.3 — unit tests for the on-demand per-metric status generation queue. + * + * Covers: + * - the dispatch runs the matching generator with force:true; + * - an unknown metric is annotated and skipped, not thrown; + * - the enqueue helper no-ops without a global boss instance and de-dupes + * with a singletonKey when one is present; + * - the queue is registered in `allQueues` AND wired to a `boss.work` + * handler in reminder-worker.ts (the v1.4.37 W10 unregistered-queue + * catch — a queue built but never registered silently never drains). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; + +const getGlobalBoss = vi.fn(); +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: (...a: unknown[]) => getGlobalBoss(...a), +})); +// The dispatch module imports the seven generators transitively; stub them +// so the test never reaches a live provider. +vi.mock("@/lib/insights/general-status", () => ({ + generateGeneralStatusForUser: vi.fn(), +})); +vi.mock("@/lib/insights/blood-pressure-status", () => ({ + generateBloodPressureStatusForUser: vi.fn(), +})); +vi.mock("@/lib/insights/weight-status", () => ({ + generateWeightStatusForUser: vi.fn(), +})); +vi.mock("@/lib/insights/pulse-status", () => ({ + generatePulseStatusForUser: vi.fn(), +})); +vi.mock("@/lib/insights/bmi-status", () => ({ + generateBmiStatusForUser: vi.fn(), +})); +vi.mock("@/lib/insights/mood-status", () => ({ + generateMoodStatusForUser: vi.fn(), +})); +vi.mock("@/lib/insights/medication-compliance-status", () => ({ + generateMedicationComplianceStatusForUser: vi.fn(), +})); + +import { + runInsightStatusGenerate, + enqueueStatusGeneration, + INSIGHT_STATUS_GENERATE_QUEUE, + INSIGHT_STATUS_METRICS, +} from "../insight-status-generate"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("runInsightStatusGenerate", () => { + it("forces the matching generator with the payload locale", async () => { + const weight = vi.fn().mockResolvedValue(undefined); + const generators = { + general: vi.fn(), + "blood-pressure": vi.fn(), + weight, + pulse: vi.fn(), + bmi: vi.fn(), + mood: vi.fn(), + "medication-compliance": vi.fn(), + }; + await runInsightStatusGenerate( + { userId: "u1", metric: "weight", locale: "en" }, + generators, + ); + expect(weight).toHaveBeenCalledWith("u1", { locale: "en", force: true }); + // No other generator ran. + expect(generators.pulse).not.toHaveBeenCalled(); + }); + + it("skips (does not throw) an unknown metric", async () => { + await expect( + runInsightStatusGenerate( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { userId: "u1", metric: "nope" as any, locale: "de" }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + {} as any, + ), + ).resolves.toBeUndefined(); + }); + + it("exposes the seven canonical metrics", () => { + expect([...INSIGHT_STATUS_METRICS].sort()).toEqual( + [ + "general", + "blood-pressure", + "weight", + "pulse", + "bmi", + "mood", + "medication-compliance", + ].sort(), + ); + }); +}); + +describe("enqueueStatusGeneration", () => { + it("no-ops when no global boss is available", async () => { + getGlobalBoss.mockReturnValue(null); + await expect( + enqueueStatusGeneration({ userId: "u1", metric: "weight", locale: "de" }), + ).resolves.toBeUndefined(); + }); + + it("sends with a per-(user,metric,locale) singletonKey when boss exists", async () => { + const send = vi.fn().mockResolvedValue("job-id"); + getGlobalBoss.mockReturnValue({ send }); + await enqueueStatusGeneration({ + userId: "u1", + metric: "mood", + locale: "en", + }); + expect(send).toHaveBeenCalledWith( + INSIGHT_STATUS_GENERATE_QUEUE, + { userId: "u1", metric: "mood", locale: "en" }, + expect.objectContaining({ singletonKey: "u1:mood:en" }), + ); + }); + + it("swallows a boss.send rejection (best-effort enqueue)", async () => { + const send = vi.fn().mockRejectedValue(new Error("boom")); + getGlobalBoss.mockReturnValue({ send }); + await expect( + enqueueStatusGeneration({ userId: "u1", metric: "bmi", locale: "de" }), + ).resolves.toBeUndefined(); + }); +}); + +describe("queue registration", () => { + const workerSrc = fs.readFileSync( + path.resolve(__dirname, "../reminder-worker.ts"), + "utf8", + ); + + it("registers the queue name in the allQueues createQueue loop", () => { + const match = workerSrc.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/); + expect(match).not.toBeNull(); + expect(match![1]).toMatch(/\bINSIGHT_STATUS_GENERATE_QUEUE\b/); + }); + + it("registers a boss.work handler for the queue", () => { + expect(workerSrc).toMatch( + /boss\.work[\s\S]{0,120}INSIGHT_STATUS_GENERATE_QUEUE/, + ); + }); + + it("exposes a stable queue name", () => { + expect(INSIGHT_STATUS_GENERATE_QUEUE).toBe("insight-status-generate"); + }); +}); diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index e70f9f3b3..85c769e76 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -156,12 +156,20 @@ const DEFAULT_STATUS_GENERATORS: ReadonlyArray = [ */ async function warmPerStatusCaches( userId: string, - locale: "de" | "en", + locales: ReadonlyArray<"de" | "en">, generators: ReadonlyArray, ): Promise { const limit = pLimit(WARM_PASS_CONCURRENCY); - const results = await Promise.all( - generators.map((generate) => + // v1.8.3 — warm every locale the user might request, not just the + // User.locale. The per-status cache key is + // `insights.-status.`, but the client sends the active + // UI-cookie / Accept-Language locale (narrowed to de|en), which can + // differ from the persisted User.locale (e.g. null → "de" in the cron + // while the browser cookie is "en"). Warming only the User.locale left + // the locale the client actually reads cold, so the first visit still + // ran a live generation per card — the exact freeze this release closes. + const tasks = generators.flatMap((generate) => + locales.map((locale) => limit(async () => { try { const outcome = await generate(userId, { locale, force: true }); @@ -174,6 +182,7 @@ async function warmPerStatusCaches( }), ), ); + const results = await Promise.all(tasks); return results.reduce((sum: number, n) => sum + n, 0); } @@ -301,18 +310,23 @@ export async function runInsightPregenerate( } // Warm the per-metric assessment caches the comprehensive generator - // evicts (`evictPerStatusInsightCache`) but does not re-fill. Only - // do this when the comprehensive pass actually produced an insight: - // a `skipped` (no-provider) user has nothing to warm, and a `failed` - // pass means the provider chain is unhealthy this run — re-running - // it seven more times would only multiply the failure. A `cached` - // outcome can't happen here (we always force), but if a future - // change reintroduces it the per-metric caches were not evicted, so - // there is nothing to warm. - if (outcome.status === "generated") { + // evicts (`evictPerStatusInsightCache`) but does not re-fill. + // + // v1.8.3 — warm on `generated` OR `cached`. A `cached` outcome means + // the comprehensive insight is fresh-enough that its write already + // evicted the per-status caches, so they are cold and need re-filling + // exactly as on a fresh `generated`. The pre-v1.8.3 `=== "generated"` + // gate left those caches cold whenever the comprehensive pass short- + // circuited to `cached`, so the morning visit still ran a live + // generation per card. `skipped` (no provider) has nothing to warm and + // `failed` means the chain is unhealthy this run — re-running it seven + // more times would only multiply the failure. + if (outcome.status === "generated" || outcome.status === "cached") { result.assessmentsWarmed += await warmPerStatusCaches( candidate.id, - locale, + // Warm both supported locales — the client reads against the active + // UI locale, which can differ from the persisted User.locale. + ["de", "en"], statusGenerators, ); } diff --git a/src/lib/jobs/insight-status-generate-shared.ts b/src/lib/jobs/insight-status-generate-shared.ts new file mode 100644 index 000000000..67581559e --- /dev/null +++ b/src/lib/jobs/insight-status-generate-shared.ts @@ -0,0 +1,91 @@ +/** + * v1.8.3 — shared contract for the on-demand per-metric status generation + * queue, free of any generator import. + * + * The status generators import `enqueueStatusGeneration` from here (via + * `status-cache.ts`). Keeping the enqueue + the queue name + the metric + * vocabulary in a generator-free module breaks the import cycle that would + * otherwise form: generator → status-cache → (enqueue) → generator. The + * worker-only dispatch (`runInsightStatusGenerate`, which references the + * concrete generators) lives in `insight-status-generate.ts` and is + * imported solely by the worker boot file. + */ +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { annotate } from "@/lib/logging/context"; + +export const INSIGHT_STATUS_GENERATE_QUEUE = "insight-status-generate"; + +/** + * Low concurrency keeps the on-demand warm pass from saturating the + * Prisma pool or fanning out an unbounded number of concurrent provider + * calls when several cards request generation on the same first visit. + */ +export const INSIGHT_STATUS_GENERATE_CONCURRENCY = 2; + +/** + * The seven per-metric status scopes the queue can generate. Mirrors the + * `cacheAction` slug each generator uses (`insights.-status.`) + * so the queue payload, the route, and the client all speak one vocabulary. + */ +export type InsightStatusMetric = + | "general" + | "blood-pressure" + | "weight" + | "pulse" + | "bmi" + | "mood" + | "medication-compliance"; + +export const INSIGHT_STATUS_METRICS: ReadonlyArray = [ + "general", + "blood-pressure", + "weight", + "pulse", + "bmi", + "mood", + "medication-compliance", +]; + +export interface InsightStatusGeneratePayload { + userId: string; + metric: InsightStatusMetric; + /** Resolved client locale — the cache key the route reads against. */ + locale: "de" | "en"; +} + +/** + * Fire-and-forget enqueue used by the read-only status route on a cache + * miss. A `singletonKey` per `(user, metric, locale)` collapses repeated + * polls into one queued job, so a client polling every few seconds while + * the provider works can't pile up duplicate generations. No-ops cleanly + * when the global boss instance is not available (e.g. the web process + * runs without an embedded worker) — the nightly pre-generate cron and + * the daily status crons remain the catch-net. + */ +export async function enqueueStatusGeneration( + payload: InsightStatusGeneratePayload, +): Promise { + const boss = getGlobalBoss(); + if (!boss) { + annotate({ + action: { name: "insights.status.generate.no_boss" }, + meta: { metric: payload.metric }, + }); + return; + } + try { + await boss.send(INSIGHT_STATUS_GENERATE_QUEUE, payload, { + singletonKey: `${payload.userId}:${payload.metric}:${payload.locale}`, + // De-dupe within a short window so a polling client doesn't enqueue + // a fresh job on every tick while a generation is already running. + singletonSeconds: 120, + }); + annotate({ + action: { name: "insights.status.generate.enqueued" }, + meta: { metric: payload.metric }, + }); + } catch { + // Enqueue is best-effort; a failure here just means the card stays + // in `preparing` until the next poll / nightly cron warms the cache. + } +} diff --git a/src/lib/jobs/insight-status-generate.ts b/src/lib/jobs/insight-status-generate.ts new file mode 100644 index 000000000..6a00ef3b2 --- /dev/null +++ b/src/lib/jobs/insight-status-generate.ts @@ -0,0 +1,92 @@ +/** + * v1.8.3 — on-demand per-metric status generation queue (worker dispatch). + * + * Before this, every `/insights/` sub-page fetched its status + * card from `GET /api/insights/-status`, and that GET ran the + * full SQL gather + a blocking LLM round-trip inline. The provider call + * was capped server-side at 60 s, so a cold cache on a slow provider + * pinned the navigation request — and, because the timeout fallback is + * deliberately not persisted, every subsequent visit re-blocked. + * + * This queue moves the heavy gather+LLM off the request path. The route + * now reads the cache only; on a miss it fire-and-forget enqueues a job + * (via `enqueueStatusGeneration` in the generator-free shared module) and + * returns a `preparing` envelope. The worker runs the matching generator + * with `force: true`, which persists the assessment cache row; the client + * polls the read-only GET until it lands. + * + * Recurring/event pg-boss task — never runs inside an HTTP request and + * never shells out to `tsx` (CLAUDE.md DO-NOTs). Registered in the + * reminder-worker `allQueues` loop + a `boss.work` handler; a queue built + * but never registered silently never drains (the v1.4.37 W10 incident), + * so a guard test pins the registration. + * + * The queue name, payload type, metric vocabulary, and the enqueue helper + * live in `insight-status-generate-shared.ts` so the status generators can + * import the enqueue without pulling the concrete generators back in (an + * import cycle). This module re-exports them for the worker's convenience. + */ +import { annotate } from "@/lib/logging/context"; +import { generateGeneralStatusForUser } from "@/lib/insights/general-status"; +import { generateBloodPressureStatusForUser } from "@/lib/insights/blood-pressure-status"; +import { generateWeightStatusForUser } from "@/lib/insights/weight-status"; +import { generatePulseStatusForUser } from "@/lib/insights/pulse-status"; +import { generateBmiStatusForUser } from "@/lib/insights/bmi-status"; +import { generateMoodStatusForUser } from "@/lib/insights/mood-status"; +import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status"; +import { + INSIGHT_STATUS_GENERATE_QUEUE, + INSIGHT_STATUS_GENERATE_CONCURRENCY, + INSIGHT_STATUS_METRICS, + enqueueStatusGeneration, + type InsightStatusMetric, + type InsightStatusGeneratePayload, +} from "@/lib/jobs/insight-status-generate-shared"; + +export { + INSIGHT_STATUS_GENERATE_QUEUE, + INSIGHT_STATUS_GENERATE_CONCURRENCY, + INSIGHT_STATUS_METRICS, + enqueueStatusGeneration, +}; +export type { InsightStatusMetric, InsightStatusGeneratePayload }; + +/** + * Narrow generator shape: the queue only forces a fresh generation and + * discards the payload (the generator persists the cache row itself). + */ +type StatusGenerator = ( + userId: string, + options: { locale: "de" | "en"; force: boolean }, +) => Promise; + +const GENERATORS: Record = { + general: generateGeneralStatusForUser, + "blood-pressure": generateBloodPressureStatusForUser, + weight: generateWeightStatusForUser, + pulse: generatePulseStatusForUser, + bmi: generateBmiStatusForUser, + mood: generateMoodStatusForUser, + "medication-compliance": generateMedicationComplianceStatusForUser, +}; + +/** + * Run one on-demand status generation. Pure of pg-boss so the unit test + * can drive it directly. Forces a fresh generation so the cache row the + * route's read-only path missed gets written; an unknown metric is + * annotated and skipped rather than thrown. + */ +export async function runInsightStatusGenerate( + payload: InsightStatusGeneratePayload, + generators: Record = GENERATORS, +): Promise { + const generate = generators[payload.metric]; + if (!generate) { + annotate({ + action: { name: "insights.status.generate.unknown_metric" }, + meta: { metric: payload.metric }, + }); + return; + } + await generate(payload.userId, { locale: payload.locale, force: true }); +} diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index 9a0968272..d83ef925c 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -67,6 +67,12 @@ import { runInsightPregenerate, type InsightPregeneratePayload, } from "@/lib/jobs/insight-pregenerate"; +import { + INSIGHT_STATUS_GENERATE_QUEUE, + INSIGHT_STATUS_GENERATE_CONCURRENCY, + runInsightStatusGenerate, + type InsightStatusGeneratePayload, +} from "@/lib/jobs/insight-status-generate"; import { INTAKE_AUTO_SKIP_QUEUE, INTAKE_AUTO_SKIP_CRON, @@ -1700,6 +1706,32 @@ async function handleInsightPregenerateJob( }); } +/** + * v1.8.3 — on-demand per-metric status generation. The read-only status + * route enqueues one job per cold card; this handler runs the matching + * generator with `force: true` so the assessment cache row lands and the + * polling client picks it up. Each job carries `{ userId, metric, locale }`. + */ +async function handleInsightStatusGenerate( + jobs: Job[], +) { + await withBackgroundEvent("job.insight_status_generate", async (evt) => { + for (const job of jobs) { + if (!job.data?.userId || !job.data?.metric) continue; + try { + await runInsightStatusGenerate(job.data); + evt.addMeta("status_generated", `${job.data.metric}:${job.data.locale}`); + } catch (err) { + evt.addWarning( + `insight-status-generate failed for ${job.data.metric}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + }); +} + /** * v1.4.46 — hourly auto-skip for stale unmarked medication intakes. * @@ -2055,6 +2087,12 @@ export async function startReminderWorker() { // this entry the 04:30 schedule silently no-ops and every briefing // falls back to the lazy on-demand generation it was meant to retire. INSIGHT_PREGENERATE_QUEUE, + // v1.8.3 — on-demand per-metric status generation. The read-only + // status route enqueues here on a cold card; without this entry + // pg-boss never provisions the queue and the enqueue silently drops, + // leaving every status card stuck on "preparing". No cron schedule — + // it is a send-only queue driven by navigation. + INSIGHT_STATUS_GENERATE_QUEUE, // v1.7.0 — soft-deleted measurement tombstone prune. Same createQueue // contract as the other cleanup jobs; without this entry the daily // schedule silently no-ops and pruned-past-retention tombstones pile @@ -2306,6 +2344,15 @@ export async function startReminderWorker() { { localConcurrency: 1 }, handleInsightPregenerateJob, ); + // v1.8.3 — on-demand per-metric status generation enqueued by the + // read-only status route on a cold card. Low concurrency so a first + // visit that cold-misses several cards can't saturate the Prisma pool + // or fan out an unbounded number of concurrent provider calls. + await boss.work( + INSIGHT_STATUS_GENERATE_QUEUE, + { localConcurrency: INSIGHT_STATUS_GENERATE_CONCURRENCY }, + handleInsightStatusGenerate, + ); // v1.4.46 — hourly auto-skip for stale unmarked intakes. // Single-flight: two ticks racing against the same row pile is wasted // work, and the underlying `updateMany` is the canonical idempotent