Refetch the inactive daily reads after a quick-add intake

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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-13 09:14:55 +02:00
1 parent 767a2b039b
commit e991ef7c77
3 files changed
+70 -12

No files matched your search

@@ -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",
@@ -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("<MedicationIntakeQuickAdd> — 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()));
});
});
@@ -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({