fix(query-keys): refresh the day view and timeline after every write

Symptoms, workouts, lab results, allergies, documents and questionnaires
evicted only their own lists. An open day, its calendar dots and the
timeline kept the pre-write answer for up to five minutes, so "Capture
for this day" saved a symptom or a workout and the day showed the old
list, and a deleted row stayed on it.

Workouts, labs, allergies and documents get a bundle beside the seven
that already carry the day and timeline roots; the six lab lists become
one (the OCR dialog's own copy of the commit hook's list goes with it).
Symptoms and questionnaires add the two roots to their list. Symptoms
stay off the illness bundle on purpose: nothing on the dashboard or the
Today digest reads them. Custom metrics need nothing: neither the day
nor the timeline reads them. invalidateKeys now drops a key listed twice,
since spread bundles share the two roots.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 19:41:14 +01:00
1 parent aae029d94e
commit 2e73b0ffec
24 files changed
+257 -118

No files matched your search

@@ -0,0 +1,63 @@
/**
* The day view and the timeline read every record table, and the day query
* stays fresh for five minutes because every record write evicts its root.
* A write whose invalidation leaves the two roots out keeps an open day and
* the timeline on their pre-write answer: "Capture for this day" saved a
* symptom or a workout and the day showed the old list.
*/
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { invalidateSymptomReads } from "@/components/symptoms/use-symptoms";
import {
allergyDependentKeys,
cycleDependentKeys,
documentDependentKeys,
encounterDependentKeys,
illnessDependentKeys,
labDependentKeys,
measurementDependentKeys,
medicationDependentKeys,
moodDependentKeys,
queryKeys,
vaccinationDependentKeys,
workoutDependentKeys,
} from "@/lib/query-keys";
const RECORD_BUNDLES = {
allergy: allergyDependentKeys,
cycle: cycleDependentKeys,
document: documentDependentKeys,
encounter: encounterDependentKeys,
illness: illnessDependentKeys,
lab: labDependentKeys,
measurement: measurementDependentKeys,
medication: medicationDependentKeys,
mood: moodDependentKeys,
vaccination: vaccinationDependentKeys,
workout: workoutDependentKeys,
};
describe("record writes reach the day view and the timeline", () => {
it.each(Object.entries(RECORD_BUNDLES))(
"a %s write evicts the day and timeline roots",
(_name, bundle) => {
expect(bundle).toContainEqual(queryKeys.dayRoot());
expect(bundle).toContainEqual(queryKeys.timelineRoot());
},
);
it("a symptom write marks an open day and the timeline stale", async () => {
const client = new QueryClient();
const day = queryKeys.day("2026-11-10");
const lanes = queryKeys.timeline("week", null, null, "");
client.setQueryData(day, { sections: [] });
client.setQueryData(lanes, { lanes: [] });
await invalidateSymptomReads(client);
expect(client.getQueryState(day)?.isInvalidated).toBe(true);
expect(client.getQueryState(lanes)?.isInvalidated).toBe(true);
client.clear();
});
});
+54 -5
View File
@@ -359,6 +359,52 @@ export const vaccinationDependentKeys = [
queryKeys.timelineRoot(),
];
/**
* Keys invalidated when a workout is logged, edited or deleted. The Today
* digest names a workout that just arrived and the write evicts the server's
* daily caches, so the daily reads ride along; pair call sites with
* `refetchInactiveDailyReads`, because both are unmounted wherever the form
* opens outside the dashboard.
*/
export const workoutDependentKeys = [
queryKeys.workouts(),
queryKeys.dashboardSnapshot(),
queryKeys.dailyDigest(),
queryKeys.dayRoot(),
queryKeys.timelineRoot(),
];
/**
* Keys invalidated when a lab reading is created, edited, deleted or restored,
* or a marker is renamed, hidden, merged or deleted. The marker list carries
* each marker's latest reading, and the "what changed since your last panel"
* card compares the two newest panels.
*/
export const labDependentKeys = [
queryKeys.labResults(),
queryKeys.biomarkers(),
queryKeys.insightsLabsChanges(),
queryKeys.dayRoot(),
queryKeys.timelineRoot(),
];
/** Keys invalidated when an allergy is created, edited or deleted. */
export const allergyDependentKeys = [
queryKeys.allergies(),
queryKeys.dayRoot(),
queryKeys.timelineRoot(),
];
/**
* Keys invalidated when a document is uploaded, imported, edited, deleted or
* restored. A document's date places it on its day and on the timeline.
*/
export const documentDependentKeys = [
queryKeys.documents(),
queryKeys.dayRoot(),
queryKeys.timelineRoot(),
];
/**
* Keys invalidated when an input of the `ai` block on `GET /api/auth/me`
* changes: the account's own provider (key, local model, compatible endpoint,
@@ -385,7 +431,8 @@ export const aiInputDependentKeys = [
/**
* Invalidate every key in the bundle in parallel. Use this from mutation
* `onSuccess` handlers so the call site stays a one-liner instead of repeating
* `Promise.all(keys.map(...))` everywhere.
* `Promise.all(keys.map(...))` everywhere. A key listed twice (two bundles
* spread into one list share the day and timeline roots) is invalidated once.
*
* Uses `allSettled` so one transient network failure doesn't abort subsequent
* invalidations (cache would otherwise be left half-stale) and so the `void
@@ -404,8 +451,11 @@ export function invalidateKeys(
queryClient: QueryClient,
keys: readonly QueryKey[],
): Promise<PromiseSettledResult<unknown>[]> {
const unique = new Map(keys.map((key) => [JSON.stringify(key), key]));
return Promise.allSettled(
keys.map((queryKey) => invalidateKey(queryClient, queryKey)),
[...unique.values()].map((queryKey) =>
invalidateKey(queryClient, queryKey),
),
);
}
@@ -441,9 +491,8 @@ async function invalidateKey(
* remount re-fetches under `refetchOnMount: false`.
*
* Originates from the v1.16.11 dashboard-snapshot fix and the v1.29.1
* medication-intake fix (`invalidateMedicationReads` in
* `components/medications/use-medication-intake.ts`, which predates this
* shared helper and keeps its own inline copy). Call this alongside
* medication-intake fix (`invalidateMedicationReads` below wraps the pair
* for medication writes). Call this alongside
* `invalidateKeys(queryClient, xDependentKeys)` from any write whose server
* route already hard-evicts the snapshot bucket — the forced refetch then
* returns post-write data immediately, no server change needed.