fix(insights): make per-category status cards read-only so navigation never blocks on the LLM

Opening an Insights category page froze the whole UI: the per-metric
status card fired a cold, uncapped LLM fetch on mount with no client
abort, so the request hung up to the server's 60s ceiling and the
pending/resolve re-renders thrashed the tab strip and ate touch events.
Timeouts were never negatively cached, so every visit re-blocked.

Fix the class holistically:

- Bound every status GET on the client. The shared use-insight-status
  hook and the inline medication-compliance query now wrap fetch in an
  8s AbortController and stop retrying inline; navigation can no longer
  await an uncapped round-trip.

- Make the status routes read-only. On a cache miss the generator
  enqueues an out-of-band pg-boss job (insight-status-generate) and
  returns a preparing envelope; the client polls a bounded interval
  until the worker warms the cache. Models the dashboard snapshot's
  ready/preparing pattern. The queue is registered in the worker
  allQueues loop + a boss.work handler, with a guard test.

- Fix the nightly warm-pass regression: warm every supported locale
  (the client reads against the active UI locale, not the persisted
  User.locale) and warm on a cached comprehensive outcome too (its
  write still evicts the per-status caches). Add a short-TTL negative
  cache on the timeout path so a transient provider stall no longer
  makes every subsequent visit re-block or re-enqueue.

- Add loading.tsx for the insights segment + the root so any future
  RSC await streams a skeleton instead of blocking the transition.

- Stabilise the advisor regenerate callback so a status-query flip
  can't re-render the memoised tab strip mid-gesture.

New i18n key insights.assessmentPreparing across all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 08:59:59 +02:00
1 parent 6a603ad587
commit 35800becbb
47 files changed
+1297 -75

No files matched your search

