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

@@ -7,7 +7,8 @@
* helpers on their own:
*
* 1. the save posts ONE entry to `POST /api/workouts/batch`, MANUAL, with
* the form's external id, and refreshes the workout reads;
* the form's external id, and refreshes the workout reads, the day and
* the timeline;
* 2. a `duplicate` (a second tap) is a success, a `skipped` entry is not;
* 3. delete is offered for a hand-entered workout in one's own record only,
* and calls `DELETE /api/workouts/{id}`.
@@ -44,9 +45,16 @@ function fakeClient() {
return {
invalidateQueries: vi.fn().mockResolvedValue(undefined),
removeQueries: vi.fn(),
getQueryCache: () => ({ findAll: () => [] }),
};
}
function invalidatedKeys(client: ReturnType<typeof fakeClient>): string[] {
return client.invalidateQueries.mock.calls.map(([arg]) =>
JSON.stringify((arg as { queryKey: unknown }).queryKey),
);
}
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
@@ -80,7 +88,7 @@ describe("saveManualWorkout — the wire", () => {
expect(body.workouts[0].externalId).toBe(ENTRY.externalId);
});
it("refreshes every workout read, and the daily reads", async () => {
it("refreshes every workout read, the daily reads, the day and the timeline", async () => {
fetchSpy.mockResolvedValue(
envelope({ entries: [{ index: 0, status: "inserted" }] }),
);
@@ -88,12 +96,13 @@ describe("saveManualWorkout — the wire", () => {
await saveManualWorkout(ENTRY, client as unknown as QueryClient);
const keys = client.invalidateQueries.mock.calls.map(([arg]) =>
JSON.stringify((arg as { queryKey: unknown }).queryKey),
);
const keys = invalidatedKeys(client);
expect(keys).toContain(JSON.stringify(queryKeys.workouts()));
expect(keys).toContain(JSON.stringify(queryKeys.dailyDigest()));
expect(keys).toContain(JSON.stringify(queryKeys.dashboardSnapshot()));
// "Capture for this day" saves into an open day view.
expect(keys).toContain(JSON.stringify(queryKeys.dayRoot()));
expect(keys).toContain(JSON.stringify(queryKeys.timelineRoot()));
});
it("treats a duplicate (the same form submitted twice) as saved", async () => {
@@ -177,8 +186,9 @@ describe("delete — a hand-entered workout in one's own record only", () => {
expect(client.removeQueries).toHaveBeenCalledWith({
queryKey: queryKeys.workoutDetail("w-1"),
});
expect(client.invalidateQueries).toHaveBeenCalledWith({
queryKey: queryKeys.workouts(),
});
const keys = invalidatedKeys(client);
expect(keys).toContain(JSON.stringify(queryKeys.workouts()));
expect(keys).toContain(JSON.stringify(queryKeys.dayRoot()));
expect(keys).toContain(JSON.stringify(queryKeys.timelineRoot()));
});
});
@@ -20,7 +20,12 @@ import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { apiDelete } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
import {
invalidateKeys,
queryKeys,
refetchInactiveDailyReads,
workoutDependentKeys,
} from "@/lib/query-keys";
import { cn } from "@/lib/utils";
/**
@@ -38,8 +43,8 @@ export function canDeleteWorkout(
/**
* Delete, then drop the deleted detail from the cache and refresh the rest of
* the workout reads (list, dashboard tile) and the two daily reads the route
* evicted server-side. The detail is removed rather than invalidated, so
* the workout reads (list, dashboard tile, the day and the timeline) and the
* two daily reads the route evicted server-side. The detail is removed rather than invalidated, so
* nothing refetches a row that no longer exists.
*/
export async function deleteManualWorkout(
@@ -48,7 +53,7 @@ export async function deleteManualWorkout(
): Promise<void> {
await apiDelete(`/api/workouts/${encodeURIComponent(id)}`);
queryClient.removeQueries({ queryKey: queryKeys.workoutDetail(id) });
await queryClient.invalidateQueries({ queryKey: queryKeys.workouts() });
await invalidateKeys(queryClient, workoutDependentKeys);
await refetchInactiveDailyReads(queryClient);
}
@@ -24,7 +24,11 @@ import { useUnitDisplay } from "@/hooks/use-unit-display";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { getQuantityTransform } from "@/lib/measurements/display-transform";
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
import {
invalidateKeys,
refetchInactiveDailyReads,
workoutDependentKeys,
} from "@/lib/query-keys";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import {
buildManualWorkoutEntry,
@@ -105,11 +109,10 @@ export async function saveManualWorkout(
if (status !== "inserted" && status !== "duplicate" && status !== "updated") {
throw new Error(`workout entry not stored: ${status ?? "unknown"}`);
}
// The list, the dashboard tile and every detail read sit under this
// prefix. The form also opens from the capture picker on any page, where
// the Today hero (which names a workout that just landed) and the dashboard
// snapshot are unmounted, so those two are refetched outright.
await queryClient.invalidateQueries({ queryKey: queryKeys.workouts() });
// The form also opens from the capture picker on any page, where the
// Today hero and the dashboard snapshot are unmounted, so those two are
// refetched outright.
await invalidateKeys(queryClient, workoutDependentKeys);
await refetchInactiveDailyReads(queryClient);
return status;
}