From 2e73b0ffec4b43919a9bfb64469eff8900151a78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 19:41:14 +0100 Subject: [PATCH] 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. --- .../baselines/source-function-lines.json | 2 +- .../baselines/version-stamp-comments.json | 2 +- .../daily-reads-refetch-guard.test.ts | 10 +-- src/app/labs/page.tsx | 18 +----- .../documents/detail/use-document-detail.ts | 12 ++-- .../sources/document-source-picker.tsx | 8 ++- .../documents/trash/use-recently-deleted.ts | 11 +++- .../documents/use-document-upload.ts | 4 +- .../documents/vault/use-vault-bulk.ts | 4 +- .../labs/__tests__/ocr-review-dialog.test.tsx | 1 - src/components/labs/biomarker-manager.tsx | 8 +-- src/components/labs/biomarker-merge-sheet.tsx | 5 +- src/components/labs/lab-biomarker-detail.tsx | 25 +++----- src/components/labs/lab-history-list.tsx | 11 +--- src/components/labs/ocr-review-dialog.tsx | 3 - src/components/labs/use-ocr-extract.ts | 13 ++-- .../mental-health/mental-wellbeing.tsx | 27 ++++---- src/components/records/allergy-manager.tsx | 10 ++- src/components/symptoms/use-symptoms.ts | 25 +++++++- .../__tests__/manual-workout-save.test.ts | 26 +++++--- .../workouts/delete-workout-button.tsx | 13 ++-- .../workouts/manual-workout-form.tsx | 15 +++-- .../__tests__/record-write-day-keys.test.ts | 63 +++++++++++++++++++ src/lib/query-keys/index.ts | 59 +++++++++++++++-- 24 files changed, 257 insertions(+), 118 deletions(-) create mode 100644 src/lib/query-keys/__tests__/record-write-day-keys.test.ts diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index dd83b4bdc..5acf9fdf8 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -45,7 +45,7 @@ "app/insights/catalog/page.tsx :: MetricCatalogPage": 204, "app/insights/medications/page.tsx :: InsightsMedikamentePage": 265, "app/insights/page-client.tsx :: InsightsPageClient": 375, - "app/labs/page.tsx :: LabsPage": 228, + "app/labs/page.tsx :: LabsPage": 216, "app/medications/page-client.tsx :: MedicationsPageClient": 590, "app/notifications/page.tsx :: NotificationsPage": 327, "components/activity/activity-calendar-card.tsx :: ActivityCalendarCard": 470, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index fc64b4a07..11f71af80 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1071,7 +1071,7 @@ "components/labs/lab-biomarker-chart.tsx": 3, "components/labs/lab-biomarker-detail.tsx": 13, "components/labs/lab-form.tsx": 6, - "components/labs/lab-history-list.tsx": 4, + "components/labs/lab-history-list.tsx": 3, "components/labs/lab-list.tsx": 5, "components/labs/lab-reference-range-bar.tsx": 1, "components/labs/lab-trend-card.tsx": 1, diff --git a/src/__tests__/daily-reads-refetch-guard.test.ts b/src/__tests__/daily-reads-refetch-guard.test.ts index 5e030daba..0dab55671 100644 --- a/src/__tests__/daily-reads-refetch-guard.test.ts +++ b/src/__tests__/daily-reads-refetch-guard.test.ts @@ -49,15 +49,15 @@ const FACTORY = "lib/query-keys/index.ts"; * 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 + * The cycle / encounter / vaccination / illness / workout families joined the + * matcher alongside the original three: the encounter, illness and workout + * bundles 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|cycleDependentKeys|encounterDependentKeys|vaccinationDependentKeys|illnessDependentKeys)\b/g; + /invalidateKeys\(\s*[^,]+,\s*\[?\s*(?:\.\.\.)?\s*(?:medicationDependentKeys|measurementDependentKeys|moodDependentKeys|cycleDependentKeys|encounterDependentKeys|vaccinationDependentKeys|illnessDependentKeys|workoutDependentKeys)\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". diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 74316b483..4105720f0 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -37,7 +37,7 @@ import { useMounted } from "@/hooks/use-mounted"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { invalidateKeys, labDependentKeys, queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; import type { LabResultListResponse } from "@/components/labs/types"; import { @@ -231,18 +231,14 @@ export default function LabsPage() { footerSlot={addFooterEl} onSuccess={() => { setDialogOpen(false); - queryClient.invalidateQueries({ - queryKey: queryKeys.labResults(), - }); + void invalidateKeys(queryClient, labDependentKeys); }} // v1.30.1 — "Save & add another" keeps the sheet open; the // form clears the reading itself, this callback just refreshes // the list underneath so the just-saved row is there once the // user is done and closes the sheet. onSavedKeepOpen={() => { - queryClient.invalidateQueries({ - queryKey: queryKeys.labResults(), - }); + void invalidateKeys(queryClient, labDependentKeys); }} onCancel={() => setDialogOpen(false)} /> @@ -255,14 +251,6 @@ export default function LabsPage() { mode={ocrCapability.data.mode} pdfSupported={ocrCapability.data.pdfSupported} consentRequired={labsOcr.reason === "consent_required"} - onCommitted={() => { - queryClient.invalidateQueries({ - queryKey: queryKeys.labResults(), - }); - queryClient.invalidateQueries({ - queryKey: queryKeys.biomarkers(), - }); - }} /> ) : null} diff --git a/src/components/documents/detail/use-document-detail.ts b/src/components/documents/detail/use-document-detail.ts index ff269a6d2..2006ef0ec 100644 --- a/src/components/documents/detail/use-document-detail.ts +++ b/src/components/documents/detail/use-document-detail.ts @@ -18,7 +18,11 @@ import { import { DOCUMENT_PURGE_GRACE_DAYS } from "@/lib/documents/inbound-constants"; import { showErrorToast } from "@/lib/errors/error-toast"; import { useTranslations } from "@/lib/i18n/context"; -import { invalidateKeys, queryKeys } from "@/lib/query-keys"; +import { + documentDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; import type { DocumentSourceSystemValue, InboundDocumentDetailDto, @@ -265,7 +269,7 @@ function useDocumentWrites({ // A link written from this side changes what the visit's and the // dose's own views list, so their reads refresh with the vault's. void invalidateKeys(queryClient, [ - queryKeys.documents(), + ...documentDependentKeys, ...(input.encounterIds !== undefined ? [queryKeys.encounters()] : []), ...(input.vaccinationIds !== undefined ? [queryKeys.vaccinations()] @@ -282,7 +286,7 @@ function useDocumentWrites({ mutationFn: (id: string) => apiPost(`/api/documents/inbound/${id}/restore`, {}), onSuccess: () => { - void invalidateKeys(queryClient, [queryKeys.documents()]); + void invalidateKeys(queryClient, documentDependentKeys); }, onError: (error) => { const reason = @@ -303,7 +307,7 @@ function useDocumentWrites({ mutationFn: (id: string) => apiDelete(`/api/documents/inbound/${id}`), onSuccess: (_data, id) => { onOpenChange(false); - void invalidateKeys(queryClient, [queryKeys.documents()]); + void invalidateKeys(queryClient, documentDependentKeys); toast.success(t("documents.toast.deleted"), { description: t("documents.toast.deletedHint", { days: DOCUMENT_PURGE_GRACE_DAYS, diff --git a/src/components/documents/sources/document-source-picker.tsx b/src/components/documents/sources/document-source-picker.tsx index 7b9732d7e..d3c224335 100644 --- a/src/components/documents/sources/document-source-picker.tsx +++ b/src/components/documents/sources/document-source-picker.tsx @@ -71,7 +71,11 @@ import { type DocumentSourceTagDto, } from "@/lib/documents/sources/types"; import { useTranslations } from "@/lib/i18n/context"; -import { invalidateKeys, queryKeys } from "@/lib/query-keys"; +import { + documentDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; import { cn } from "@/lib/utils"; import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents"; @@ -317,7 +321,7 @@ export function DocumentSourcePicker({ // The vault, its pickers and the record's own lists read documents // through the `["documents"]` prefix; the picker's states through its own. void invalidateKeys(queryClient, [ - queryKeys.documents(), + ...documentDependentKeys, queryKeys.documentSources(), ]); if (handed.length > 0) onImported?.(handed); diff --git a/src/components/documents/trash/use-recently-deleted.ts b/src/components/documents/trash/use-recently-deleted.ts index 139fca599..215395abc 100644 --- a/src/components/documents/trash/use-recently-deleted.ts +++ b/src/components/documents/trash/use-recently-deleted.ts @@ -10,7 +10,11 @@ import { ApiError, apiGetList, apiPost } from "@/lib/api/api-fetch"; import { DOCUMENT_BULK_MAX_IDS } from "@/lib/documents/inbound-constants"; import { showErrorToast } from "@/lib/errors/error-toast"; import { useTranslations } from "@/lib/i18n/context"; -import { invalidateKeys, queryKeys } from "@/lib/query-keys"; +import { + documentDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; import type { DeletedDocumentListDto, DocumentBulkResultDto, @@ -21,7 +25,8 @@ import type { * document back (one at a time, or all through the bulk route); "Delete now" * removes one for good ahead of the grace window. Every write invalidates the * `["documents"]` prefix, so the vault, its usage figures and this list repaint - * together. + * together, and the day and timeline roots, which place a document on its + * date. */ export function useRecentlyDeleted({ enabled }: { enabled: boolean }) { const { t } = useTranslations(); @@ -36,7 +41,7 @@ export function useRecentlyDeleted({ enabled }: { enabled: boolean }) { }); const refresh = useCallback( - () => void invalidateKeys(queryClient, [queryKeys.documents()]), + () => void invalidateKeys(queryClient, documentDependentKeys), [queryClient], ); diff --git a/src/components/documents/use-document-upload.ts b/src/components/documents/use-document-upload.ts index 7eb16d7b2..7cba8016a 100644 --- a/src/components/documents/use-document-upload.ts +++ b/src/components/documents/use-document-upload.ts @@ -32,7 +32,7 @@ import { toast } from "sonner"; import { useTranslations } from "@/lib/i18n/context"; import { DOCUMENT_UPLOAD_PER_USER_CONCURRENCY } from "@/lib/documents/inbound-constants"; import { randomId } from "@/lib/random-id"; -import { invalidateKeys, queryKeys } from "@/lib/query-keys"; +import { documentDependentKeys, invalidateKeys } from "@/lib/query-keys"; import { uploadWithBusyRetry } from "./upload-busy-retry"; import { parseUploadResponse, @@ -261,7 +261,7 @@ export function useDocumentUpload( } const decision = settleDecision(result); removeItem(localId); - void invalidateKeys(queryClient, [queryKeys.documents()]); + void invalidateKeys(queryClient, documentDependentKeys); if (decision.toastDuplicate) { toast.info(t("documents.toast.duplicate")); } diff --git a/src/components/documents/vault/use-vault-bulk.ts b/src/components/documents/vault/use-vault-bulk.ts index 4b5cff351..f6a4cb433 100644 --- a/src/components/documents/vault/use-vault-bulk.ts +++ b/src/components/documents/vault/use-vault-bulk.ts @@ -11,7 +11,7 @@ import { } from "@/lib/documents/inbound-constants"; import { showErrorToast } from "@/lib/errors/error-toast"; import { useTranslations } from "@/lib/i18n/context"; -import { invalidateKeys, queryKeys } from "@/lib/query-keys"; +import { documentDependentKeys, invalidateKeys } from "@/lib/query-keys"; import type { DocumentBulkAction, DocumentBulkResultDto, @@ -64,7 +64,7 @@ export function useVaultBulk({ return results; }, onSettled: () => { - void invalidateKeys(queryClient, [queryKeys.documents()]); + void invalidateKeys(queryClient, documentDependentKeys); }, }); diff --git a/src/components/labs/__tests__/ocr-review-dialog.test.tsx b/src/components/labs/__tests__/ocr-review-dialog.test.tsx index b92bece18..d3712ea05 100644 --- a/src/components/labs/__tests__/ocr-review-dialog.test.tsx +++ b/src/components/labs/__tests__/ocr-review-dialog.test.tsx @@ -91,7 +91,6 @@ function render( mode={options.mode ?? "vision"} pdfSupported={options.pdfSupported ?? true} consentRequired={options.consentRequired} - onCommitted={() => {}} /> , diff --git a/src/components/labs/biomarker-manager.tsx b/src/components/labs/biomarker-manager.tsx index c69d11aba..53eb31e58 100644 --- a/src/components/labs/biomarker-manager.tsx +++ b/src/components/labs/biomarker-manager.tsx @@ -16,7 +16,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch"; import { formatReferenceRange } from "@/lib/labs/reference-range"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { invalidateKeys, labDependentKeys, queryKeys } from "@/lib/query-keys"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; @@ -68,8 +68,7 @@ export function BiomarkerManager() { mutationFn: (id: string) => apiDelete(`/api/biomarkers/${id}`), onSuccess: () => { toast.success(t("labs.biomarker.deletedToast")); - queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() }); - queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); + void invalidateKeys(queryClient, labDependentKeys); }, onError: (failure) => showErrorToast(t("labs.biomarker.deleteError"), { cause: failure }), @@ -103,8 +102,7 @@ export function BiomarkerManager() { function afterSave() { setFormOpen(false); setEditing(null); - queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() }); - queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); + void invalidateKeys(queryClient, labDependentKeys); } const markers = data?.biomarkers ?? []; diff --git a/src/components/labs/biomarker-merge-sheet.tsx b/src/components/labs/biomarker-merge-sheet.tsx index 6073c4d91..1a4cc9f1d 100644 --- a/src/components/labs/biomarker-merge-sheet.tsx +++ b/src/components/labs/biomarker-merge-sheet.tsx @@ -25,7 +25,7 @@ import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { TagChip } from "@/components/ui/tag-chip"; import { apiPost } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { invalidateKeys, labDependentKeys } from "@/lib/query-keys"; import type { BiomarkerDto, BiomarkerMergeResultDto } from "./types"; import { labUnitLabel } from "@/lib/labs/dimensionless"; @@ -79,8 +79,7 @@ export function BiomarkerMergeSheet({ name: result.target.name, }), ); - queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() }); - queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); + void invalidateKeys(queryClient, labDependentKeys); reset(); onOpenChange(false); }, diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index cfff0826d..9eb8e5909 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -44,7 +44,7 @@ import { type MessageBundle, } from "@/lib/i18n/load-locale"; import { resolveKey } from "@/lib/i18n/resolve-key"; -import { queryKeys } from "@/lib/query-keys"; +import { invalidateKeys, labDependentKeys, queryKeys } from "@/lib/query-keys"; import dynamic from "next/dynamic"; import type { ComponentProps } from "react"; @@ -259,13 +259,10 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { // lapsed; bust them alongside the result list so the assessment repaints as // soon as the reading lands. function invalidateAfterReadingWrite() { - queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); - queryClient.invalidateQueries({ - queryKey: queryKeys.insightsBiomarkerAssessment(biomarkerId, locale), - }); - queryClient.invalidateQueries({ - queryKey: queryKeys.insightsLabsChanges(), - }); + void invalidateKeys(queryClient, [ + ...labDependentKeys, + queryKeys.insightsBiomarkerAssessment(biomarkerId, locale), + ]); } function afterAdd() { @@ -282,12 +279,11 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { function afterEditMarker() { setEditOpen(false); - queryClient.invalidateQueries({ - queryKey: queryKeys.biomarkerDetail(biomarkerId), - }); - queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() }); // Resolved name / unit / range on every reading derives from the marker. - queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); + void invalidateKeys(queryClient, [ + ...labDependentKeys, + queryKeys.biomarkerDetail(biomarkerId), + ]); } // v1.18.9 (#41/#3) — delete the biomarker directly from its detail page so @@ -301,8 +297,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { mutationFn: () => apiDelete(`/api/biomarkers/${biomarkerId}`), onSuccess: () => { toast.success(t("labs.biomarker.deletedToast")); - queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() }); - queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); + void invalidateKeys(queryClient, labDependentKeys); router.push("/labs"); }, onError: (failure) => diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index 130e85faa..56dbcf6a0 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -35,7 +35,7 @@ import { LabConvertedNote } from "./lab-converted-note"; import { resolveNoteForUpdate } from "@/lib/labs/note-update"; import { resolveValueForUpdate, valueEditSeed } from "@/lib/labs/value-update"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { invalidateKeys, labDependentKeys, queryKeys } from "@/lib/query-keys"; import { ReferenceRangeBadge } from "./reference-range-badge"; import { LabReferenceRangeBar } from "./lab-reference-range-bar"; @@ -147,14 +147,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { const [hasNote, setHasNote] = useState(false); function invalidate() { - queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); - // v1.32.19 — a lab reading edit / delete / restore shifts the two most - // recent panels the Insights "what changed since your last panel" card - // reads, so bust it too instead of leaving it on its pre-write deltas - // until the 60 s stale window lapses. - queryClient.invalidateQueries({ - queryKey: queryKeys.insightsLabsChanges(), - }); + void invalidateKeys(queryClient, labDependentKeys); } async function restore(id: string) { diff --git a/src/components/labs/ocr-review-dialog.tsx b/src/components/labs/ocr-review-dialog.tsx index 5ac645ec2..4b7247bf9 100644 --- a/src/components/labs/ocr-review-dialog.tsx +++ b/src/components/labs/ocr-review-dialog.tsx @@ -142,7 +142,6 @@ export function OcrReviewDialog({ mode, pdfSupported, consentRequired = false, - onCommitted, }: { open: boolean; onOpenChange: (open: boolean) => void; @@ -155,7 +154,6 @@ export function OcrReviewDialog({ * granted `/api/auth/me` refreshes and the picker appears. */ consentRequired?: boolean; - onCommitted: () => void; }) { const { t } = useTranslations(); const pickerId = useId(); @@ -335,7 +333,6 @@ export function OcrReviewDialog({ }) : t("labs.ocr.savedEmptyToast"), ); - onCommitted(); handleClose(false); }, onError: (failure) => diff --git a/src/components/labs/use-ocr-extract.ts b/src/components/labs/use-ocr-extract.ts index 3963e48c9..989b8b3cc 100644 --- a/src/components/labs/use-ocr-extract.ts +++ b/src/components/labs/use-ocr-extract.ts @@ -29,7 +29,9 @@ import type { AiRunAccepted } from "@/lib/documents/ai-runs/types"; import { ocrImageToText } from "@/lib/labs/local-ocr"; import { aiInputDependentKeys, + documentDependentKeys, invalidateKeys, + labDependentKeys, queryKeys, } from "@/lib/query-keys"; import type { @@ -259,12 +261,11 @@ export function useOcrCommit() { }); }, onSuccess: (_result, { file }) => { - queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); - queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() }); - // A scan filed into the vault adds a document — refresh its lists. - if (file) { - queryClient.invalidateQueries({ queryKey: queryKeys.documents() }); - } + // A scan filed into the vault adds a document as well. + void invalidateKeys(queryClient, [ + ...labDependentKeys, + ...(file ? documentDependentKeys : []), + ]); }, }); } diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx index d275f31c3..0cdebd557 100644 --- a/src/components/mental-health/mental-wellbeing.tsx +++ b/src/components/mental-health/mental-wellbeing.tsx @@ -32,7 +32,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "@/lib/i18n/context"; import { apiGetList, apiPost } from "@/lib/api/api-fetch"; -import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys"; +import { + invalidateKeys, + 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"; @@ -96,17 +100,16 @@ export function MentalWellbeing() { onSuccess: (data) => { setResult(data); setPhase("result"); - 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(), - }); + // A screening is listed on its day, and it 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. + void invalidateKeys(queryClient, [ + queryKeys.mentalHealthAssessments(), + queryKeys.measurementReminders(), + queryKeys.dayRoot(), + ]); void refetchInactiveDailyReads(queryClient); }, }); diff --git a/src/components/records/allergy-manager.tsx b/src/components/records/allergy-manager.tsx index c6a1ed919..9d3a09f30 100644 --- a/src/components/records/allergy-manager.tsx +++ b/src/components/records/allergy-manager.tsx @@ -16,7 +16,11 @@ import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; import { apiDelete, apiGetList } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { + allergyDependentKeys, + invalidateKeys, + queryKeys, +} from "@/lib/query-keys"; import { AllergyForm } from "./allergy-form"; import type { AllergyDTO } from "@/lib/records/dto"; @@ -59,7 +63,7 @@ export function AllergyManager({ mutationFn: (id: string) => apiDelete(`/api/allergies/${id}`), onSuccess: () => { toast.success(t("records.allergies.deletedToast")); - queryClient.invalidateQueries({ queryKey: queryKeys.allergies() }); + void invalidateKeys(queryClient, allergyDependentKeys); }, onError: (failure) => showErrorToast(t("records.allergies.deleteError"), { cause: failure }), @@ -78,7 +82,7 @@ export function AllergyManager({ function afterSave() { setFormOpen(false); setEditing(null); - queryClient.invalidateQueries({ queryKey: queryKeys.allergies() }); + void invalidateKeys(queryClient, allergyDependentKeys); } const rows = page?.data ?? []; diff --git a/src/components/symptoms/use-symptoms.ts b/src/components/symptoms/use-symptoms.ts index 9c900f10c..41790f6e9 100644 --- a/src/components/symptoms/use-symptoms.ts +++ b/src/components/symptoms/use-symptoms.ts @@ -6,9 +6,15 @@ * Reads unwrap the envelope through `apiGet`; every key comes from the * factory (`queryKeys.symptom*`, under the `["illness"]` root). Every write * evicts the whole illness tree, because the episode detail lists the - * occurrences filed against it and the definition summaries count them. + * occurrences filed against it and the definition summaries count them, and + * the day and timeline roots, which show each occurrence. */ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + useMutation, + useQuery, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query"; import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; @@ -118,9 +124,22 @@ export function useSymptomEvents( }); } +/** + * What a symptom write reaches: the illness tree its keys live under, and the + * day and the timeline, which list every occurrence. Nothing on the dashboard + * or the Today digest reads symptoms. + */ +export function invalidateSymptomReads(qc: QueryClient) { + return invalidateKeys(qc, [ + queryKeys.illness(), + queryKeys.dayRoot(), + queryKeys.timelineRoot(), + ]); +} + function useInvalidateSymptoms() { const qc = useQueryClient(); - return () => invalidateKeys(qc, [queryKeys.illness()]); + return () => invalidateSymptomReads(qc); } export function useCreateSymptomDefinition() { diff --git a/src/components/workouts/__tests__/manual-workout-save.test.ts b/src/components/workouts/__tests__/manual-workout-save.test.ts index e04b69829..4156d4a47 100644 --- a/src/components/workouts/__tests__/manual-workout-save.test.ts +++ b/src/components/workouts/__tests__/manual-workout-save.test.ts @@ -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): string[] { + return client.invalidateQueries.mock.calls.map(([arg]) => + JSON.stringify((arg as { queryKey: unknown }).queryKey), + ); +} + let fetchSpy: ReturnType; 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())); }); }); diff --git a/src/components/workouts/delete-workout-button.tsx b/src/components/workouts/delete-workout-button.tsx index adfdcb303..2feb89a1e 100644 --- a/src/components/workouts/delete-workout-button.tsx +++ b/src/components/workouts/delete-workout-button.tsx @@ -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 { 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); } diff --git a/src/components/workouts/manual-workout-form.tsx b/src/components/workouts/manual-workout-form.tsx index 72e618a4e..708f55819 100644 --- a/src/components/workouts/manual-workout-form.tsx +++ b/src/components/workouts/manual-workout-form.tsx @@ -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; } diff --git a/src/lib/query-keys/__tests__/record-write-day-keys.test.ts b/src/lib/query-keys/__tests__/record-write-day-keys.test.ts new file mode 100644 index 000000000..64f54253a --- /dev/null +++ b/src/lib/query-keys/__tests__/record-write-day-keys.test.ts @@ -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(); + }); +}); diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index f3423a5ac..57e244539 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -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[]> { + 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.