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

+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")),