fix(insights): bound the status preparing-poll to a fixed ceiling

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 17:31:45 +02:00
1 parent 8dced00188
commit 65cfa2b83a
3 files changed
+92 -6

No files matched your search

+11 -4
View File
@@ -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 ?? [];
@@ -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);
});
});
+36 -2
View File
@@ -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,
),
});
}