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({