From e991ef7c774739655c11ef12f3bf2ef152d3fecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 13 Aug 2026 09:14:55 +0200 Subject: [PATCH] Refetch the inactive daily reads after a quick-add intake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The medication quick-add mounts from the bottom-nav capture picker on every page, not only on the dashboard, so its bare dependent-key bundle invalidation (default active refetch) left the unmounted Today digest and dashboard snapshot stale until the next poll — back on the home screen the dose still read as due for up to two minutes. Route the submit through invalidateMedicationReads, the one blessed entry point that pairs the bundle with the forced inactive refetch, and drop the surface's allowlist entry in the refetch guard: its dashboard-only rationale stopped being true when the capture picker started mounting this form globally. The other capture-picker forms (measurement, mood) already pair the refetch. --- .../daily-reads-refetch-guard.test.ts | 4 -- .../medication-intake-quick-add.test.tsx | 63 +++++++++++++++++++ .../dashboard/medication-intake-quick-add.tsx | 15 +++-- 3 files changed, 70 insertions(+), 12 deletions(-) diff --git a/src/__tests__/daily-reads-refetch-guard.test.ts b/src/__tests__/daily-reads-refetch-guard.test.ts index a466e4f23..8631c1c2d 100644 --- a/src/__tests__/daily-reads-refetch-guard.test.ts +++ b/src/__tests__/daily-reads-refetch-guard.test.ts @@ -72,10 +72,6 @@ function count(s: string, re: RegExp): number { * 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", diff --git a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx index 207c0865e..c953ec8e1 100644 --- a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx +++ b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx @@ -22,9 +22,17 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { renderToStaticMarkup } from "react-dom/server"; +import type { QueryClient as QueryClientType } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; +import { + invalidateMedicationReads, + medicationDependentKeys, + queryKeys, +} from "@/lib/query-keys"; // Stub @tanstack/react-query so the SSR render path doesn't try to // reach into a real QueryClient. The default-stub returns the @@ -319,3 +327,58 @@ describe(" — SSR contract", () => { expect(de).toContain("Medikamente angelegt"); }); }); + +describe("submit invalidation — the quick-add is a capture-picker surface, not only a dashboard one", () => { + // This form mounts from the bottom-nav capture picker on EVERY page, so a + // bare `invalidateKeys(medicationDependentKeys)` (default active refetch) + // left the unmounted Today digest + dashboard snapshot stale until the next + // poll — the fourth recurrence of the class `daily-reads-refetch-guard` + // freezes. The fix routes the submit through the blessed + // `invalidateMedicationReads`, exactly like take-all-due (v1.32.19). + + const componentSrc = readFileSync( + resolve(__dirname, "../medication-intake-quick-add.tsx"), + "utf8", + ); + + it("routes the intake submit through the blessed invalidateMedicationReads (source pin)", () => { + expect(componentSrc).toContain( + "await invalidateMedicationReads(queryClient);", + ); + expect(componentSrc).not.toContain( + "invalidateKeys(queryClient, medicationDependentKeys)", + ); + // The submit handler pairs the blessed helper with the inline + // compliance-chart fan-out AFTER the intake POST landed. + expect(componentSrc).toMatch( + /async function handleSubmit\([\s\S]*?apiPost[\s\S]*?await invalidateMedicationReads\(queryClient\);[\s\S]*?complianceChartInline\(\)/, + ); + }); + + it("forces the inactive Today hero + dashboard reads to refetch after submit", async () => { + // The helper the submit path awaits — asserted directly, same shape as + // the take-all-due suite: the bundle fans out (N keys), then the two + // daily reads are invalidated with `refetchType: "inactive"`, which is + // what actually refetches them while unmounted. + const invalidateQueries = vi.fn().mockResolvedValue(undefined); + const queryClient = { + invalidateQueries, + } as unknown as QueryClientType; + + await invalidateMedicationReads(queryClient); + + expect(invalidateQueries).toHaveBeenCalledTimes( + medicationDependentKeys.length + 2, + ); + const inactive = invalidateQueries.mock.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(queryKeys.dashboardSnapshot())); + expect(keys).toContain(JSON.stringify(queryKeys.dailyDigest())); + }); +}); diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx index 7febbf9e6..dcc22cbd1 100644 --- a/src/components/dashboard/medication-intake-quick-add.tsx +++ b/src/components/dashboard/medication-intake-quick-add.tsx @@ -18,11 +18,7 @@ import { Loader2, Pill, Plus } from "lucide-react"; import Link from "next/link"; import { toast } from "sonner"; import { useTranslations } from "@/lib/i18n/context"; -import { - invalidateKeys, - medicationDependentKeys, - queryKeys, -} from "@/lib/query-keys"; +import { invalidateMedicationReads, queryKeys } from "@/lib/query-keys"; import type { ScheduleWindowInput } from "@/lib/medications/window-status"; import { resolveMedicationSelectionId, @@ -61,8 +57,11 @@ import { * 3. Time taken. Defaults to `now` (local datetime-local format * shaped the same as `MoodForm`). * - * On success: invalidate `medicationDependentKeys` (medications + - * analytics + insights + achievements) PLUS the + * On success: `invalidateMedicationReads` (the bundle invalidation + * paired with the forced inactive refetch of the Today digest and the + * dashboard snapshot — this form also mounts from the capture picker on + * every page, where both daily reads are unmounted and a bare bundle + * invalidation would leave them stale until the next poll) PLUS the * inline per-medication compliance chart key so the detail page tile * refreshes if it happens to be mounted elsewhere in the app. * @@ -219,7 +218,7 @@ export function MedicationIntakeQuickAdd({ ); const eventId = created?.id; - await invalidateKeys(queryClient, medicationDependentKeys); + await invalidateMedicationReads(queryClient); // Fan-out to every inline compliance chart key (one per medication) // so the detail-page tile refreshes when the user re-enters it. await queryClient.invalidateQueries({