mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user