From f2f40ae8b27e218ffcfaa989758644a2d870d83a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 13 Aug 2026 18:48:10 +0200 Subject: [PATCH] Pair every daily-read invalidation with its inactive refetch Ten client mutation sites forgot a consumer: a coach-accepted checkup stayed invisible for up to five minutes, a threshold or target edit left the dashboard bands stale, a score-config save marked the daily reads stale but never refetched them, a booked visit missed the Today rail, a source-priority reorder kept every chart on the old canonical picks, an adopted self-context note never reached the Settings read. Each site now invalidates what its write changed and forces the unmounted snapshot + digest reads to refetch through refetchInactiveDailyReads. New illnessDependentKeys bundle carries the daily reads (Rest Mode frames both), dailyDigest joins encounterDependentKeys, and the chart-data prefix gains a factory accessor for whole-cache flushes. The daily-reads guard learns the cycle / encounter / vaccination / illness bundle families and a second matcher for direct snapshot/digest invalidateQueries without a forced refetch, each with a frozen reasoned allowlist. Both matchers were proven live by reverting a fix and watching the guard go red (see the test doc comment). --- .../daily-reads-refetch-guard.test.ts | 87 ++++++++++++++++++- src/components/illness/use-illness.ts | 16 +++- .../__tests__/chat-bubble-cancelled.test.tsx | 3 + .../__tests__/message-thread.test.tsx | 3 + .../insights/coach-panel/chat-bubble.tsx | 11 ++- .../coach-panel/reminder-suggestion-card.tsx | 10 ++- .../coach-panel/self-context-adopt-offer.tsx | 11 ++- .../coach-panel/suggested-action-card.tsx | 17 +++- .../mental-health/mental-wellbeing.tsx | 11 ++- src/components/settings/score-section.tsx | 12 +-- src/components/settings/sources-section.tsx | 18 +++- .../settings/thresholds-editor-section.tsx | 7 +- src/components/targets/target-edit-sheet.tsx | 7 +- .../vaccinations/use-vaccinations.ts | 10 ++- src/hooks/use-practitioners.ts | 10 ++- src/lib/query-keys/index.ts | 22 +++++ src/lib/query-keys/measurements.ts | 8 ++ 17 files changed, 239 insertions(+), 24 deletions(-) diff --git a/src/__tests__/daily-reads-refetch-guard.test.ts b/src/__tests__/daily-reads-refetch-guard.test.ts index 8631c1c2d..3dda6b7c3 100644 --- a/src/__tests__/daily-reads-refetch-guard.test.ts +++ b/src/__tests__/daily-reads-refetch-guard.test.ts @@ -22,6 +22,14 @@ * 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. + * + * Both matcher families were proven live by breaking a real fix and watching + * the guard go red: removing the `refetchInactiveDailyReads` pairing from the + * illness invalidator (`components/illness/use-illness.ts`) failed the bundle + * assertion, and reverting the score-config save + * (`components/settings/score-section.tsx`) to its former bare + * snapshot/digest `invalidateQueries` pair failed the direct-invalidation + * assertion. Both fixes were then restored and the guard re-ran green. */ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; @@ -41,13 +49,38 @@ const FACTORY = "lib/query-keys/index.ts"; * 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. + * + * The cycle / encounter / vaccination / illness families joined the matcher + * alongside the original three: the encounter and illness bundles now carry a + * daily-read key directly, the vaccination bundle's reminder root feeds the + * Today rail, and cycle rides along so a future daily-read key added to its + * bundle cannot ship an unpaired site silently (its current sites are + * allowlisted below with the reason). */ const INVALIDATION = - /invalidateKeys\(\s*[^,]+,\s*\[?\s*(?:\.\.\.)?\s*(?:medicationDependentKeys|measurementDependentKeys|moodDependentKeys)\b/g; + /invalidateKeys\(\s*[^,]+,\s*\[?\s*(?:\.\.\.)?\s*(?:medicationDependentKeys|measurementDependentKeys|moodDependentKeys|cycleDependentKeys|encounterDependentKeys|vaccinationDependentKeys|illnessDependentKeys)\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; +// `invalidateReminderReads` counts as a paired refetch: it carries +// `refetchInactiveDailyReads` internally (see `hooks/use-measurement-reminders`), +// and the encounter / vaccination mutation sites pair through it. +const REFETCH = /(?:refetchInactiveDailyReads|invalidateReminderReads)\(/g; + +/** + * Second matcher family — a DIRECT `invalidateQueries` on a daily-read key. + * Captures `.invalidateQueries({ queryKey: queryKeys.dashboardSnapshot() })` + * / `dailyDigest()` in every observed shape (single line, prettier-broken, + * with or without a `refetchType`). Group 1 carries the `refetchType` value + * when present: `"inactive"` and `"all"` both force the unmounted daily reads + * to refetch and are compliant; a bare call (or `"active"`) marks the + * typically-unmounted daily reads stale without refetching them — exactly the + * v1.16.11/v1.29.1/v1.32.19 class, one call site at a time instead of via a + * bundle. Known limit (mirrors the bundle matcher): a call spelling the key + * through a local alias rather than `queryKeys.()` would slip past. + */ +const DIRECT_DAILY_INVALIDATION = + /\.invalidateQueries\(\s*\{\s*queryKey:\s*queryKeys\.(?:dashboardSnapshot|dailyDigest)\(\)\s*,?\s*(?:refetchType:\s*"(\w+)"\s*,?\s*)?\}\s*,?\s*\)/g; function sourceFiles(): string[] { return walkSourceFiles(SRC, { floor: 3000 }) @@ -99,6 +132,29 @@ const NO_REFETCH_ALLOWLIST = [ "components/settings/integrations/oauth-provider-card.tsx", "components/settings/integrations/whoop-card.tsx", "components/settings/integrations/withings-card.tsx", + // Cycle writes: `cycleDependentKeys` carries no daily-read key and neither + // the dashboard snapshot nor the Today digest reads cycle rows today, so + // the forced refetch would be a no-op round-trip. The bundle is in the + // matcher so the day a daily-read key joins it, these sites surface here + // and must take the pairing. + "components/cycle/use-cycle.ts", +].sort(); + +/** + * Files allowed a DIRECT daily-read `invalidateQueries` without an inactive + * refetch, deliberately. Frozen like the bundle allowlist above. + */ +const DIRECT_NO_REFETCH_ALLOWLIST = [ + // Background sync completion on the Settings surface — same reason as the + // integration cards in the bundle allowlist: the user is configuring, not + // acting on the hero; poll/focus convergence is intended. + "components/settings/integrations/google-health-card.tsx", + // The check-in card and the priority-item dismissal both render INSIDE the + // Today digest, so the digest query is mounted and the default + // `refetchType: "active"` refetches it immediately — the inactive arm has + // nothing to cover. + "hooks/use-coach-checkin.ts", + "hooks/use-priority-item-dismiss.ts", ].sort(); describe("daily-reads refetch guard", () => { @@ -146,6 +202,33 @@ describe("daily-reads refetch guard", () => { expect(nonCompliant.sort()).toEqual(NO_REFETCH_ALLOWLIST); }); + it("every direct daily-read invalidation forces a refetch, except the frozen allowlist", () => { + // A direct `invalidateQueries(dashboardSnapshot|dailyDigest)` is the + // single-key form of the same class the bundle matcher pins. Compliant + // forms: `refetchType: "inactive"` / `"all"` on the call itself, or a + // paired `refetchInactiveDailyReads` elsewhere in the file (then the + // direct call is the mounted-arm half of an explicit pair). + const nonCompliant: string[] = []; + let totalMatches = 0; + for (const rel of sourceFiles()) { + if (rel === FACTORY) continue; + const s = read(rel); + let bare = 0; + for (const m of s.matchAll(DIRECT_DAILY_INVALIDATION)) { + totalMatches++; + const refetchType = m[1]; + if (refetchType !== "inactive" && refetchType !== "all") bare++; + } + if (bare > 0 && count(s, REFETCH) === 0) nonCompliant.push(rel); + } + // The matcher must be alive: the tree has known compliant call sites + // (the dashboard-layout section's `refetchType: "all"` pair among them). + // Zero matches would mean the regex rotted, not that the class is gone — + // the lesson of the 2026-08-01 bearer-guard blind spot. + expect(totalMatches).toBeGreaterThan(3); + expect(nonCompliant.sort()).toEqual(DIRECT_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 diff --git a/src/components/illness/use-illness.ts b/src/components/illness/use-illness.ts index 4bd5dd642..71858888b 100644 --- a/src/components/illness/use-illness.ts +++ b/src/components/illness/use-illness.ts @@ -11,7 +11,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; -import { queryKeys } from "@/lib/query-keys"; +import { + illnessDependentKeys, + invalidateKeys, + queryKeys, + refetchInactiveDailyReads, +} from "@/lib/query-keys"; import type { IllnessCorrelationResponse, @@ -119,7 +124,14 @@ export function useIllnessDayLogList( function useInvalidateIllness() { const qc = useQueryClient(); - return () => qc.invalidateQueries({ queryKey: queryKeys.illness() }); + // An episode write can flip Rest Mode, which frames the dashboard hero and + // the Today digest — both unmounted from here, so the bundle invalidation + // is paired with the forced inactive refetch. The write routes hard-evict + // the server analytics bucket, so the refetch returns post-write context. + return async () => { + await invalidateKeys(qc, illnessDependentKeys); + await refetchInactiveDailyReads(qc); + }; } /** Create an episode. */ diff --git a/src/components/insights/coach-panel/__tests__/chat-bubble-cancelled.test.tsx b/src/components/insights/coach-panel/__tests__/chat-bubble-cancelled.test.tsx index 0d3db1a7e..5b4566086 100644 --- a/src/components/insights/coach-panel/__tests__/chat-bubble-cancelled.test.tsx +++ b/src/components/insights/coach-panel/__tests__/chat-bubble-cancelled.test.tsx @@ -19,6 +19,9 @@ vi.mock("@/hooks/use-auth", () => ({ vi.mock("@tanstack/react-query", () => ({ useQuery: () => ({ data: undefined }), useMutation: () => ({ mutate: vi.fn(), isPending: false, isError: false }), + // The remember-message control resolves the client for its about-me + // invalidation; the marker tests never trigger it. + useQueryClient: () => ({ invalidateQueries: vi.fn() }), })); import { MessageThread } from "../message-thread"; diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index 7f9427eb0..94498638a 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -52,6 +52,9 @@ vi.mock("@tanstack/react-query", () => ({ isPending: false, isError: false, }), + // The remember-message control resolves the client for its about-me + // invalidation; these render tests never trigger it. + useQueryClient: () => ({ invalidateQueries: vi.fn() }), })); import { diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx index 475c537e9..5651c244c 100644 --- a/src/components/insights/coach-panel/chat-bubble.tsx +++ b/src/components/insights/coach-panel/chat-bubble.tsx @@ -2,7 +2,7 @@ import { memo, useCallback, useEffect, useId, useRef, useState } from "react"; import Link from "next/link"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { BookmarkPlus, @@ -24,6 +24,7 @@ import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; import { useAuth } from "@/hooks/use-auth"; import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me"; import { @@ -944,6 +945,7 @@ ChatBubble.displayName = "ChatBubble"; */ function RememberUserMessage({ content }: { content: string }) { const { t } = useTranslations(); + const queryClient = useQueryClient(); const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null); // On settle the button unmounts while it holds focus, which would // drop keyboard focus to . The confirmation paragraph takes @@ -961,6 +963,13 @@ function RememberUserMessage({ content }: { content: string }) { }); }, onSuccess: (data) => { + if (data.adopted) { + // The adoption changed the stored self-context — refresh the + // Settings → AI about-me read so it shows the new entry. + void queryClient.invalidateQueries({ + queryKey: queryKeys.coachAboutMe(), + }); + } setSettled(data.adopted ? "adopted" : "duplicate"); }, onError: () => { diff --git a/src/components/insights/coach-panel/reminder-suggestion-card.tsx b/src/components/insights/coach-panel/reminder-suggestion-card.tsx index e182e4941..e0fb697cd 100644 --- a/src/components/insights/coach-panel/reminder-suggestion-card.tsx +++ b/src/components/insights/coach-panel/reminder-suggestion-card.tsx @@ -1,13 +1,14 @@ "use client"; import { useState } from "react"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { BellPlus, Check, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { apiPost } from "@/lib/api/api-fetch"; +import { invalidateReminderReads } from "@/hooks/use-measurement-reminders"; import type { CoachSuggestion } from "@/lib/ai/coach/types"; /** @@ -31,6 +32,7 @@ export function ReminderSuggestionCard({ suggestion: CoachSuggestion; }) { const { t } = useTranslations(); + const queryClient = useQueryClient(); const [settled, setSettled] = useState< "accepted" | "dismissed" | "stopped" | null >(null); @@ -44,6 +46,12 @@ export function ReminderSuggestionCard({ return action; }, onSuccess: (action) => { + if (action === "accept") { + // The accept minted a `MeasurementReminder` — refresh the checkups + // list and the Today rail so it does not stay invisible until the + // next poll. + void invalidateReminderReads(queryClient); + } setSettled( action === "accept" ? "accepted" diff --git a/src/components/insights/coach-panel/self-context-adopt-offer.tsx b/src/components/insights/coach-panel/self-context-adopt-offer.tsx index 753f5ea64..c5f0aa7fb 100644 --- a/src/components/insights/coach-panel/self-context-adopt-offer.tsx +++ b/src/components/insights/coach-panel/self-context-adopt-offer.tsx @@ -14,12 +14,13 @@ * vertical cost once settled. */ import { useEffect, useState } from "react"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { BookmarkPlus, Check, Loader2, X } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { apiPost } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; export interface SelfContextAdoptOfferProps { /** The clarifying question the user just answered. */ @@ -47,6 +48,7 @@ export function SelfContextAdoptOffer({ onSettled, }: SelfContextAdoptOfferProps) { const { t } = useTranslations(); + const queryClient = useQueryClient(); const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null); const adopt = useMutation({ @@ -57,6 +59,13 @@ export function SelfContextAdoptOffer({ }); }, onSuccess: (data) => { + if (data.adopted) { + // The adoption changed the stored self-context — refresh the + // Settings → AI about-me read so it shows the new entry. + void queryClient.invalidateQueries({ + queryKey: queryKeys.coachAboutMe(), + }); + } setSettled(data.adopted ? "adopted" : "duplicate"); onSettled?.(data.adopted ? "adopted" : "duplicate"); }, diff --git a/src/components/insights/coach-panel/suggested-action-card.tsx b/src/components/insights/coach-panel/suggested-action-card.tsx index bba60b15f..304d6e7d4 100644 --- a/src/components/insights/coach-panel/suggested-action-card.tsx +++ b/src/components/insights/coach-panel/suggested-action-card.tsx @@ -1,13 +1,15 @@ "use client"; import { useState } from "react"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Check, Loader2, Sparkles } from "lucide-react"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { apiPost } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { invalidateReminderReads } from "@/hooks/use-measurement-reminders"; import type { CoachSuggestedAction } from "@/lib/ai/coach/suggest-action"; /** @@ -33,13 +35,24 @@ export function SuggestedActionCard({ action: CoachSuggestedAction; }) { const { t } = useTranslations(); + const queryClient = useQueryClient(); const [settled, setSettled] = useState<"applied" | "dismissed" | null>(null); const apply = useMutation({ mutationFn: async () => { await apiPost("/api/coach/suggested-actions", action.params); }, - onSuccess: () => setSettled("applied"), + onSuccess: () => { + // Both arms mint a reminder row the checkups list and the Today rail + // read; the note arm additionally lands on the coach-reminders list. + void invalidateReminderReads(queryClient); + if (action.params.actionType === "reminder.note") { + void queryClient.invalidateQueries({ + queryKey: queryKeys.coachRemindersAll(), + }); + } + setSettled("applied"); + }, onError: () => toast.error(t("coach.suggestedAction.failed")), }); diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx index abbe508d0..ea9e079e3 100644 --- a/src/components/mental-health/mental-wellbeing.tsx +++ b/src/components/mental-health/mental-wellbeing.tsx @@ -32,7 +32,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "@/lib/i18n/context"; import { apiGet, apiPost } from "@/lib/api/api-fetch"; -import { queryKeys } from "@/lib/query-keys"; +import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys"; import { INSTRUMENTS, INSTRUMENT_ORDER } from "@/lib/mental-health/instruments"; import { PageHeader } from "@/components/ui/page-header"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; @@ -93,6 +93,15 @@ export function MentalWellbeing() { void queryClient.invalidateQueries({ queryKey: queryKeys.mentalHealthAssessments(), }); + // A completed screening can satisfy a due checkup reminder, so the + // checkups list and the Today rail must repaint. Honest constraint: + // the server satisfy runs async via pg-boss, so this invalidation + // narrows the stale window but cannot close it — a refetch racing + // the job may still see the pre-satisfy row until the next tick. + void queryClient.invalidateQueries({ + queryKey: queryKeys.measurementReminders(), + }); + void refetchInactiveDailyReads(queryClient); }, }); diff --git a/src/components/settings/score-section.tsx b/src/components/settings/score-section.tsx index f304f6bab..d7d0f8c8a 100644 --- a/src/components/settings/score-section.tsx +++ b/src/components/settings/score-section.tsx @@ -70,7 +70,7 @@ import { withBaseToken, } from "@/lib/api/optimistic-token"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import { buildScoreConfigRows, @@ -210,12 +210,12 @@ export function ScoreSection() { queryKey: queryKeys.healthScoreConfig(), }); // The number now means something else. Every surface that holds a - // computed score has to go, or the change reads as broken. + // computed score has to go, or the change reads as broken. The daily + // reads are unmounted on the Settings page and run with + // `refetchOnMount: false`, so a plain invalidation marks them stale + // without ever refetching — force the inactive refetch instead. void queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); - void queryClient.invalidateQueries({ - queryKey: queryKeys.dashboardSnapshot(), - }); - void queryClient.invalidateQueries({ queryKey: queryKeys.dailyDigest() }); + void refetchInactiveDailyReads(queryClient); toastWrittenOutcome("success", t("settings.sections.score.saved")); }, onError: (err) => { diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index 926719653..c5cef3cc4 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -48,7 +48,7 @@ import { EmptyState } from "@/components/ui/empty-state"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys"; import { DEFAULT_DEVICE_TYPE_PRIORITY, DEFAULT_SOURCE_PRIORITY, @@ -160,10 +160,15 @@ export function SourcesSection() { }, onSuccess: (saved) => { queryClient.setQueryData(queryKeys.sourcePriority(), saved); - // The analytics aggregator folds source priority into per-day - // canonical-row picks — flush its cache so the charts re-paint - // with the new picker on the next mount. + // The analytics aggregator, the per-chart daily aggregates, the + // measurement list and the dashboard snapshot all fold source + // priority into their per-day canonical-row picks — flush them so + // every chart and tile re-paints with the new picker, not only the + // analytics report. queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); + queryClient.invalidateQueries({ queryKey: queryKeys.chartDataRoot() }); + queryClient.invalidateQueries({ queryKey: queryKeys.measurements() }); + void refetchInactiveDailyReads(queryClient); setDraft(null); toast.success(t("settings.sections.sources.saveSuccess")); }, @@ -179,7 +184,12 @@ export function SourcesSection() { }, onSuccess: (saved) => { queryClient.setQueryData(queryKeys.sourcePriority(), saved); + // Same fan-out as the save arm: the defaults change the canonical-row + // picks everywhere the overrides did. queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); + queryClient.invalidateQueries({ queryKey: queryKeys.chartDataRoot() }); + queryClient.invalidateQueries({ queryKey: queryKeys.measurements() }); + void refetchInactiveDailyReads(queryClient); setDraft(null); toast.success(t("settings.sections.sources.resetSuccess")); }, diff --git a/src/components/settings/thresholds-editor-section.tsx b/src/components/settings/thresholds-editor-section.tsx index 3df0feeed..29a9689f1 100644 --- a/src/components/settings/thresholds-editor-section.tsx +++ b/src/components/settings/thresholds-editor-section.tsx @@ -15,7 +15,7 @@ import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"; import { SettingsInfoTile } from "./_info-tile"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys"; import { METRIC_BOUNDS, type ThresholdMetric, @@ -95,6 +95,9 @@ export function ThresholdsEditorSection({ id }: { id: string }) { // Every chart/band depends on these thresholds — invalidate everything. queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + // The dashboard bands + score context read the same thresholds through + // the daily reads, which are unmounted on Settings — force the refetch. + void refetchInactiveDailyReads(queryClient); toast.success(t("thresholds.saveSuccess")); }, onError: (err) => @@ -115,6 +118,8 @@ export function ThresholdsEditorSection({ id }: { id: string }) { queryClient.invalidateQueries({ queryKey: queryKeys.userThresholds() }); queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + // Same daily-reads truth as the save arm above. + void refetchInactiveDailyReads(queryClient); toast.success(t("thresholds.resetSuccess")); }, onError: () => toast.error(t("thresholds.saveError")), diff --git a/src/components/targets/target-edit-sheet.tsx b/src/components/targets/target-edit-sheet.tsx index 0774aad98..c69acb2ce 100644 --- a/src/components/targets/target-edit-sheet.tsx +++ b/src/components/targets/target-edit-sheet.tsx @@ -11,7 +11,7 @@ import { ConfirmButton } from "@/components/ui/confirm-button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys"; import { METRIC_BOUNDS, type ThresholdMetric, @@ -259,6 +259,9 @@ function TargetEditSheetBody({ queryClient.invalidateQueries({ queryKey: queryKeys.insightsTargets() }); queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + // The dashboard bands + score context read the same thresholds through + // the daily reads, which are unmounted here — force their refetch. + void refetchInactiveDailyReads(queryClient); toast.success(t("targets.edit.saveSuccess")); onOpenChange(false); }, @@ -285,6 +288,8 @@ function TargetEditSheetBody({ queryClient.invalidateQueries({ queryKey: queryKeys.insightsTargets() }); queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() }); + // Same daily-reads truth as the save arm above. + void refetchInactiveDailyReads(queryClient); toast.success(t("targets.edit.resetSuccess")); onOpenChange(false); }, diff --git a/src/components/vaccinations/use-vaccinations.ts b/src/components/vaccinations/use-vaccinations.ts index 947a97536..1f1065bd6 100644 --- a/src/components/vaccinations/use-vaccinations.ts +++ b/src/components/vaccinations/use-vaccinations.ts @@ -19,6 +19,7 @@ import { invalidateReminderReads } from "@/hooks/use-measurement-reminders"; import { invalidateKeys, queryKeys, + refetchInactiveDailyReads, vaccinationDependentKeys, } from "@/lib/query-keys"; import type { @@ -154,7 +155,14 @@ export function useLinkDocumentToVaccination() { targetKind: "document", targetIds: [documentId], }), - onSuccess: () => invalidateKeys(qc, vaccinationDependentKeys), + onSuccess: () => + Promise.all([ + invalidateKeys(qc, vaccinationDependentKeys), + // The linked-document badge can surface on the daily reads' checkup + // items; force the unmounted snapshot/digest to refetch like the + // sibling mutations do (via `invalidateReminderReads`). + refetchInactiveDailyReads(qc), + ]), }); } diff --git a/src/hooks/use-practitioners.ts b/src/hooks/use-practitioners.ts index a70944b13..0b66f26cc 100644 --- a/src/hooks/use-practitioners.ts +++ b/src/hooks/use-practitioners.ts @@ -15,6 +15,7 @@ import { encounterDependentKeys, invalidateKeys, queryKeys, + refetchInactiveDailyReads, } from "@/lib/query-keys"; import type { PractitionerDTO } from "@/lib/practitioners/dto"; @@ -45,7 +46,14 @@ export function usePractitioners(q?: string, enabled = true) { export function usePractitionerMutations() { const qc = useQueryClient(); - const invalidate = () => invalidateKeys(qc, encounterDependentKeys); + // Renaming a practice changes the label a rail appointment renders under, + // and the daily reads are unmounted from the address book — pair the + // bundle invalidation with the forced inactive refetch. + const invalidate = () => + Promise.all([ + invalidateKeys(qc, encounterDependentKeys), + refetchInactiveDailyReads(qc), + ]); const create = useMutation({ mutationKey: queryKeys.practitionerCreate(), diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index 4346959ac..dbec8a3e2 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -240,6 +240,22 @@ export const medicationDependentKeys = [ */ export const cycleDependentKeys = [queryKeys.cycle(), queryKeys.insightsRoot()]; +/** + * Keys invalidated when an illness episode starts, ends, is edited, deleted, + * restored, or a day-log lands. Rest Mode derives from the active-episode set + * and both daily reads annotate from it (the hero band's "unwell since" frame, + * the digest's paused nudges), so an episode write must reach the snapshot and + * digest keys, not only the `["illness"]` tree. Pair every call site with + * `refetchInactiveDailyReads` — the daily reads are typically unmounted while + * the user is on the illness surface, and a bare invalidation would mark them + * stale without refetching (the recurring v1.16.11/v1.29.1/v1.32.19 class). + */ +export const illnessDependentKeys = [ + queryKeys.illness(), + queryKeys.dashboardSnapshot(), + queryKeys.dailyDigest(), +]; + /** * Keys invalidated when a visit or an address-book entry changes. * @@ -257,6 +273,12 @@ export const cycleDependentKeys = [queryKeys.cycle(), queryKeys.insightsRoot()]; export const encounterDependentKeys = [ queryKeys.encounters(), queryKeys.practitioners(), + // A booked / rescheduled / removed appointment is a row the Today digest + // surfaces, so it must appear on the rail in the same tick as the visit + // list. Pair call sites with `refetchInactiveDailyReads` (or the + // `invalidateReminderReads` helper, which carries it) — the digest is + // typically unmounted while the user is on the visits surface. + queryKeys.dailyDigest(), ]; /** diff --git a/src/lib/query-keys/measurements.ts b/src/lib/query-keys/measurements.ts index 767df552d..d8fd1ff32 100644 --- a/src/lib/query-keys/measurements.ts +++ b/src/lib/query-keys/measurements.ts @@ -72,6 +72,14 @@ export const measurementKeys = { * fetch window; the factory packs those into a single tuple to keep * the cache layout byte-identical with the pre-v1.4.40 layout. */ + /** + * Prefix key — invalidates every `chartData(…)` / `chartSeriesBatch(…)` + * slot at once via TanStack's hierarchical-prefix semantics. For call + * sites outside the factory that need the whole per-chart cache flushed + * (a source-priority reorder changes every chart's canonical-row picks). + */ + chartDataRoot: () => ["chart-data"] as const, + chartData: ( types: string, valueMode: string,