From 65cfa2b83af04d5e9ef39583960471f2eeb87e70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 1 Jun 2026 17:30:56 +0200 Subject: [PATCH] fix(insights): bound the status preparing-poll to a fixed ceiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-metric status cards and the inline medication-compliance query poll the read-only status route every 4 s while the payload reports preparing:true. A configured provider whose generation persistently fails leaves preparing pinned true, so an open Insights page would poll indefinitely — steady battery and network draw on a screen the user has walked away from. Cap the poll at 12 attempts (~48 s) through a shared nextStatusPollInterval helper both call sites route through. Past the ceiling the card settles on its existing static / empty state. The happy path is unchanged: a generation that lands within a couple of intervals clears preparing and stops the timer exactly as before, and a terminal no-provider payload never carried preparing so it stays terminal. --- src/app/insights/medications/page.tsx | 15 +++++-- .../__tests__/use-insight-status.test.ts | 45 +++++++++++++++++++ src/hooks/use-insight-status.ts | 38 +++++++++++++++- 3 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 src/hooks/__tests__/use-insight-status.test.ts diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index c1712c7f5..0341e5aa4 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { Loader2, Pill } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; +import { nextStatusPollInterval } from "@/hooks/use-insight-status"; import { queryKeys } from "@/lib/query-keys"; import { useTranslations } from "@/lib/i18n/context"; import { Badge } from "@/components/ui/badge"; @@ -64,11 +65,11 @@ interface MedicationComplianceStatusData { preparing?: boolean; } -// v1.8.3 — client ceiling + preparing-poll cadence, mirroring the shared +// v1.8.3 — client ceiling on the round-trip, mirroring the shared // `use-insight-status` hook so the medication-compliance card never awaits -// an uncapped LLM round-trip on navigation. +// an uncapped LLM round-trip on navigation. The preparing-poll cadence and +// its attempt ceiling come from the shared `nextStatusPollInterval` helper. const MED_STATUS_TIMEOUT_MS = 8_000; -const MED_STATUS_POLL_MS = 4_000; interface MedicationDailyData { expected: number; @@ -121,8 +122,14 @@ export default function InsightsMedikamentePage() { enabled: isAuthenticated, staleTime: 60 * 1000, retry: 0, + // v1.8.4 — bounded preparing poll: stop after the shared attempt + // ceiling so a persistently failing generation can't poll an open + // page forever. refetchInterval: (query) => - query.state.data?.preparing ? MED_STATUS_POLL_MS : false, + nextStatusPollInterval( + query.state.data?.preparing, + query.state.dataUpdateCount, + ), }); const medications = comprehensive?.medications ?? []; diff --git a/src/hooks/__tests__/use-insight-status.test.ts b/src/hooks/__tests__/use-insight-status.test.ts new file mode 100644 index 000000000..ce47845ad --- /dev/null +++ b/src/hooks/__tests__/use-insight-status.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; + +import { + nextStatusPollInterval, + STATUS_POLL_MAX_ATTEMPTS, +} from "../use-insight-status"; + +/** + * v1.8.4 — the preparing poll must stop after a bounded number of + * attempts. A provider can be configured yet have generation fail + * persistently; without a ceiling the status card polls an open page + * forever (battery / network waste). `nextStatusPollInterval` is the + * shared decision both the hook and the inline medication-compliance + * query route through, so locking its contract covers both sites. + */ +describe("nextStatusPollInterval — v1.8.4 poll ceiling", () => { + it("does not poll a terminal payload (preparing absent/false)", () => { + expect(nextStatusPollInterval(undefined, 1)).toBe(false); + expect(nextStatusPollInterval(false, 1)).toBe(false); + // A terminal `no-provider` / settled payload never carries preparing, + // so it stops immediately regardless of attempt count. + expect(nextStatusPollInterval(false, 0)).toBe(false); + }); + + it("keeps polling while preparing and below the cap", () => { + expect(nextStatusPollInterval(true, 0)).toBeTypeOf("number"); + expect(nextStatusPollInterval(true, 1)).toBeTypeOf("number"); + expect( + nextStatusPollInterval(true, STATUS_POLL_MAX_ATTEMPTS - 1), + ).toBeTypeOf("number"); + }); + + it("stops polling once the attempt cap is reached", () => { + expect(nextStatusPollInterval(true, STATUS_POLL_MAX_ATTEMPTS)).toBe(false); + expect( + nextStatusPollInterval(true, STATUS_POLL_MAX_ATTEMPTS + 5), + ).toBe(false); + }); + + it("returns a positive interval while it polls", () => { + const interval = nextStatusPollInterval(true, 0); + expect(interval).not.toBe(false); + expect(interval as number).toBeGreaterThan(0); + }); +}); diff --git a/src/hooks/use-insight-status.ts b/src/hooks/use-insight-status.ts index 6583fa2c1..8b404d355 100644 --- a/src/hooks/use-insight-status.ts +++ b/src/hooks/use-insight-status.ts @@ -49,6 +49,35 @@ const STATUS_TIMEOUT_MS = 8_000; */ const STATUS_POLL_MS = 4_000; +/** + * v1.8.4 — hard ceiling on the preparing poll. The route is read-only and + * a worker warms the cache out of band; a healthy generation lands within + * a couple of intervals. If a provider is configured but generation + * persistently fails, `preparing` stays true forever and the open page + * would otherwise poll indefinitely (battery / network waste). Cap the + * poll at ~12 attempts (≈ 48 s at the 4 s cadence) and then fall back to + * the static empty / "no analysis yet" state. `dataUpdateCount` counts + * every settled fetch — initial load plus each poll round — so once it + * crosses the cap we stop scheduling the next interval. + */ +export const STATUS_POLL_MAX_ATTEMPTS = 12; + +/** + * Decide whether a `preparing` card should schedule its next poll. + * Pure and shared between the `useInsightStatus` hook and the inline + * medication-compliance query so both sites enforce the identical + * ceiling. Returns the interval in ms while polling is warranted, or + * `false` once the payload is terminal OR the attempt cap is reached. + */ +export function nextStatusPollInterval( + preparing: boolean | undefined, + dataUpdateCount: number, +): number | false { + if (!preparing) return false; + if (dataUpdateCount >= STATUS_POLL_MAX_ATTEMPTS) return false; + return STATUS_POLL_MS; +} + /** * Metric slugs the sub-pages render. Each slug is paired with the * Insights-status endpoint and the matching `queryKeys.*` factory so @@ -124,8 +153,13 @@ export function useInsightStatus(metric: InsightStatusMetric) { 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. + // never re-fetches on an interval. v1.8.4 — also stop after the + // attempt ceiling so a persistently failing generation can't poll an + // open page forever. refetchInterval: (query) => - query.state.data?.preparing ? STATUS_POLL_MS : false, + nextStatusPollInterval( + query.state.data?.preparing, + query.state.dataUpdateCount, + ), }); }