+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
@@ -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" } });
+4
View File
@@ -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" } });
+1
View File
@@ -124,6 +124,7 @@ export default function InsightsBlutdruckPage() {
cached={status?.cached ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
preparing={status?.preparing ?? false}
/>
<CoachLaunchButton />
+1
View File
@@ -133,6 +133,7 @@ export default function InsightsBmiPage() {
cached={status?.cached ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
preparing={status?.preparing ?? false}
/>
<CoachLaunchButton />
+37
View File
@@ -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>
);
}
+29 -5
View File
@@ -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
+1
View File
@@ -115,6 +115,7 @@ export default function InsightsStimmungPage() {
cached={status?.cached ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
preparing={status?.preparing ?? false}
/>
<CoachLaunchButton />
+1
View File
@@ -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
+1
View File
@@ -97,6 +97,7 @@ export default function InsightsGewichtPage() {
cached={status?.cached ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
preparing={status?.preparing ?? false}
/>
<CoachLaunchButton />
+24
View File
@@ -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>
);
}
@@ -18,6 +18,13 @@ interface InsightStatusCardProps {
cached: boolean;
updatedAt: string | null;
loading?: boolean;
/**
* v1.8.3 — the read-only status route enqueued an out-of-band generation
* and the assessment isn't warm yet. Render the same skeleton geometry as
* `loading` but with a "preparing" caption so the user understands the
* card is being assembled, not stuck. The client polls until text lands.
*/
preparing?: boolean;
}
// ─── Main Component ───────────────────────────────────────
@@ -30,6 +37,7 @@ export function InsightStatusCard({
cached,
updatedAt,
loading = false,
preparing = false,
}: InsightStatusCardProps) {
const { t } = useTranslations();
const flags = useFeatureFlags();
@@ -38,6 +46,36 @@ export function InsightStatusCard({
// suppressed in full so the layout collapses naturally.
if (!flags.insightStatus) return null;
// v1.8.3 — preparing: the read-only route enqueued a generation and the
// client is polling. Render the loading skeleton geometry with a
// preparing caption so the card reads as "assembling", not stuck. Only
// shown when a provider is configured (the route returns the no-key
// fallback otherwise).
if (preparing && hasProvider && !text) {
return (
<Card
aria-busy="true"
aria-live="polite"
data-testid="insight-status-card-preparing"
>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
{icon}
<CardTitle className="text-base">{title}</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="bg-muted h-3.5 w-full animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted h-3.5 w-11/12 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted h-3.5 w-9/12 animate-pulse rounded motion-reduce:animate-none" />
<p className="text-muted-foreground text-xs">
{t("insights.assessmentPreparing")}
</p>
</CardContent>
</Card>
);
}
if (loading) {
// v1.4.37 — structured skeleton over the centred spinner. When the
// per-metric status route had to fall back to the 20 s provider
@@ -1,5 +1,6 @@
"use client";
import { useCallback } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { InsightResult } from "@/lib/ai/types";
import {
@@ -176,12 +177,20 @@ export function useInsightsAdvisorQuery(
},
});
// v1.8.3 — stabilise the `regenerate` callback so the memoised
// `<InsightsTabStrip>` (which receives it as `onRegenerate`) is not
// re-rendered on every shell render by a fresh arrow reference. A
// status-query flip on a sub-page that re-renders the shell would
// otherwise re-reconcile the whole strip mid-gesture and eat taps.
const { mutate } = mutation;
const regenerate = useCallback(() => mutate(), [mutate]);
return {
payload: query.data ?? null,
isLoading: query.isLoading,
isError: query.isError,
error: (query.error as Error | null) ?? null,
regenerate: () => mutation.mutate(),
regenerate,
isRegenerating: mutation.isPending,
regenerateError: (mutation.error as Error | null) ?? null,
};
+52 -8
View File
@@ -23,8 +23,32 @@ export interface InsightStatusData {
text: string | null;
cached: boolean;
updatedAt: string | null;
/**
* v1.8.3 — the route is read-only: a cache miss enqueues an out-of-band
* generation and returns `preparing: true` with `text: null`. The card
* shows a preparing state and the hook polls (bounded) until the worker
* warms the cache. Absent / false means the payload is terminal.
*/
preparing?: boolean;
}
/**
* v1.8.3 — client-side ceiling on the status GET. The route is now
* read-only (sub-second cache read), so a slow response means the network
* itself is degraded, not the LLM. Mirror the advisor hook's 8 s
* `ADVISOR_TIMEOUT_MS`: abort and surface the empty / preparing state
* rather than letting a hung request pin the navigation. No user
* navigation may ever await an uncapped round-trip.
*/
const STATUS_TIMEOUT_MS = 8_000;
/**
* Poll interval while a card is `preparing`. Bounded by `staleTime` so a
* settled (terminal) payload never re-fetches on a timer; only the
* preparing state keeps polling, and it stops as soon as text lands.
*/
const STATUS_POLL_MS = 4_000;
/**
* Metric slugs the sub-pages render. Each slug is paired with the
* Insights-status endpoint and the matching `queryKeys.*` factory so
@@ -71,17 +95,37 @@ export function useInsightStatus(metric: InsightStatusMetric) {
return useQuery({
queryKey: QUERY_KEY_FACTORY[metric](locale),
queryFn: async (): Promise<InsightStatusData> => {
const res = await fetch(`/api/insights/${metric}-status?locale=${locale}`);
if (!res.ok) throw new Error("Failed");
const json = (await res.json()) as { data: InsightStatusData };
return json.data;
// v1.8.3 — bound the fetch on the client. AbortController + 8 s
// timeout so a degraded network can't pin the navigation thread.
// The card surfaces its preparing / empty state on abort exactly as
// it does for a `preparing` payload, and the bounded poll retries.
const controller = new AbortController();
const timeoutHandle = setTimeout(
() => controller.abort(),
STATUS_TIMEOUT_MS,
);
try {
const res = await fetch(
`/api/insights/${metric}-status?locale=${locale}`,
{ signal: controller.signal },
);
if (!res.ok) throw new Error("Failed");
const json = (await res.json()) as { data: InsightStatusData };
return json.data;
} finally {
clearTimeout(timeoutHandle);
}
},
enabled: isAuthenticated,
staleTime: 60 * 1000,
// v1.4.28 FB-D2 — the status route now returns a deterministic
// cached-style envelope on provider timeout. Retrying that response
// re-fires the same expensive upstream call and the user perceives
// a longer hang. Drop retries to surface the fallback immediately.
// v1.4.28 FB-D2 — the status route returns a deterministic envelope on
// a miss/timeout. Retrying inline re-fires work and lengthens the
// perceived hang; the bounded `preparing` poll below covers the warm-up.
retry: 0,
// v1.8.3 — poll only while the worker is preparing the assessment.
// Returns false (no timer) for any terminal payload so a settled card
// never re-fetches on an interval.
refetchInterval: (query) =>
query.state.data?.preparing ? STATUS_POLL_MS : false,
});
}
@@ -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,
);
}
});
});
+26 -2
View File
@@ -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,
);
}
});
});
@@ -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,
);
}
});
});
@@ -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,
);
}
});
});
+13 -1
View File
@@ -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,
);
}
});
});
@@ -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");
});
});
@@ -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);
});
});
@@ -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,
);
}
});
});
+33 -1
View File
@@ -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),
});
}
+33 -1
View File
@@ -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),
});
}
+33 -1
View File
@@ -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),
});
}
@@ -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 {
+33 -1
View File
@@ -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),
});
}
+33 -1
View File
@@ -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),
});
}
+96
View File
@@ -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: <no-key fallback> }`
* - `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<ReadOnlyMissOutcome> {
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<boolean> {
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;
}
}
+16
View File
@@ -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<boolean> {
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
+73 -17
View File
@@ -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<void> {
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,
+41 -1
View File
@@ -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),
});
}
@@ -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);
});
});
@@ -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");
});
});
+27 -13
View File
@@ -156,12 +156,20 @@ const DEFAULT_STATUS_GENERATORS: ReadonlyArray<StatusGenerator> = [
*/
async function warmPerStatusCaches(
userId: string,
locale: "de" | "en",
locales: ReadonlyArray<"de" | "en">,
generators: ReadonlyArray<StatusGenerator>,
): Promise<number> {
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.<metric>-status.<locale>`, 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,
);
}
@@ -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.<metric>-status.<locale>`)
* 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<InsightStatusMetric> = [
"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<void> {
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.
}
}
+92
View File
@@ -0,0 +1,92 @@
/**
* v1.8.3 — on-demand per-metric status generation queue (worker dispatch).
*
* Before this, every `/insights/<metric>` sub-page fetched its status
* card from `GET /api/insights/<metric>-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<unknown>;
const GENERATORS: Record<InsightStatusMetric, StatusGenerator> = {
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<InsightStatusMetric, StatusGenerator> = GENERATORS,
): Promise<void> {
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 });
}
+47
View File
@@ -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<InsightStatusGeneratePayload>[],
) {
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<InsightStatusGeneratePayload>(
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