From 15b8df11bc211061e0f1ecef947d5e6120315cc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 24 Jul 2026 13:56:22 +0200 Subject: [PATCH] =?UTF-8?q?chore(release):=20v1.32.19=20=E2=80=94=20clear?= =?UTF-8?q?=20the=20Today=20view=20the=20instant=20a=20write=20lands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dose logged from another screen marked the Today hero and the dashboard stale without re-reading them, so returning to the home page kept showing the old state until the 120 s poll or a hard reload. The daily reads run with refetchOnMount off, so a bundle invalidation with the default active refetch-type never touched them while they were unmounted. Route every intake-affecting medication write through one blessed invalidateMedicationReads (bundle invalidation plus a forced inactive refetch of the digest and snapshot): Take all due, the intake edit dialog, the history bulk/row delete, the dose-history ledger and its add dialog, the lifecycle and danger-zone actions, and the intake import. Give the Vorsorge reminder mutations the same daily-rail refresh, bust the biomarker assessment and the labs-changes read after a lab write, and refetch the daily reads after a briefing regenerate so the Today lead updates without a reload. Restore the dashboard-layout concurrency belt: the settings layout query now always loads its own tokened copy, so a home-page visit can no longer strand a save without its base token and silently drop the v1.32.16 conflict guard. Add a structural guard that fails CI when a new site invalidates a daily-reads bundle without the paired inactive refetch, so this class cannot recur a fourth time, plus behavioural coverage for the batch take and the Vorsorge refresh. Refs #581 --- CHANGELOG.md | 31 ++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- .../daily-reads-refetch-guard.test.ts | 171 ++++++++++++++++++ .../insights/use-insights-advisor.ts | 24 ++- src/components/labs/lab-biomarker-detail.tsx | 22 ++- src/components/labs/lab-history-list.tsx | 7 + .../__tests__/take-all-due.test.tsx | 50 ++++- .../medications/dose-history-add-dialog.tsx | 10 +- .../medications/dose-history-ledger.tsx | 17 ++ .../medications/intake-edit-dialog.tsx | 7 +- .../medications/intake-history-editable.tsx | 6 +- .../medications/intake-import-dialog.tsx | 7 +- .../sections/destructive-zone-section.tsx | 14 +- src/components/medications/take-all-due.ts | 17 +- .../medications/use-medication-intake.ts | 42 +---- .../settings/dashboard-layout-section.tsx | 15 ++ .../use-measurement-reminders-refetch.test.ts | 50 +++++ src/hooks/use-measurement-reminders.ts | 33 +++- src/lib/queries/use-daily-digest.ts | 9 + src/lib/query-keys/index.ts | 34 ++++ 22 files changed, 487 insertions(+), 85 deletions(-) create mode 100644 src/__tests__/daily-reads-refetch-guard.test.ts create mode 100644 src/hooks/__tests__/use-measurement-reminders-refetch.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b6c7e4c7..0f14a149a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ ## [Unreleased] +## [1.32.19] — 2026-07-24 + +After you logged a dose, the Today view could keep showing it as still due +until you reloaded the page. The Today hero and the dashboard read from a cache +that refreshes on a timer, and a write made from another screen marked that +cache stale without actually re-reading it. The change reached the server, but +the screen you came back to still showed the old state for up to two minutes, +or until a hard refresh. + +- **A dose you take now clears from Today at once.** This covers the "Take all + due" button on the medications page, editing or deleting a logged dose, and + importing intake history. The Today view and the dashboard now re-read the + moment the write lands instead of waiting for the timer. +- **A preventive-care reminder marked done leaves the Today rail immediately.** + Marking one off, editing it, or deleting it updates the Today view straight + away rather than after a refresh. +- **A lab reading you add updates its cards right away.** Adding a reading + refreshes that marker's assessment on the same page and the "what changed + since your last panel" card, instead of leaving them on their earlier text. +- **A briefing you regenerate shows up on Today without a reload.** The new + briefing now reaches the Today hero and the dashboard as soon as it is ready. +- **Saving a dashboard layout keeps its safeguard against a conflicting edit.** + A visit to the home page could leave the layout editor without the token that + guards a save, which quietly turned off the protection added in v1.32.16 + against two edits overwriting each other. The editor now always loads its own + copy, so the guard stays on. + +Thanks to @lutzkind, whose run of reports surfaced the stale Today view. + +Refs #581 + ## [1.32.18] — 2026-07-24 When a Fitbit token was revoked or expired, the hourly sync noticed the failure diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6f97b1e17..61272e806 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.32.18 + version: 1.32.19 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index cb098d507..394273a58 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.32.18", + "version": "1.32.19", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 0ba82d22d..555646c48 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.32.18"; + /* @sw-version-fallback */ "v1.32.19"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/__tests__/daily-reads-refetch-guard.test.ts b/src/__tests__/daily-reads-refetch-guard.test.ts new file mode 100644 index 000000000..3bc244c37 --- /dev/null +++ b/src/__tests__/daily-reads-refetch-guard.test.ts @@ -0,0 +1,171 @@ +/** + * Structural guard on the "stale Today hero / dashboard after a write" class. + * + * The daily reads — `dailyDigest()` (Today hero) and `dashboardSnapshot()` + * (dashboard) — run with `refetchOnMount: false` and a 120 s poll. A mutation + * that invalidates one of the daily-reads-bearing bundles + * (`medicationDependentKeys` / `measurementDependentKeys` / `moodDependentKeys`) + * with the DEFAULT `refetchType: "active"` only refetches MOUNTED queries — so a + * write made from a surface where the daily reads are UNMOUNTED (the medications + * page, a detail page, the mood/measurement forms) marks them stale but never + * refetches them. Returning to `/` then serves the pre-write cache until the + * poll ticks or the tab is hard-reloaded. + * + * The blessed contract is `refetchInactiveDailyReads(queryClient)` (or its + * wrapper `invalidateMedicationReads`), which forces the two inactive reads to + * refetch. This class has recurred THREE times (v1.16.11 snapshot, v1.29.1 + * digest, v1.32.19 "Take all due") because the bundle cannot express + * `refetchType`, so every new call site re-decides it by hand. This guard makes + * a fourth recurrence fail CI. + * + * Like the Bearer-scope guard, this is a tripwire, not a proof. It cannot show + * an allowlisted exception is correct — only that no NEW site invalidates a + * daily bundle without the paired refetch, and no PAIRED site loses its refetch, + * without someone editing this file. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join, sep } from "node:path"; +import { globSync } from "node:fs"; + +const SRC = join(process.cwd(), "src"); + +/** The factory itself DEFINES the bundles + helpers; it is never a call site. */ +const FACTORY = "lib/query-keys/index.ts"; + +/** + * A direct-bundle invalidation call site: `invalidateKeys(, )` or + * `invalidateKeys(, [..., ])`. Captures every form the + * codebase actually uses (bare bundle, or spread into an array with an extra + * per-surface key). Sites routed through `invalidateMedicationReads` do NOT + * match — the wrapper carries the refetch internally, so they are compliant by + * construction and correctly invisible here. + */ +const INVALIDATION = + /invalidateKeys\(\s*[^,]+,\s*\[?\s*(?:\.\.\.)?\s*(?:medicationDependentKeys|measurementDependentKeys|moodDependentKeys)\b/g; +// Match the CALL form only (open paren), never the bare import specifier — +// otherwise the `import { refetchInactiveDailyReads }` line would inflate the +// count and let a file drop one paired call while still "passing". +const REFETCH = /refetchInactiveDailyReads\(/g; + +function sourceFiles(): string[] { + return globSync("**/*.{ts,tsx}", { cwd: SRC }) + .filter( + (p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"), + ) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + .map((p) => p.split(sep).join("/")) + .sort(); +} + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +function count(s: string, re: RegExp): number { + return (s.match(re) ?? []).length; +} + +/** + * Files that invalidate a daily bundle WITHOUT pairing the inactive refetch, + * deliberately. Frozen: a new entry is a conscious decision that must land in a + * reviewed diff, exactly like the Bearer-scope mint allowlist. Each entry is a + * surface where the missing refetch is intentional, grouped by reason. + */ +const NO_REFETCH_ALLOWLIST = [ + // The digest is ACTIVE on the dashboard where the quick-add lives, so the + // default active invalidation already refetches it (audit B, §1: "not the + // bug"). + "components/dashboard/medication-intake-quick-add.tsx", + // Injection-site metadata saved AFTER an already-recorded (and already + // refetched) take — it does not change any dose's due/taken state. + "components/medications/glp1-medication-card.tsx", + "components/medications/medication-card.tsx", + // Pull-to-refresh: a current-surface read gesture on the medications page, + // not a hero-affecting write (audit B, B-5). + "app/medications/page-client.tsx", + // Medication configuration / metadata surfaces — rename, dose, schedule + // times, phase config, push preferences, API tokens, the create wizard. + // None records an intake; a schedule edit's next-due shift converges via the + // standard poll from a deep-config surface. Tighten in a later pass if a + // config surface is shown to strand the rail. + "components/medications/scheduling/schedule-times-editor.tsx", + "components/medications/sections/api-tokens-row.tsx", + "components/medications/sections/notifications-section.tsx", + "components/medications/sections/phase-config-sheet.tsx", + "components/medications/sections/settings-section.tsx", + "components/medications/wizard/medication-wizard-dialog.tsx", + // Background integration-sync completion on the Settings surface — the user + // is configuring an integration, not acting on the hero; convergence via the + // daily reads' own poll/focus refetch is the intended behaviour. + "components/settings/integrations/fitbit-card.tsx", + "components/settings/integrations/google-health-card.tsx", + "components/settings/integrations/nightscout-card.tsx", + "components/settings/integrations/oauth-provider-card.tsx", + "components/settings/integrations/whoop-card.tsx", + "components/settings/integrations/withings-card.tsx", +].sort(); + +describe("daily-reads refetch guard", () => { + it("the blessed helper pairs the bundle invalidation with the inactive refetch", () => { + const src = read(FACTORY); + // The one canonical entry point every intake surface routes through. + expect(src).toMatch(/export async function invalidateMedicationReads\(/); + // It must do BOTH — invalidate the bundle AND force the inactive refetch. + const body = src.slice( + src.indexOf("export async function invalidateMedicationReads("), + ); + expect(body).toMatch( + /invalidateKeys\(\s*queryClient,\s*medicationDependentKeys\s*\)/, + ); + expect(body).toMatch(/refetchInactiveDailyReads\(queryClient\)/); + }); + + it("every bundle-invalidation site pairs a refetch, except the frozen allowlist", () => { + // For each file that invalidates a daily bundle, the count of paired + // refetches must be at least the count of invalidations. A count rule (not + // a line-proximity heuristic) catches BOTH a new unpaired file AND a new + // unpaired call site slipped into an already-compliant file (its refetch + // count would then fall below its invalidation count). + const nonCompliant: string[] = []; + for (const rel of sourceFiles()) { + if (rel === FACTORY) continue; + const s = read(rel); + const invalidations = count(s, INVALIDATION); + if (invalidations === 0) continue; + const refetches = count(s, REFETCH); + if (refetches < invalidations) nonCompliant.push(rel); + } + + // The set of deliberately-unpaired sites must be EXACTLY the allowlist: + // a new intake mutation that forgets the refetch appears here but not in + // the allowlist → fail; a paired site that loses its refetch drops into + // this set → fail; a deliberate new exception must be added above. + expect(nonCompliant.sort()).toEqual(NO_REFETCH_ALLOWLIST); + }); + + it("the fixed intake surfaces route through the blessed helper or pair the refetch", () => { + // Positive pin: the v1.32.19 seed sites must carry the fix, so a revert to + // a bare `invalidateKeys(medicationDependentKeys)` is caught here as well + // as by the count assertion above. + const routedThroughHelper = [ + "components/medications/take-all-due.ts", + "components/medications/intake-edit-dialog.tsx", + "components/medications/intake-history-editable.tsx", + "components/medications/intake-import-dialog.tsx", + "components/medications/sections/destructive-zone-section.tsx", + "components/medications/use-medication-intake.ts", + ]; + for (const rel of routedThroughHelper) { + expect(read(rel)).toMatch(/invalidateMedicationReads\(/); + } + const pairInPlace = [ + "components/medications/dose-history-ledger.tsx", + "components/medications/dose-history-add-dialog.tsx", + ]; + for (const rel of pairInPlace) { + expect(read(rel)).toMatch(REFETCH); + } + }); +}); diff --git a/src/components/insights/use-insights-advisor.ts b/src/components/insights/use-insights-advisor.ts index 2f11cc4c7..18e5ec147 100644 --- a/src/components/insights/use-insights-advisor.ts +++ b/src/components/insights/use-insights-advisor.ts @@ -14,7 +14,7 @@ import type { DailyBriefing as DailyBriefingPayload, TrendAnnotations, } from "@/lib/ai/schema"; -import { queryKeys } from "@/lib/query-keys"; +import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys"; import { apiFetchRaw } from "@/lib/api/api-fetch"; import type { BriefingFailureClass } from "@/lib/insights/briefing-failure-marker"; @@ -381,15 +381,19 @@ export function useInsightsAdvisorQuery( // their query subtree so the per-section text below the advisor card // re-fetches. queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); - // v1.18.10 — the dashboard hero surfaces the briefing headline from the - // SNAPSHOT (server-lifted `User.insightsCachedText`), not from this - // advisor cache. A regenerate rewrites that server cache, so invalidate - // the snapshot too — otherwise returning to the Startseite shows the - // previous briefing under the greeting until the snapshot's own stale - // window lapses. Closes "the briefing doesn't update" on the dashboard. - queryClient.invalidateQueries({ - queryKey: queryKeys.dashboardSnapshot(), - }); + // v1.18.10 / v1.32.19 — the dashboard hero surfaces the briefing + // headline from the SNAPSHOT (server-lifted `User.insightsCachedText`), + // and the Today hero's lead line is the SAME briefing lifted into the + // digest — neither is this advisor cache. A regenerate rewrites that + // server cache, so both the snapshot and the digest must refetch. + // Regenerate runs on `/insights`, where both are UNMOUNTED, so the + // earlier active-only snapshot invalidation only marked it stale (never + // refetched, `refetchOnMount: false`) and never touched the digest at + // all — returning to `/` then showed the previous briefing under the + // greeting AND in the Today lead for up to 120 s. `refetchInactiveDailyReads` + // forces both to refetch right here so the return paints the fresh + // briefing at once. + void refetchInactiveDailyReads(queryClient); }, }); diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index a86b522ea..6985618a7 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -93,7 +93,7 @@ const READINGS_PAGE_SIZE = 200; */ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { const { user } = useAuth(); - const { t } = useTranslations(); + const { t, locale } = useTranslations(); const router = useRouter(); const queryClient = useQueryClient(); // v1.24 — the per-marker description used to live on the labs overview rows. @@ -225,16 +225,32 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { const { data: assessment, isLoading: assessmentLoading } = useInsightBiomarkerAssessment(biomarkerId, readings.length > 0); + // v1.32.19 — a new reading for this marker changes the same-page AI + // assessment card (`insightsBiomarkerAssessment`, mounted here) and the + // Insights "what changed since your last panel" read (`insightsLabsChanges`). + // Both used to stay on their pre-write text until their own stale window + // lapsed; bust them alongside the result list so the assessment repaints as + // soon as the reading lands. + function invalidateAfterReadingWrite() { + queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); + queryClient.invalidateQueries({ + queryKey: queryKeys.insightsBiomarkerAssessment(biomarkerId, locale), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.insightsLabsChanges(), + }); + } + function afterAdd() { setAddOpen(false); - queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); + invalidateAfterReadingWrite(); } // v1.30.1 H3 — "Save & add another": same invalidation as `afterAdd`, // minus the close. Useful here too — logging several past readings for // this one biomarker back-to-back without re-opening the sheet each time. function afterAddKeepOpen() { - queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); + invalidateAfterReadingWrite(); } function afterEditMarker() { diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index 9bed0f065..5b547dd7a 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -108,6 +108,13 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { function invalidate() { queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); + // v1.32.19 — a lab reading edit / delete / restore shifts the two most + // recent panels the Insights "what changed since your last panel" card + // reads, so bust it too instead of leaving it on its pre-write deltas + // until the 60 s stale window lapses. + queryClient.invalidateQueries({ + queryKey: queryKeys.insightsLabsChanges(), + }); } async function restore(id: string) { diff --git a/src/components/medications/__tests__/take-all-due.test.tsx b/src/components/medications/__tests__/take-all-due.test.tsx index c63d9d7f5..2ae62400c 100644 --- a/src/components/medications/__tests__/take-all-due.test.tsx +++ b/src/components/medications/__tests__/take-all-due.test.tsx @@ -467,13 +467,45 @@ describe("runTakeAllDue — per-medication loop with failure isolation", () => { expect(vi.mocked(toast.success)).not.toHaveBeenCalledWith( expect.stringContaining("successToast"), ); - // The landed take must reach every dependent consumer. + // The landed take must reach every dependent consumer AND force the two + // inactive daily reads (dashboard snapshot + Today digest) to refetch — + // the blessed `invalidateMedicationReads` fans out the bundle (N keys) + // then invalidates those two with `refetchType: "inactive"`. expect(queryClient.invalidateQueries).toHaveBeenCalledTimes( - medicationDependentKeys.length, + medicationDependentKeys.length + 2, ); vi.unstubAllGlobals(); }); + it("forces the inactive Today hero + dashboard reads to refetch after a landed take (v1.32.19 stale-hero fix)", async () => { + const fetchMock = vi.fn().mockResolvedValue(okResponse("evt-a")); + vi.stubGlobal("fetch", fetchMock); + const queryClient = fakeQueryClient(); + + await runTakeAllDue({ + medications: [dueTwo[0]], + t, + queryClient, + }); + + // The batch button lives on `/medications`, where the digest + snapshot + // are UNMOUNTED. A bare bundle invalidation (default active refetch) would + // leave them stale — so the helper MUST invalidate both with + // `refetchType: "inactive"`, which is what actually refetches them. + const calls = vi.mocked(queryClient.invalidateQueries).mock.calls; + const inactive = calls.filter( + ([arg]) => + (arg as { refetchType?: string } | undefined)?.refetchType === + "inactive", + ); + const keys = inactive.map(([arg]) => + JSON.stringify((arg as { queryKey?: unknown }).queryKey), + ); + expect(keys).toContain(JSON.stringify(["dashboard", "snapshot"])); + expect(keys).toContain(JSON.stringify(["daily", "digest"])); + vi.unstubAllGlobals(); + }); + it("reports total failure without invalidating (nothing changed server-side)", async () => { vi.stubGlobal( "fetch", @@ -491,13 +523,15 @@ describe("runTakeAllDue — per-medication loop with failure isolation", () => { vi.unstubAllGlobals(); }); - it("invalidates exactly the medicationDependentKeys bundle (source pin)", () => { - // The bundle includes the dashboard snapshot key (v1.16.11), so a - // batch take refreshes the hero dose tally too. Pin the source so a - // refactor to a hand-rolled key list cannot slip through. + it("routes the batch invalidation through the blessed invalidateMedicationReads (source pin)", () => { + // v1.32.19 — the batch take now routes through the one blessed helper, + // which fans out `medicationDependentKeys` AND forces the inactive digest + // + snapshot to refetch (the maintainer's stale-hero repro). Pin the + // source so a revert to a bare bundle invalidation cannot slip through. const src = readFileSync(resolve(__dirname, "../take-all-due.ts"), "utf8"); - expect(src).toContain( - "await invalidateKeys(queryClient, medicationDependentKeys)", + expect(src).toContain("await invalidateMedicationReads(queryClient)"); + expect(src).not.toContain( + "invalidateKeys(queryClient, medicationDependentKeys)", ); }); }); diff --git a/src/components/medications/dose-history-add-dialog.tsx b/src/components/medications/dose-history-add-dialog.tsx index db7fc5f4c..2aa48c19f 100644 --- a/src/components/medications/dose-history-add-dialog.tsx +++ b/src/components/medications/dose-history-add-dialog.tsx @@ -28,7 +28,11 @@ import { Label } from "@/components/ui/label"; import { DateTimeField } from "@/components/ui/date-time-field"; import { Switch } from "@/components/ui/switch"; import { useTranslations } from "@/lib/i18n/context"; -import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys"; +import { + invalidateKeys, + medicationDependentKeys, + refetchInactiveDailyReads, +} from "@/lib/query-keys"; import { apiPost } from "@/lib/api/api-fetch"; import type { QueryKey } from "@tanstack/react-query"; import type { LedgerSchedule } from "@/components/medications/dose-history-ledger"; @@ -174,6 +178,10 @@ export function LedgerAddDialog({ ...medicationDependentKeys, ledgerKey, ]); + // A ledger add can land a today-dated dose, so clear the unmounted + // Today hero + dashboard too (v1.32.19 — same class as the ledger's + // own log/edit/delete sites). + await refetchInactiveDailyReads(queryClient); setNudgeOpen(false); onOpenChange(false); } catch { diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index c15a22de7..9b3724a91 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -87,6 +87,7 @@ import { invalidateKeys, medicationDependentKeys, queryKeys, + refetchInactiveDailyReads, } from "@/lib/query-keys"; import type { DoseHistoryStatus } from "@/lib/medications/scheduling/dose-history"; import { runUndoIntake } from "@/components/medications/use-medication-intake"; @@ -295,6 +296,10 @@ export function DoseHistoryLedger({ ...medicationDependentKeys, queryKey, ]); + // The ledger also invalidates its own window key above; the daily + // reads (Today hero + dashboard) are unmounted behind the detail + // page, so force their inactive refetch too (v1.32.19). + await refetchInactiveDailyReads(queryClient); } catch { if (prev) queryClient.setQueryData(queryKey, prev); toast.error( @@ -334,6 +339,10 @@ export function DoseHistoryLedger({ ...medicationDependentKeys, queryKey, ]); + // The ledger also invalidates its own window key above; the daily + // reads (Today hero + dashboard) are unmounted behind the detail + // page, so force their inactive refetch too (v1.32.19). + await refetchInactiveDailyReads(queryClient); toast.success(t("medications.detail.intake.edit.savedToast")); } catch { toast.error(t("medications.detail.intake.edit.failed")); @@ -365,6 +374,10 @@ export function DoseHistoryLedger({ ...medicationDependentKeys, queryKey, ]); + // The ledger also invalidates its own window key above; the daily + // reads (Today hero + dashboard) are unmounted behind the detail + // page, so force their inactive refetch too (v1.32.19). + await refetchInactiveDailyReads(queryClient); } catch { toast.error(t("medications.detail.intake.edit.failed")); } @@ -380,6 +393,10 @@ export function DoseHistoryLedger({ await apiDelete(`/api/medications/${medicationId}/intake/${id}`); toast.success(t("medications.detail.intake.deleteRow.toast")); await invalidateKeys(queryClient, [...medicationDependentKeys, queryKey]); + // The ledger also invalidates its own window key above; the daily + // reads (Today hero + dashboard) are unmounted behind the detail + // page, so force their inactive refetch too (v1.32.19). + await refetchInactiveDailyReads(queryClient); } catch { toast.error(t("medications.detail.intake.deleteRow.failed")); } diff --git a/src/components/medications/intake-edit-dialog.tsx b/src/components/medications/intake-edit-dialog.tsx index 9f542e273..f60d3d623 100644 --- a/src/components/medications/intake-edit-dialog.tsx +++ b/src/components/medications/intake-edit-dialog.tsx @@ -32,7 +32,7 @@ import { DateTimeField } from "@/components/ui/date-time-field"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; -import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys"; +import { invalidateMedicationReads } from "@/lib/query-keys"; import { apiPut } from "@/lib/api/api-fetch"; export interface IntakeEditDialogProps { @@ -129,7 +129,10 @@ function IntakeEditDialogBody({ body.takenAt = null; } await apiPut(`/api/medications/${medicationId}/intake/${event.id}`, body); - await invalidateKeys(queryClient, medicationDependentKeys); + // Editing today's dose (un-skip, retime) changes its due/taken state — + // route through the blessed helper so the Today hero + dashboard clear + // at once even when they are unmounted behind the detail page. + await invalidateMedicationReads(queryClient); toast.success(t("medications.detail.intake.edit.savedToast")); onClose(); } catch { diff --git a/src/components/medications/intake-history-editable.tsx b/src/components/medications/intake-history-editable.tsx index 87e6d852a..f1c51ac47 100644 --- a/src/components/medications/intake-history-editable.tsx +++ b/src/components/medications/intake-history-editable.tsx @@ -40,7 +40,7 @@ import { type IntakeHistorySortKey, } from "@/components/medications/intake-history-list-v2"; import { useTranslations } from "@/lib/i18n/context"; -import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys"; +import { invalidateMedicationReads } from "@/lib/query-keys"; import { apiDelete, apiPost } from "@/lib/api/api-fetch"; export interface IntakeHistoryEditableProps { @@ -94,7 +94,7 @@ export function IntakeHistoryEditable({ await apiPost(`/api/medications/${medicationId}/intake/bulk-delete`, { eventIds: ids, }); - await invalidateKeys(queryClient, medicationDependentKeys); + await invalidateMedicationReads(queryClient); toast.success(t("medications.detail.intake.bulkDelete.toast")); setBulkConfirmOpen(false); clearSelection(); @@ -111,7 +111,7 @@ export function IntakeHistoryEditable({ const id = pendingDeleteId; try { await apiDelete(`/api/medications/${medicationId}/intake/${id}`); - await invalidateKeys(queryClient, medicationDependentKeys); + await invalidateMedicationReads(queryClient); toast.success(t("medications.detail.intake.deleteRow.toast")); setPendingDeleteId(null); // Drop the deleted row from the pending selection so the diff --git a/src/components/medications/intake-import-dialog.tsx b/src/components/medications/intake-import-dialog.tsx index f87d4694f..6a201bb2b 100644 --- a/src/components/medications/intake-import-dialog.tsx +++ b/src/components/medications/intake-import-dialog.tsx @@ -32,7 +32,7 @@ import { import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { useTranslations } from "@/lib/i18n/context"; -import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys"; +import { invalidateMedicationReads } from "@/lib/query-keys"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; import { MedicationIntakeImportError, @@ -124,7 +124,10 @@ export function IntakeImportDialog({ : ""), ); setResultType("success"); - void invalidateKeys(queryClient, medicationDependentKeys); + // An import can land today-dated doses, so clear the Today hero + + // dashboard through the blessed helper (they are unmounted behind the + // import dialog on the detail page). + void invalidateMedicationReads(queryClient); } catch (err) { if (err instanceof MedicationIntakeImportError) { setResult(t("medications.importFailed")); diff --git a/src/components/medications/sections/destructive-zone-section.tsx b/src/components/medications/sections/destructive-zone-section.tsx index c3ff9877f..4240c3346 100644 --- a/src/components/medications/sections/destructive-zone-section.tsx +++ b/src/components/medications/sections/destructive-zone-section.tsx @@ -46,11 +46,7 @@ import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; import { useTranslations } from "@/lib/i18n/context"; -import { - invalidateKeys, - medicationDependentKeys, - queryKeys, -} from "@/lib/query-keys"; +import { invalidateMedicationReads, queryKeys } from "@/lib/query-keys"; import { apiDelete, apiPut } from "@/lib/api/api-fetch"; const PAUSE_SWITCH_ID = "medication-detail-pause-switch"; @@ -89,7 +85,7 @@ export function LifecycleManageBody({ // v1.5.5 C-E3-2 — body carries `{ active }` only; server derives // pausedAt. No client-side pausedAt literal. await apiPut(`/api/medications/${medicationId}`, { active: !pauseNext }); - await invalidateKeys(queryClient, medicationDependentKeys); + await invalidateMedicationReads(queryClient); toast.success( pauseNext ? t("medications.detail.zone.pause.pausedToast") @@ -110,7 +106,7 @@ export function LifecycleManageBody({ try { const today = new Date().toISOString(); await apiPut(`/api/medications/${medicationId}`, { endsOn: today }); - await invalidateKeys(queryClient, medicationDependentKeys); + await invalidateMedicationReads(queryClient); toast.success(t("medications.detail.zone.end.toast")); setEndDialogOpen(false); onAfterAction?.(); @@ -246,7 +242,7 @@ export function DangerZoneBody({ setTier3aBusy(true); try { await apiDelete(`/api/medications/${medicationId}/intake/purge`); - await invalidateKeys(queryClient, medicationDependentKeys); + await invalidateMedicationReads(queryClient); toast.success(t("medications.detail.zone.purge.toast")); setPurgeDialogOpen(false); onAfterAction?.(); @@ -281,7 +277,7 @@ export function DangerZoneBody({ // Fire-and-forget the dependent-bundle invalidation (list / // analytics / compliance / inline chart). Not awaited: navigation // already happened and a transient refetch must not block it. - void invalidateKeys(queryClient, medicationDependentKeys); + void invalidateMedicationReads(queryClient); } catch { toast.error(t("medications.detail.zone.delete.failed")); setTier3bBusy(false); diff --git a/src/components/medications/take-all-due.ts b/src/components/medications/take-all-due.ts index 800625e4b..72ac3bc5f 100644 --- a/src/components/medications/take-all-due.ts +++ b/src/components/medications/take-all-due.ts @@ -39,7 +39,7 @@ import type { QueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { apiPost } from "@/lib/api/api-fetch"; -import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys"; +import { invalidateMedicationReads } from "@/lib/query-keys"; import { reduceCurrentWindowStatus, type ScheduleWindowInput, @@ -230,12 +230,17 @@ export async function runTakeAllDue(deps: { } } - // One invalidation for the whole batch (the bundle includes the - // dashboard snapshot key, so the hero dose tally refreshes too). A - // zero-success run changed nothing server-side — skip the refetch and - // keep the due set as-is for the retry. + // One invalidation for the whole batch through the blessed helper: it + // fans out the dependent-key bundle AND forces the inactive digest + + // snapshot to refetch. The batch button lives on `/medications`, where the + // Today hero and dashboard are UNMOUNTED, so a bare bundle invalidation + // (default `refetchType: "active"`) marked them stale but never refetched — + // the taken doses stayed "due" on the hero until the 120 s poll or a hard + // reload (the maintainer's exact repro, v1.32.19). A zero-success run + // changed nothing server-side — skip the refetch and keep the due set for + // the retry. if (taken > 0) { - await invalidateKeys(queryClient, medicationDependentKeys); + await invalidateMedicationReads(queryClient); } if (failed === 0) { diff --git a/src/components/medications/use-medication-intake.ts b/src/components/medications/use-medication-intake.ts index f3f96a110..b142b7afc 100644 --- a/src/components/medications/use-medication-intake.ts +++ b/src/components/medications/use-medication-intake.ts @@ -5,44 +5,18 @@ import { useQueryClient, type QueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { useTranslations } from "@/lib/i18n/context"; -import { - invalidateKeys, - medicationDependentKeys, - queryKeys, -} from "@/lib/query-keys"; +import { invalidateMedicationReads } from "@/lib/query-keys"; import { apiDelete, apiPost } from "@/lib/api/api-fetch"; /** - * Invalidate every read that reflects a dose's taken/due state after an - * intake write. `invalidateKeys` invalidates with the default - * `refetchType: "active"`, which only refetches mounted queries — so the - * dashboard snapshot AND the Today digest, both inactive while the user is on - * the medication card or detail page, are marked stale but never refetched. On - * navigating back each remounts under `refetchOnMount: false`, so the pre-write - * cache is served and the "due" prompt (snapshot dose tally) plus the digest's - * dose-window rail item both linger until a hard reload. Force the inactive - * queries to refetch so the dashboard clears as soon as the dose is recorded. - * - * v1.29.1 — the digest joins the forced-inactive refetch. Its `medsToday` - * reads the SAME server snapshot cell the intake route already hard-evicts, so - * the refetch returns post-write dose state immediately (no server change - * needed — the eviction seam is reused). + * Every intake write in this hook clears the Today hero + dashboard through + * the one blessed `invalidateMedicationReads` (in `@/lib/query-keys`): it + * pairs the `medicationDependentKeys` bundle invalidation with a forced + * inactive refetch of the digest + snapshot, so a taken / skipped / undone + * dose repaints the moment it lands rather than on the 120 s poll. The helper + * used to live inline here (v1.29.1); v1.32.19 lifted it into the factory as + * the single canonical entry point every intake surface routes through. */ -async function invalidateMedicationReads( - queryClient: QueryClient, -): Promise { - await invalidateKeys(queryClient, medicationDependentKeys); - await Promise.all([ - queryClient.invalidateQueries({ - queryKey: queryKeys.dashboardSnapshot(), - refetchType: "inactive", - }), - queryClient.invalidateQueries({ - queryKey: queryKeys.dailyDigest(), - refetchType: "inactive", - }), - ]); -} type Translator = ( key: string, diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 99f9a80d7..a1a80d49a 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -238,6 +238,21 @@ export function DashboardLayoutSection({ id }: { id: string }) { queryFn: async () => { return apiGet("/api/dashboard/widgets"); }, + // v1.32.19 (B-3, belt for the #581 fix) — the home RSC seeds + // `dashboardWidgets()` from the dashboard SNAPSHOT layout, which carries + // NO `updatedAt` optimistic-concurrency token (it is a token-less + // `DashboardLayout`, not the `DashboardLayoutWithToken` this section's GET + // returns). Within the global 5-min `staleTime` a visit to `/` then Settings + // read that seeded token-less value with no mount refetch, so `readBaseToken()` + // returned undefined, the Save sent no `baseUpdatedAt`, and the route's + // compat fallback silently reverted to last-write-wins — disabling the + // just-shipped 409 belt. Force a mount refetch so this section always holds + // its own tokened GET response and every write carries the base token. The + // smallest correct option: the alternatives (a distinct seed key, or + // threading the token through the snapshot layout) touch the home client and + // the server snapshot shape; this is one line, config-independent, and makes + // the settings query authoritative for its own tokened read. + refetchOnMount: "always", }); /** diff --git a/src/hooks/__tests__/use-measurement-reminders-refetch.test.ts b/src/hooks/__tests__/use-measurement-reminders-refetch.test.ts new file mode 100644 index 000000000..56f32ef2f --- /dev/null +++ b/src/hooks/__tests__/use-measurement-reminders-refetch.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi } from "vitest"; +import type { QueryClient } from "@tanstack/react-query"; + +import { invalidateReminderReads } from "@/hooks/use-measurement-reminders"; + +/** + * v1.32.19 — a Vorsorge reminder marked done (or created / edited / deleted) + * is the exact Hero-dose analog on the Today rail. Its mutations run + * `invalidateReminderReads`, which must NOT only invalidate the reminders root + * (that leaves the digest + dashboard snapshot stale-but-unrefetched while the + * user is on the Vorsorge section) but ALSO force those two inactive daily + * reads to refetch — otherwise the reminder lingers on the Today rail until + * the 120 s poll. This pins the actual refetch behaviour, not just an + * invalidation. + */ +function fakeQueryClient(): QueryClient { + return { + invalidateQueries: vi.fn().mockResolvedValue(undefined), + } as unknown as QueryClient; +} + +describe("invalidateReminderReads — the Vorsorge daily-rail freshness contract", () => { + it("invalidates the reminders root AND forces the inactive daily reads to refetch", async () => { + const qc = fakeQueryClient(); + + await invalidateReminderReads(qc); + + const calls = vi.mocked(qc.invalidateQueries).mock.calls; + + // The reminders root (drives the section list + the dashboard tile). + const reminderKeys = calls.map(([arg]) => + JSON.stringify((arg as { queryKey?: unknown }).queryKey), + ); + expect(reminderKeys).toContain(JSON.stringify(["measurement-reminders"])); + + // The two daily reads must be invalidated with `refetchType: "inactive"` — + // the ONLY variant that refetches them while unmounted on the Vorsorge + // surface. A plain (active) invalidation would repeat the stale-hero bug. + const inactive = calls.filter( + ([arg]) => + (arg as { refetchType?: string } | undefined)?.refetchType === + "inactive", + ); + const inactiveKeys = inactive.map(([arg]) => + JSON.stringify((arg as { queryKey?: unknown }).queryKey), + ); + expect(inactiveKeys).toContain(JSON.stringify(["dashboard", "snapshot"])); + expect(inactiveKeys).toContain(JSON.stringify(["daily", "digest"])); + }); +}); diff --git a/src/hooks/use-measurement-reminders.ts b/src/hooks/use-measurement-reminders.ts index 21998a28c..de2c35f8c 100644 --- a/src/hooks/use-measurement-reminders.ts +++ b/src/hooks/use-measurement-reminders.ts @@ -9,10 +9,36 @@ * tile repaint in lockstep. The DTO is the server-authoritative shape — * the client renders `nextDueAt` as-is, never recomputing. */ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useMutation, + useQuery, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query"; import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; -import { queryKeys } from "@/lib/query-keys"; +import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys"; + +/** + * v1.32.19 — the read fan-out every Vorsorge reminder mutation runs on + * success. Extracted (and exported) so it is unit-testable against a real + * `QueryClient` without a React render, mirroring the medication intake + * helpers. A reminder is the exact Hero-dose analog on the Today rail: + * creating, editing, deleting or marking one done changes what the digest + * surfaces as due. Marking it done from the section (or the tile) leaves the + * digest + dashboard snapshot UNMOUNTED, so invalidating only the reminders + * root marked the daily reads stale but never refetched them + * (`refetchOnMount: false`) — the reminder lingered on the Today rail until + * the 120 s poll. Force the inactive daily reads to refetch alongside the + * reminders invalidation so the rail clears at once, the same freshness + * contract the medication intake surfaces use. + */ +export function invalidateReminderReads(qc: QueryClient): Promise { + return Promise.all([ + qc.invalidateQueries({ queryKey: queryKeys.measurementReminders() }), + refetchInactiveDailyReads(qc), + ]); +} export interface MeasurementReminder { id: string; @@ -68,8 +94,7 @@ export function useMeasurementReminders(enabled = true) { export function useMeasurementReminderMutations() { const qc = useQueryClient(); - const invalidate = () => - qc.invalidateQueries({ queryKey: queryKeys.measurementReminders() }); + const invalidate = () => invalidateReminderReads(qc); const create = useMutation({ mutationKey: queryKeys.measurementReminderCreate(), diff --git a/src/lib/queries/use-daily-digest.ts b/src/lib/queries/use-daily-digest.ts index d6384d3bf..dc573a41f 100644 --- a/src/lib/queries/use-daily-digest.ts +++ b/src/lib/queries/use-daily-digest.ts @@ -43,6 +43,15 @@ export function useDailyDigest(enabled = true) { staleTime: 60_000, refetchOnMount: false, refetchOnWindowFocus: "always", + // B-4 (INTENDED, not a bug): a dose logged on ANOTHER device (iOS, a + // Telegram intake, a background sync) has no web invalidation channel, so + // an open web tab converges on it only via this 120 s foreground poll or + // the window-focus "always" refetch above — a bounded ≤120 s window on an + // idle tab. This is deliberate and is NOT the v1.32.19 stale-hero class, + // which was an in-tab write on the SAME device that marked these reads + // stale without refetching them (fixed via `invalidateMedicationReads` / + // `refetchInactiveDailyReads`). Do not conflate the two: a same-device + // action must clear the hero at once; a cross-device one may lag the poll. refetchInterval: DASHBOARD_REFETCH_INTERVAL_MS, refetchIntervalInBackground: false, // One retry on transient network / 5xx (never 401/403) so a single diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 4912ee132..572f3fee6 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -253,3 +253,37 @@ export async function refetchInactiveDailyReads( }), ]); } + +/** + * The ONE blessed entry point for any intake-affecting medication write. It + * pairs the dependent-key bundle invalidation with the forced inactive + * refetch so a taken / skipped / edited / deleted / imported dose clears the + * Today hero and the dashboard the instant it is recorded — never after the + * 120 s poll or a hard reload. + * + * Route EVERY medication mutation through this instead of calling + * `invalidateKeys(queryClient, medicationDependentKeys)` directly. The bundle + * cannot express `refetchType`, so a bare invalidation only refetches MOUNTED + * queries; the digest and snapshot are typically unmounted while the user is + * on the medications page (the "Take all due" repro) or a detail page, so + * they were marked stale but never refetched. This helper closes that gap in + * one call. The class it closes — a bundle invalidation that never refetches + * the unmounted daily reads — has recurred three times (v1.16.11, v1.29.1, + * v1.32.19); `daily-reads-refetch-guard.test.ts` now fails CI if a new site + * invalidates the bundle without the paired refetch. + * + * The server intake routes already hard-evict the `${userId}|` analytics + * prefix, so the refetch this triggers returns post-write data immediately — + * no server change is needed at any call site. + * + * Sites that need to invalidate an EXTRA key alongside the bundle (the + * dose-history ledger's own window key) keep their `invalidateKeys([...bundle, + * localKey])` call and add `refetchInactiveDailyReads(queryClient)` directly; + * the guard accepts either form. + */ +export async function invalidateMedicationReads( + queryClient: QueryClient, +): Promise { + await invalidateKeys(queryClient, medicationDependentKeys); + await refetchInactiveDailyReads(queryClient); +}