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).
This commit is contained in:
Marc-André Bombeck committed 2026-08-13 18:48:10 +02:00
1 parent 002d120b69
commit f2f40ae8b2
17 files changed
+239 -24

No files matched your search

@@ -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 `<qc>.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.<key>()` 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
+14 -2
View File
@@ -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. */
@@ -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";
@@ -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 {
@@ -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 <body>. 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: () => {
@@ -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"
@@ -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");
},
@@ -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")),
});
@@ -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);
},
});
+6 -6
View File
@@ -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) => {
+14 -4
View File
@@ -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"));
},
@@ -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")),
+6 -1
View File
@@ -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);
},
@@ -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),
]),
});
}
+9 -1
View File
@@ -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(),
+22
View File
@@ -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(),
];
/**
+8
View File
@@ -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,