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 01/10] 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. From 7f54a820b0659d5687e2c576b65881450f6bc997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 19:42:17 +0100 Subject: [PATCH 02/10] fix(checkups): clear a done check-up from the Today rail at once Marking a check-up done on the dashboard updated its tile, while the Today rail above it kept listing it as due until the 120 s poll. The reminder writes refreshed the two daily reads only through the inactive pass, which fetches nothing for a query that is on screen. They are now invalidated the ordinary way too, so a mounted rail refetches, and the day root joins them because a check-up marked done is listed on its day. The test fake pinned only the inactive call; a real query client now shows the mounted rail refetching. --- .../use-measurement-reminders-refetch.test.ts | 25 ++++++++++++++++++- src/hooks/use-measurement-reminders.ts | 20 ++++++++++++--- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/hooks/__tests__/use-measurement-reminders-refetch.test.ts b/src/hooks/__tests__/use-measurement-reminders-refetch.test.ts index 56f32ef2f..4c4f01db1 100644 --- a/src/hooks/__tests__/use-measurement-reminders-refetch.test.ts +++ b/src/hooks/__tests__/use-measurement-reminders-refetch.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect, vi } from "vitest"; -import type { QueryClient } from "@tanstack/react-query"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; import { invalidateReminderReads } from "@/hooks/use-measurement-reminders"; +import { queryKeys } from "@/lib/query-keys"; /** * v1.32.19 — a Vorsorge reminder marked done (or created / edited / deleted) @@ -16,6 +17,7 @@ import { invalidateReminderReads } from "@/hooks/use-measurement-reminders"; function fakeQueryClient(): QueryClient { return { invalidateQueries: vi.fn().mockResolvedValue(undefined), + getQueryCache: () => ({ findAll: () => [] }), } as unknown as QueryClient; } @@ -47,4 +49,25 @@ describe("invalidateReminderReads — the Vorsorge daily-rail freshness contract expect(inactiveKeys).toContain(JSON.stringify(["dashboard", "snapshot"])); expect(inactiveKeys).toContain(JSON.stringify(["daily", "digest"])); }); + + it("refetches the Today rail while it is on screen", async () => { + // The dashboard: the check-up tile and the Today rail are both mounted, + // where the inactive pass alone fetches nothing. + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + let reads = 0; + const observer = new QueryObserver(client, { + queryKey: queryKeys.dailyDigest(), + queryFn: async () => ++reads, + }); + const unsubscribe = observer.subscribe(() => {}); + await vi.waitFor(() => expect(reads).toBe(1)); + + await invalidateReminderReads(client); + + expect(reads).toBe(2); + unsubscribe(); + client.clear(); + }); }); diff --git a/src/hooks/use-measurement-reminders.ts b/src/hooks/use-measurement-reminders.ts index b42a564c8..b2376dddc 100644 --- a/src/hooks/use-measurement-reminders.ts +++ b/src/hooks/use-measurement-reminders.ts @@ -17,7 +17,11 @@ import { } from "@tanstack/react-query"; import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; -import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys"; +import { + invalidateKeys, + queryKeys, + refetchInactiveDailyReads, +} from "@/lib/query-keys"; /** * v1.32.19 — the read fan-out every Vorsorge reminder mutation runs on @@ -31,11 +35,21 @@ import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys"; * (`refetchOnMount: false`) — the reminder lingered on the Today rail until * the 120 s poll. Force the inactive daily reads to refetch alongside the * reminders invalidation so the rail clears at once, the same freshness - * contract the medication intake surfaces use. + * contract the medication intake surfaces use. The inactive pass fetches + * nothing for a read that is on screen, though: marked done from the + * dashboard tile, the check-up stayed due on the Today rail above it. So + * the two daily reads are invalidated the ordinary way as well, which + * refetches them where they are mounted. A check-up marked done is also + * listed on its day. */ export function invalidateReminderReads(qc: QueryClient): Promise { return Promise.all([ - qc.invalidateQueries({ queryKey: queryKeys.measurementReminders() }), + invalidateKeys(qc, [ + queryKeys.measurementReminders(), + queryKeys.dailyDigest(), + queryKeys.dashboardSnapshot(), + queryKeys.dayRoot(), + ]), refetchInactiveDailyReads(qc), ]); } From 971d5d32ccf98428ae1a0a350b5861cf4f9eae65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 19:44:13 +0100 Subject: [PATCH 03/10] fix(documents): refresh what confirmed facts and a take-over create Confirming a document's facts creates medications, conditions and lab values, and the profile take-over creates a condition or an allergy. Both evicted only the roots of those lists, so the dashboard and the Today digest showed the old medication set until the 120 s poll, the day and timeline kept the old state for five minutes, and adherence and nutrient reads were not evicted at all. Both now spread the bundles of what they create, and the condition paths pair them with the inactive refetch of the daily reads. --- .../baselines/source-function-lines.json | 1 - .../confirm-facts-invalidation.test.ts | 34 ++++++++++++ .../documents/use-document-facts.ts | 53 ++++++++++++++----- .../records/free-text-take-over.tsx | 31 ++++++----- 4 files changed, 90 insertions(+), 29 deletions(-) create mode 100644 src/components/documents/__tests__/confirm-facts-invalidation.test.ts diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 5acf9fdf8..7c156b9d6 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -184,7 +184,6 @@ "components/provenance/source-provenance-view.tsx :: SourceProvenanceView": 363, "components/records/allergy-form.tsx :: AllergyForm": 225, "components/records/audit-c-panel.tsx :: AuditCPanel": 285, - "components/records/free-text-take-over.tsx :: FreeTextTakeOver": 201, "components/records/life-circumstances-manager.tsx :: SectionBody": 472, "components/settings/about-section.tsx :: AboutSection": 236, "components/settings/access/grant-invite-card.tsx :: GrantInviteCard": 311, diff --git a/src/components/documents/__tests__/confirm-facts-invalidation.test.ts b/src/components/documents/__tests__/confirm-facts-invalidation.test.ts new file mode 100644 index 000000000..43391c745 --- /dev/null +++ b/src/components/documents/__tests__/confirm-facts-invalidation.test.ts @@ -0,0 +1,34 @@ +/** + * Confirming a document's facts creates medications, conditions and lab + * values. The confirm used to evict only the roots of those lists, so the + * dashboard, the Today digest, adherence, nutrients, the day and the timeline + * kept showing the record without them. + */ +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, describe, expect, it } from "vitest"; + +import { invalidateConfirmedFactReads } from "../use-document-facts"; +import { queryKeys } from "@/lib/query-keys"; + +const client = new QueryClient(); + +afterEach(() => client.clear()); + +describe("invalidateConfirmedFactReads", () => { + it.each([ + ["the dashboard snapshot", queryKeys.dashboardSnapshot()], + ["the Today digest", queryKeys.dailyDigest()], + ["the nutrient reads", queryKeys.nutrientsRoot()], + ["an open day", queryKeys.day("2026-11-10")], + ["the timeline", queryKeys.timelineRoot()], + ["the lab results", queryKeys.labResults()], + ["the condition journal", queryKeys.illness()], + ["the document", queryKeys.inboundDocument("doc-1")], + ])("marks %s stale", async (_label, key) => { + client.setQueryData(key, { seeded: true }); + + await invalidateConfirmedFactReads(client, "doc-1"); + + expect(client.getQueryState(key)?.isInvalidated).toBe(true); + }); +}); diff --git a/src/components/documents/use-document-facts.ts b/src/components/documents/use-document-facts.ts index 2ab18b787..999092376 100644 --- a/src/components/documents/use-document-facts.ts +++ b/src/components/documents/use-document-facts.ts @@ -13,7 +13,11 @@ * approve/reject decisions the person made; a fact without a decision stays * pending on the server. */ -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + useMutation, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query"; import { resolveAiRun, useAiRunPhase } from "@/hooks/use-ai-run"; import { apiPatch, apiPost } from "@/lib/api/api-fetch"; @@ -21,7 +25,15 @@ import type { AiRunAccepted, DocumentExtractRunResult, } from "@/lib/documents/ai-runs/types"; -import { invalidateKeys, queryKeys } from "@/lib/query-keys"; +import { + documentDependentKeys, + illnessDependentKeys, + invalidateKeys, + labDependentKeys, + medicationDependentKeys, + queryKeys, + refetchInactiveDailyReads, +} from "@/lib/query-keys"; import type { ExtractedFactDto, InboundFactEdit, @@ -101,11 +113,32 @@ export function useEditFact() { }); } +/** + * Every read a confirm can touch: the medications, conditions and lab values + * it creates, with what derives from them, and the document itself (status + * and fact states). A new medication or condition also reaches the dashboard + * and the Today digest, which are unmounted while the person reviews. + */ +export function invalidateConfirmedFactReads( + queryClient: QueryClient, + documentId: string, +) { + return Promise.all([ + invalidateKeys(queryClient, [ + ...medicationDependentKeys, + ...illnessDependentKeys, + ...labDependentKeys, + ...documentDependentKeys, + queryKeys.inboundDocument(documentId), + ]), + refetchInactiveDailyReads(queryClient), + ]); +} + /** * Commit the review decisions. Approved facts land in their structured * stores (labs / conditions / medications) through the server's existing - * field-by-field commit; rejected facts are discarded. Invalidates every - * read the commit can touch, plus the document itself (status + fact states). + * field-by-field commit; rejected facts are discarded. */ export function useConfirmFacts() { const queryClient = useQueryClient(); @@ -127,15 +160,7 @@ export function useConfirmFacts() { `/api/documents/inbound/${documentId}/confirm`, { decisions }, ), - onSuccess: (_data, { documentId }) => { - void invalidateKeys(queryClient, [ - queryKeys.inboundDocument(documentId), - queryKeys.documents(), - queryKeys.labResults(), - queryKeys.biomarkers(), - queryKeys.illness(), - queryKeys.medications(), - ]); - }, + onSuccess: (_data, { documentId }) => + void invalidateConfirmedFactReads(queryClient, documentId), }); } diff --git a/src/components/records/free-text-take-over.tsx b/src/components/records/free-text-take-over.tsx index e52f2f608..9574ee6ed 100644 --- a/src/components/records/free-text-take-over.tsx +++ b/src/components/records/free-text-take-over.tsx @@ -31,7 +31,13 @@ import { withBaseToken } from "@/lib/api/optimistic-token"; import { showErrorToast } from "@/lib/errors/error-toast"; import { useTranslations } from "@/lib/i18n/context"; import type { LifeCircumstancesDto } from "@/lib/profile/life-circumstances"; -import { queryKeys } from "@/lib/query-keys"; +import { + allergyDependentKeys, + illnessDependentKeys, + invalidateKeys, + queryKeys, + refetchInactiveDailyReads, +} from "@/lib/query-keys"; import { dateOnlyAtNoonUtc, isCalendarDateKey } from "@/lib/tz/date-only"; interface AboutMeData { @@ -66,13 +72,12 @@ export function FreeTextTakeOver({ const afterCreate = () => { setSaved(true); toastWrittenOutcome("success", t("records.life.takeOver.savedToast")); - void queryClient.invalidateQueries({ - queryKey: queryKeys.lifeCircumstances(), - }); - void queryClient.invalidateQueries({ - queryKey: - field === "conditions" ? queryKeys.illness() : queryKeys.allergies(), - }); + void invalidateKeys(queryClient, [ + queryKeys.lifeCircumstances(), + ...(field === "conditions" ? illnessDependentKeys : allergyDependentKeys), + ]); + // A chronic condition frames the dashboard and the Today digest. + if (field === "conditions") void refetchInactiveDailyReads(queryClient); }; const createCondition = useMutation({ @@ -103,12 +108,10 @@ export function FreeTextTakeOver({ onSuccess: () => { toastWrittenOutcome("success", t("records.life.takeOver.clearedToast")); close(); - void queryClient.invalidateQueries({ - queryKey: queryKeys.coachAboutMe(), - }); - void queryClient.invalidateQueries({ - queryKey: queryKeys.lifeCircumstances(), - }); + void invalidateKeys(queryClient, [ + queryKeys.coachAboutMe(), + queryKeys.lifeCircumstances(), + ]); }, onError: (error) => showErrorToast(t("records.life.takeOver.clearError"), { cause: error }), From 4721914cacb7e759d243700bdcfe1c265dab0e48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 19:46:17 +0100 Subject: [PATCH 04/10] fix(forms): close the sheet as soon as a save lands Saving a weight, a mood, a workout, a measurement edit or a quick-add dose emptied the form and kept the button spinning until every read the write touches had refetched (snapshot, digest, charts, insights). On a slow connection that looked like nothing had been saved. The vaccination, visit, address-book, symptom and check-up hooks did the same through an onSuccess that returned the refetch, which a mutation waits for. The forms now report and close first and start the refresh without waiting for it; the hooks no longer return it. The quick-add loses a second sweep of the inline compliance charts that the medication bundle already covers, and its source pin follows. --- .../medication-intake-quick-add.test.tsx | 15 ++++++++------- .../dashboard/medication-intake-quick-add.tsx | 10 +++------- .../measurements/list/use-measurement-edit.ts | 6 +++--- src/components/measurements/measurement-form.tsx | 6 ++++-- src/components/mood/mood-form.tsx | 4 ++-- src/components/symptoms/use-symptoms.ts | 4 +++- src/components/vaccinations/use-vaccinations.ts | 4 +++- .../__tests__/manual-workout-save.test.ts | 16 ++++++++++++++++ .../workouts/delete-workout-button.tsx | 4 ++-- src/components/workouts/manual-workout-form.tsx | 7 ++++--- src/hooks/use-encounters.ts | 5 +++-- src/hooks/use-measurement-reminders.ts | 3 ++- src/hooks/use-practitioners.ts | 5 +++-- 13 files changed, 56 insertions(+), 33 deletions(-) diff --git a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx index 584b08b36..1d7d1eed5 100644 --- a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx +++ b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx @@ -393,21 +393,22 @@ describe("submit invalidation — the quick-add is a capture-picker surface, not ); it("routes the intake submit through the blessed invalidateMedicationReads (source pin)", () => { - expect(componentSrc).toContain( - "await invalidateMedicationReads(queryClient);", - ); expect(componentSrc).not.toContain( "invalidateKeys(queryClient, medicationDependentKeys)", ); - // The submit handler pairs the blessed helper with the inline - // compliance-chart fan-out AFTER the intake POST landed. + // AFTER the intake POST landed, and after the sheet was told it is done: + // the save does not wait for the refetches before it closes. expect(componentSrc).toMatch( - /async function handleSubmit\([\s\S]*?apiPost[\s\S]*?await invalidateMedicationReads\(queryClient\);[\s\S]*?complianceChartInline\(\)/, + /async function handleSubmit\([\s\S]*?apiPost[\s\S]*?onSuccess\?\.\(\);\s*(?:\/\/[^\n]*\n\s*)*void invalidateMedicationReads\(queryClient\);/, + ); + // The bundle carries every inline compliance chart, so no second sweep. + expect(medicationDependentKeys).toContainEqual( + queryKeys.complianceChartInline(), ); }); it("forces the inactive Today hero + dashboard reads to refetch after submit", async () => { - // The helper the submit path awaits — asserted directly, same shape as + // The helper the submit path calls — asserted directly, same shape as // the take-all-due suite: the bundle fans out (N keys), then the two // daily reads are invalidated with `refetchType: "inactive"`, which is // what actually refetches them while unmounted. diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx index c67561b12..38bd99fcf 100644 --- a/src/components/dashboard/medication-intake-quick-add.tsx +++ b/src/components/dashboard/medication-intake-quick-add.tsx @@ -235,13 +235,6 @@ export function MedicationIntakeQuickAdd({ ); const eventId = created?.id; - await invalidateMedicationReads(queryClient); - // Fan-out to every inline compliance chart key (one per medication) - // so the detail-page tile refreshes when the user re-enters it. - await queryClient.invalidateQueries({ - queryKey: queryKeys.complianceChartInline(), - }); - // v1.36.x — the shared decision, not a fourth copy of it. This surface // was left out of the collapse that took the other three: it offered a // delegate an Undo hitting `DELETE /api/medications/{id}/intake/{id}`, @@ -272,6 +265,9 @@ export function MedicationIntakeQuickAdd({ toast.success(t("common.saved")); } onSuccess?.(); + // The dose is stored; the sheet does not wait for the refetches. The + // bundle covers every inline compliance chart as well. + void invalidateMedicationReads(queryClient); } catch (err) { setError( err instanceof ApiError && err.message diff --git a/src/components/measurements/list/use-measurement-edit.ts b/src/components/measurements/list/use-measurement-edit.ts index e7ae637e7..f4213ba97 100644 --- a/src/components/measurements/list/use-measurement-edit.ts +++ b/src/components/measurements/list/use-measurement-edit.ts @@ -127,15 +127,15 @@ export function useMeasurementEdit({ }) => { await apiPut(`/api/measurements/${id}`, { value, measuredAt, notes }); }, - onSuccess: async () => { - await invalidateKeys(queryClient, measurementDependentKeys); - await refetchInactiveDailyReads(queryClient); + onSuccess: () => { setEditSeed({ value: editValue, measuredAt: editMeasuredAt, notes: editNotes, }); dismissEdit(); + void invalidateKeys(queryClient, measurementDependentKeys); + void refetchInactiveDailyReads(queryClient); }, onError: (err) => { // A 409 names its reason in `meta.errorCode`: the edit collides with diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index 76e7b2f95..9f7ba309b 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -528,8 +528,6 @@ export function MeasurementForm({ setPulse(""); setPairedGlucose(""); setNotes(""); - await invalidateKeys(queryClient, measurementDependentKeys); - await refetchInactiveDailyReads(queryClient); // v1.36.x — a delegate's receipt names the record. "Saved" alone is // the one confirmation somebody acting for another person does not // need, and the reading has just left their own history for good. @@ -544,6 +542,10 @@ export function MeasurementForm({ : undefined, ); onSuccess?.(); + // The reading is stored; the sheet does not wait for every read that + // shows it to come back. + void invalidateKeys(queryClient, measurementDependentKeys); + void refetchInactiveDailyReads(queryClient); } catch (err) { setError(localizedApiError(err, t, "measurements.saveError")); } finally { diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index 2fecf8747..e79bf95c6 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -257,13 +257,13 @@ export function MoodForm({ if (tagKeys.length > 0) recordUse(tagKeys); resetForm(); setMoreTagsOpen(false); - await invalidateKeys(queryClient, moodDependentKeys); - await refetchInactiveDailyReads(queryClient); toast.success(t("common.saved")); // The entry landed, but a tag picked here may have been archived or // deleted elsewhere since the picker loaded; the server says so. if (droppedAnyLinkKeys(saved)) toast.warning(t("mood.tagsNotSaved")); onSuccess?.(); + void invalidateKeys(queryClient, moodDependentKeys); + void refetchInactiveDailyReads(queryClient); } catch (err) { setError(localizedApiError(err, t, "mood.saveError")); } finally { diff --git a/src/components/symptoms/use-symptoms.ts b/src/components/symptoms/use-symptoms.ts index 41790f6e9..711a7c96a 100644 --- a/src/components/symptoms/use-symptoms.ts +++ b/src/components/symptoms/use-symptoms.ts @@ -137,9 +137,11 @@ export function invalidateSymptomReads(qc: QueryClient) { ]); } +// Not returned, so a save reports as soon as it lands rather than after +// every read it touches has refetched. function useInvalidateSymptoms() { const qc = useQueryClient(); - return () => invalidateSymptomReads(qc); + return () => void invalidateSymptomReads(qc); } export function useCreateSymptomDefinition() { diff --git a/src/components/vaccinations/use-vaccinations.ts b/src/components/vaccinations/use-vaccinations.ts index c67f2b77f..a2c9c8656 100644 --- a/src/components/vaccinations/use-vaccinations.ts +++ b/src/components/vaccinations/use-vaccinations.ts @@ -125,8 +125,10 @@ export function useVaccination(id: string | null) { */ export function useVaccinationMutations() { const qc = useQueryClient(); + // Not returned: a mutation would wait for every refetch before it settles, + // and the sheet would stay open with its spinner until then. const invalidate = () => - Promise.all([ + void Promise.all([ invalidateKeys(qc, vaccinationDependentKeys), invalidateReminderReads(qc), ]); diff --git a/src/components/workouts/__tests__/manual-workout-save.test.ts b/src/components/workouts/__tests__/manual-workout-save.test.ts index 4156d4a47..b7a97beba 100644 --- a/src/components/workouts/__tests__/manual-workout-save.test.ts +++ b/src/components/workouts/__tests__/manual-workout-save.test.ts @@ -105,6 +105,22 @@ describe("saveManualWorkout — the wire", () => { expect(keys).toContain(JSON.stringify(queryKeys.timelineRoot())); }); + it("returns once the entry is stored, without waiting for the refetches", async () => { + // The sheet closes on this answer; on a slow connection the reads it + // refreshes can take seconds, and the person would watch a spinner. + fetchSpy.mockResolvedValue( + envelope({ entries: [{ index: 0, status: "inserted" }] }), + ); + const client = { + ...fakeClient(), + invalidateQueries: vi.fn(() => new Promise(() => {})), + }; + + await expect( + saveManualWorkout(ENTRY, client as unknown as QueryClient), + ).resolves.toBe("inserted"); + }); + it("treats a duplicate (the same form submitted twice) as saved", async () => { fetchSpy.mockResolvedValue( envelope({ entries: [{ index: 0, status: "duplicate" }] }), diff --git a/src/components/workouts/delete-workout-button.tsx b/src/components/workouts/delete-workout-button.tsx index 2feb89a1e..b04d1c417 100644 --- a/src/components/workouts/delete-workout-button.tsx +++ b/src/components/workouts/delete-workout-button.tsx @@ -53,8 +53,8 @@ export async function deleteManualWorkout( ): Promise { await apiDelete(`/api/workouts/${encodeURIComponent(id)}`); queryClient.removeQueries({ queryKey: queryKeys.workoutDetail(id) }); - await invalidateKeys(queryClient, workoutDependentKeys); - await refetchInactiveDailyReads(queryClient); + void invalidateKeys(queryClient, workoutDependentKeys); + void refetchInactiveDailyReads(queryClient); } /** diff --git a/src/components/workouts/manual-workout-form.tsx b/src/components/workouts/manual-workout-form.tsx index 708f55819..b10aafc1d 100644 --- a/src/components/workouts/manual-workout-form.tsx +++ b/src/components/workouts/manual-workout-form.tsx @@ -94,7 +94,8 @@ interface BatchResult { export type ManualWorkoutSaveOutcome = "inserted" | "duplicate" | "updated"; /** - * Post the entry and refresh what shows workouts. A `duplicate` is a success: + * Post the entry and start refreshing what shows workouts; the caller reports + * the save without waiting for those reads. A `duplicate` is a success: * the same form already landed this session (a second tap, a retry), so the * row exists exactly once. A `skipped` entry was refused and throws. */ @@ -112,8 +113,8 @@ export async function saveManualWorkout( // 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); + void invalidateKeys(queryClient, workoutDependentKeys); + void refetchInactiveDailyReads(queryClient); return status; } diff --git a/src/hooks/use-encounters.ts b/src/hooks/use-encounters.ts index dc947d9de..d140be832 100644 --- a/src/hooks/use-encounters.ts +++ b/src/hooks/use-encounters.ts @@ -166,10 +166,11 @@ export function useEncounterMutations() { * * So the fan-out is the visit roots PLUS the reminder reads, through the same * helper the Vorsorge surfaces use, rather than a second sweep that could - * drift from it. + * drift from it. Not returned: a mutation would wait for every refetch + * before it settles, and the sheet would stay open until then. */ const invalidate = () => - Promise.all([ + void Promise.all([ invalidateKeys(qc, encounterDependentKeys), invalidateReminderReads(qc), ]); diff --git a/src/hooks/use-measurement-reminders.ts b/src/hooks/use-measurement-reminders.ts index b2376dddc..d60766302 100644 --- a/src/hooks/use-measurement-reminders.ts +++ b/src/hooks/use-measurement-reminders.ts @@ -117,7 +117,8 @@ export function useMeasurementReminders(enabled = true) { export function useMeasurementReminderMutations() { const qc = useQueryClient(); - const invalidate = () => invalidateReminderReads(qc); + // Not returned: a mutation would wait for every refetch before it settles. + const invalidate = () => void invalidateReminderReads(qc); const create = useMutation({ mutationKey: queryKeys.measurementReminderCreate(), diff --git a/src/hooks/use-practitioners.ts b/src/hooks/use-practitioners.ts index bab014841..4b083a47f 100644 --- a/src/hooks/use-practitioners.ts +++ b/src/hooks/use-practitioners.ts @@ -62,9 +62,10 @@ export function usePractitionerMutations() { const qc = useQueryClient(); // 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. + // bundle invalidation with the forced inactive refetch. Not returned, so the + // sheet closes without waiting for the refetches. const invalidate = () => - Promise.all([ + void Promise.all([ invalidateKeys(qc, encounterDependentKeys), refetchInactiveDailyReads(qc), ]); From 0639d28f6a8084efe6031908740c3c1ced61c7cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 19:48:27 +0100 Subject: [PATCH 05/10] fix(i18n): say "sign in again" or "too many requests" on a refused save A save after the session ran out, or into a rate limit, showed the form's generic "could not save". The server already names both refusals in meta.errorCode (auth.missing for an ended web session, the status codes http.rate_limited, http.forbidden and http.invalid otherwise), but the catalogs had no sentence for them, so the lookup fell through to the fallback. They have one now in all ten locales, and a refusal that reaches the client without a code (a reverse proxy's 429) is read by its status the way the server labels it. --- messages/de.json | 9 +++- messages/en.json | 9 +++- messages/es.json | 9 +++- messages/fr.json | 9 +++- messages/it.json | 9 +++- messages/ko.json | 9 +++- messages/nl.json | 9 +++- messages/pl.json | 9 +++- messages/pt-BR.json | 9 +++- messages/ru.json | 9 +++- src/lib/api/__tests__/localized-error.test.ts | 47 +++++++++++++++++++ src/lib/api/localized-error.ts | 15 ++++-- 12 files changed, 137 insertions(+), 15 deletions(-) diff --git a/messages/de.json b/messages/de.json index 4dcc47c51..c35f312d1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -143,7 +143,8 @@ "reproof": { "required": "Bestätige dein aktuelles Passwort, um das zu ändern.", "failed": "Die Bestätigung ist fehlgeschlagen. Es wurde nichts geändert." - } + }, + "missing": "Deine Sitzung ist abgelaufen, daher wurde nichts geändert. Melde dich erneut an, um weiterzumachen." }, "biomarkers": { "unit": { @@ -178,6 +179,12 @@ }, "aiRuns": { "readerBusy": "Der Dokumentleser ist gerade mit anderen Aufgaben beschäftigt. Versuch es in einer Minute noch einmal." + }, + "http": { + "unauthenticated": "Deine Sitzung ist abgelaufen, daher wurde nichts geändert. Melde dich erneut an, um weiterzumachen.", + "rate_limited": "Gerade gibt es zu viele Anfragen, daher wurde nichts geändert. Warte kurz und versuche es erneut.", + "forbidden": "Dafür fehlt dir die Berechtigung, daher wurde nichts geändert.", + "invalid": "Einige Angaben wurden nicht angenommen, daher wurde nichts geändert. Prüfe sie und versuche es erneut." } }, "doctorReport": { diff --git a/messages/en.json b/messages/en.json index 6a5c05ac9..29d5ddf0b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -143,7 +143,8 @@ "reproof": { "required": "Confirm your current password to change this.", "failed": "That did not verify. Nothing was changed." - } + }, + "missing": "Your session has ended, so nothing was changed. Sign in again to continue." }, "biomarkers": { "unit": { @@ -178,6 +179,12 @@ }, "aiRuns": { "readerBusy": "The document reader is busy with other work. Try again in a minute." + }, + "http": { + "unauthenticated": "Your session has ended, so nothing was changed. Sign in again to continue.", + "rate_limited": "There are too many requests just now, so nothing was changed. Wait a moment and try again.", + "forbidden": "You don't have permission for this, so nothing was changed.", + "invalid": "Some of the entries were not accepted, so nothing was changed. Check them and try again." } }, "doctorReport": { diff --git a/messages/es.json b/messages/es.json index a2b612bad..71ee4da7e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -143,7 +143,8 @@ "reproof": { "required": "Confirma tu contraseña actual para cambiar esto.", "failed": "La verificación ha fallado. No se ha cambiado nada." - } + }, + "missing": "Tu sesión ha caducado, así que no se ha cambiado nada. Inicia sesión de nuevo para continuar." }, "biomarkers": { "unit": { @@ -178,6 +179,12 @@ }, "aiRuns": { "readerBusy": "El lector de documentos está ocupado con otras tareas. Inténtalo de nuevo en un minuto." + }, + "http": { + "unauthenticated": "Tu sesión ha caducado, así que no se ha cambiado nada. Inicia sesión de nuevo para continuar.", + "rate_limited": "Hay demasiadas solicitudes ahora mismo, así que no se ha cambiado nada. Espera un momento y vuelve a intentarlo.", + "forbidden": "No tienes permiso para esto, así que no se ha cambiado nada.", + "invalid": "Algunos datos no se aceptaron, así que no se ha cambiado nada. Revísalos y vuelve a intentarlo." } }, "doctorReport": { diff --git a/messages/fr.json b/messages/fr.json index af97df036..8d7b7a40c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -143,7 +143,8 @@ "reproof": { "required": "Confirmez votre mot de passe actuel pour modifier ceci.", "failed": "La vérification a échoué. Rien n'a été modifié." - } + }, + "missing": "Votre session a expiré, rien n'a donc été modifié. Reconnectez-vous pour continuer." }, "biomarkers": { "unit": { @@ -178,6 +179,12 @@ }, "aiRuns": { "readerBusy": "Le lecteur de documents est occupé par d'autres tâches. Réessayez dans une minute." + }, + "http": { + "unauthenticated": "Votre session a expiré, rien n'a donc été modifié. Reconnectez-vous pour continuer.", + "rate_limited": "Il y a trop de requêtes pour le moment, rien n'a donc été modifié. Patientez un instant puis réessayez.", + "forbidden": "Vous n'avez pas l'autorisation pour cette action, rien n'a donc été modifié.", + "invalid": "Certaines saisies n'ont pas été acceptées, rien n'a donc été modifié. Vérifiez-les puis réessayez." } }, "doctorReport": { diff --git a/messages/it.json b/messages/it.json index fed51449c..02478f1e1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -143,7 +143,8 @@ "reproof": { "required": "Conferma la password attuale per modificarlo.", "failed": "La verifica non è riuscita. Non è stato modificato nulla." - } + }, + "missing": "La tua sessione è scaduta, quindi non è stato modificato nulla. Accedi di nuovo per continuare." }, "biomarkers": { "unit": { @@ -178,6 +179,12 @@ }, "aiRuns": { "readerBusy": "Il lettore di documenti è occupato con altre attività. Riprova tra un minuto." + }, + "http": { + "unauthenticated": "La tua sessione è scaduta, quindi non è stato modificato nulla. Accedi di nuovo per continuare.", + "rate_limited": "Ci sono troppe richieste in questo momento, quindi non è stato modificato nulla. Attendi un attimo e riprova.", + "forbidden": "Non hai l'autorizzazione per farlo, quindi non è stato modificato nulla.", + "invalid": "Alcuni dati non sono stati accettati, quindi non è stato modificato nulla. Controllali e riprova." } }, "doctorReport": { diff --git a/messages/ko.json b/messages/ko.json index 77eee65e5..fd4860a74 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -143,7 +143,8 @@ "reproof": { "required": "변경하려면 현재 비밀번호를 확인하세요.", "failed": "확인에 실패했습니다. 아무것도 변경되지 않았습니다." - } + }, + "missing": "세션이 만료되어 아무것도 변경되지 않았어요. 계속하려면 다시 로그인해 주세요." }, "biomarkers": { "unit": { @@ -178,6 +179,12 @@ }, "aiRuns": { "readerBusy": "문서 판독기가 다른 작업으로 바빠요. 1분 후에 다시 시도하세요." + }, + "http": { + "unauthenticated": "세션이 만료되어 아무것도 변경되지 않았어요. 계속하려면 다시 로그인해 주세요.", + "rate_limited": "지금은 요청이 너무 많아 아무것도 변경되지 않았어요. 잠시 후 다시 시도해 주세요.", + "forbidden": "이 작업을 할 권한이 없어 아무것도 변경되지 않았어요.", + "invalid": "일부 입력값이 받아들여지지 않아 아무것도 변경되지 않았어요. 확인한 후 다시 시도해 주세요." } }, "doctorReport": { diff --git a/messages/nl.json b/messages/nl.json index 1df8e98bf..6680b0928 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -143,7 +143,8 @@ "reproof": { "required": "Bevestig je huidige wachtwoord om dit te wijzigen.", "failed": "De verificatie is mislukt. Er is niets gewijzigd." - } + }, + "missing": "Je sessie is verlopen, dus er is niets gewijzigd. Meld je opnieuw aan om verder te gaan." }, "biomarkers": { "unit": { @@ -178,6 +179,12 @@ }, "aiRuns": { "readerBusy": "De documentlezer is met ander werk bezig. Probeer het over een minuut opnieuw." + }, + "http": { + "unauthenticated": "Je sessie is verlopen, dus er is niets gewijzigd. Meld je opnieuw aan om verder te gaan.", + "rate_limited": "Er zijn net te veel verzoeken, dus er is niets gewijzigd. Wacht even en probeer het opnieuw.", + "forbidden": "Je hebt hier geen toestemming voor, dus er is niets gewijzigd.", + "invalid": "Sommige gegevens zijn niet geaccepteerd, dus er is niets gewijzigd. Controleer ze en probeer het opnieuw." } }, "doctorReport": { diff --git a/messages/pl.json b/messages/pl.json index fa9457d77..02ca079b0 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -143,7 +143,8 @@ "reproof": { "required": "Potwierdź obecne hasło, aby to zmienić.", "failed": "Weryfikacja nie powiodła się. Nic nie zostało zmienione." - } + }, + "missing": "Twoja sesja wygasła, więc nic nie zostało zmienione. Zaloguj się ponownie, aby kontynuować." }, "biomarkers": { "unit": { @@ -178,6 +179,12 @@ }, "aiRuns": { "readerBusy": "Czytnik dokumentów jest zajęty innymi zadaniami. Spróbuj ponownie za minutę." + }, + "http": { + "unauthenticated": "Twoja sesja wygasła, więc nic nie zostało zmienione. Zaloguj się ponownie, aby kontynuować.", + "rate_limited": "W tej chwili jest zbyt wiele zapytań, więc nic nie zostało zmienione. Zaczekaj chwilę i spróbuj ponownie.", + "forbidden": "Nie masz do tego uprawnień, więc nic nie zostało zmienione.", + "invalid": "Niektóre dane nie zostały przyjęte, więc nic nie zostało zmienione. Sprawdź je i spróbuj ponownie." } }, "doctorReport": { diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 6abf9a204..18df65df1 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -143,7 +143,8 @@ "reproof": { "required": "Confirme sua senha atual para alterar isto.", "failed": "A verificação não foi concluída. Nada foi alterado." - } + }, + "missing": "Sua sessão terminou, então nada foi alterado. Entre novamente para continuar." }, "biomarkers": { "unit": { @@ -178,6 +179,12 @@ }, "aiRuns": { "readerBusy": "O leitor de documentos está ocupado com outras tarefas. Tente novamente em um minuto." + }, + "http": { + "unauthenticated": "Sua sessão terminou, então nada foi alterado. Entre novamente para continuar.", + "rate_limited": "Há solicitações demais neste momento, então nada foi alterado. Aguarde um pouco e tente de novo.", + "forbidden": "Você não tem permissão para isso, então nada foi alterado.", + "invalid": "Alguns dados não foram aceitos, então nada foi alterado. Confira e tente de novo." } }, "doctorReport": { diff --git a/messages/ru.json b/messages/ru.json index 159cf4fab..4cf579664 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -143,7 +143,8 @@ "reproof": { "required": "Подтвердите текущий пароль, чтобы изменить это.", "failed": "Не подтверждено. Ничего не изменено." - } + }, + "missing": "Сеанс завершён, поэтому ничего не изменено. Войдите снова, чтобы продолжить." }, "biomarkers": { "unit": { @@ -178,6 +179,12 @@ }, "aiRuns": { "readerBusy": "Средство чтения документов занято другой работой. Попробуйте через минуту." + }, + "http": { + "unauthenticated": "Сеанс завершён, поэтому ничего не изменено. Войдите снова, чтобы продолжить.", + "rate_limited": "Сейчас слишком много запросов, поэтому ничего не изменено. Подождите немного и попробуйте снова.", + "forbidden": "У вас нет прав на это действие, поэтому ничего не изменено.", + "invalid": "Некоторые данные не приняты, поэтому ничего не изменено. Проверьте их и попробуйте снова." } }, "doctorReport": { diff --git a/src/lib/api/__tests__/localized-error.test.ts b/src/lib/api/__tests__/localized-error.test.ts index df0597295..b828bf842 100644 --- a/src/lib/api/__tests__/localized-error.test.ts +++ b/src/lib/api/__tests__/localized-error.test.ts @@ -113,4 +113,51 @@ describe("localizedApiError", () => { ), ).toBe(translator(locale as Catalog)("measurements.saveError")); }); + + describe("a refusal the client can act on", () => { + const enT = translator(en as Catalog); + + it.each([ + ["an ended session", 401, "auth.missing", "Sign in again"], + ["a rate limit", 429, "http.rate_limited", "too many requests"], + ["a missing right", 403, "http.forbidden", "permission"], + ["a rejected entry", 422, "http.invalid", "Check them"], + ])("names %s instead of the save fallback", (_, status, code, text) => { + const error = new ApiError("raw English server prose", status, { + errorCode: code, + }); + expect(localizedApiError(error, enT, "measurements.saveError")).toMatch( + new RegExp(text, "i"), + ); + }); + + it("reads a refusal without a code by its status", () => { + // A reverse proxy's 429 carries no envelope at all. + const error = new ApiError("Request failed (429)", 429); + expect(localizedApiError(error, enT, "measurements.saveError")).toBe( + enT("apiErrors.http.rate_limited"), + ); + }); + + it.each([ + ["de", de], + ["es", es], + ["fr", fr], + ["it", itCatalog], + ["pl", pl], + ])("has its own sentence in %s", (_, locale) => { + const localT = translator(locale as Catalog); + for (const code of [ + "auth.missing", + "http.unauthenticated", + "http.rate_limited", + "http.forbidden", + "http.invalid", + ]) { + const sentence = localT(`apiErrors.${code}`); + expect(sentence).not.toBe(`apiErrors.${code}`); + expect(sentence).not.toBe(enT(`apiErrors.${code}`)); + } + }); + }); }); diff --git a/src/lib/api/localized-error.ts b/src/lib/api/localized-error.ts index 085429ee2..ee102ad73 100644 --- a/src/lib/api/localized-error.ts +++ b/src/lib/api/localized-error.ts @@ -1,21 +1,26 @@ import { ApiError } from "./api-fetch"; +import { defaultErrorCodeForStatus } from "./default-error-codes"; import { translateCode } from "@/lib/i18n/translate-code"; type Translate = (key: string) => string; /** * Resolve an API envelope's stable error code through the locale catalog. - * Missing/unknown codes, network failures, and non-API errors deliberately - * collapse to the caller's safe domain fallback; server prose is never shown. + * A refusal without a code of its own (a proxy's 429, an answer without the + * envelope) is read by its status, the way the server labels one, so an + * ended session or a rate limit is named as such. Unknown codes, network + * failures, and non-API errors deliberately collapse to the caller's safe + * domain fallback; server prose is never shown. */ export function localizedApiError( error: unknown, t: Translate, fallbackKey: string, ): string { - const errorCode = - error instanceof ApiError && typeof error.meta?.errorCode === "string" + const errorCode = !(error instanceof ApiError) + ? null + : typeof error.meta?.errorCode === "string" ? error.meta.errorCode - : null; + : defaultErrorCodeForStatus(error.status); return translateCode(t, "apiErrors", errorCode, fallbackKey); } From 7001308636fa4d73a897b4b1b57f39e5576e4878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 19:56:59 +0100 Subject: [PATCH 06/10] fix(measurements): mark an implausible edited value on its field Typing 720 instead of 72.0 in the measurement edit sheet showed the English banner "Validation failed" in every locale, with no field marked. The route refuses a changed value outside the type's band with a 422 whose issue list names the field, and the client dropped that list. The sheet now checks the same band before sending and says, under the value input and in the unit it shows, which range is accepted. ApiError carries the field paths of a refusal, so a value the server still refuses lands on the field too; any other refusal goes through localizedApiError instead of printing the server's prose. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/ko.json | 3 +- messages/nl.json | 3 +- messages/pl.json | 3 +- messages/pt-BR.json | 3 +- messages/ru.json | 3 +- .../__tests__/measurement-edit-range.test.ts | 74 ++++++++++++ .../list/measurement-edit-sheet.tsx | 10 +- .../measurements/list/use-measurement-edit.ts | 110 ++++++++++++++---- src/lib/api/__tests__/api-fetch.test.ts | 32 +++++ src/lib/api/api-fetch.ts | 23 +++- 15 files changed, 243 insertions(+), 36 deletions(-) create mode 100644 src/components/measurements/__tests__/measurement-edit-range.test.ts diff --git a/messages/de.json b/messages/de.json index c35f312d1..38a6915f3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -986,7 +986,8 @@ "typeStressLevel": "Stresslevel", "typeTrainingReadiness": "Trainingsbereitschaft", "typeAcuteTrainingLoad": "Akute Trainingsbelastung", - "sourceGarmin": "Garmin" + "sourceGarmin": "Garmin", + "valueOutOfRange": "Gib einen Wert zwischen {min} und {max} {unit} ein." }, "metricCatalog": { "title": "Was HealthLog erfassen kann", diff --git a/messages/en.json b/messages/en.json index 29d5ddf0b..f237a82cf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -986,7 +986,8 @@ "typeStressLevel": "Stress level", "typeTrainingReadiness": "Training readiness", "typeAcuteTrainingLoad": "Acute training load", - "sourceGarmin": "Garmin" + "sourceGarmin": "Garmin", + "valueOutOfRange": "Enter a value between {min} and {max} {unit}." }, "metricCatalog": { "title": "What HealthLog can track", diff --git a/messages/es.json b/messages/es.json index 71ee4da7e..78b9b5535 100644 --- a/messages/es.json +++ b/messages/es.json @@ -986,7 +986,8 @@ "typeStressLevel": "Nivel de estrés", "typeTrainingReadiness": "Preparación para entrenar", "typeAcuteTrainingLoad": "Carga de entrenamiento aguda", - "sourceGarmin": "Garmin" + "sourceGarmin": "Garmin", + "valueOutOfRange": "Introduce un valor entre {min} y {max} {unit}." }, "metricCatalog": { "title": "Lo que HealthLog puede registrar", diff --git a/messages/fr.json b/messages/fr.json index 8d7b7a40c..ca9d45fbf 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -986,7 +986,8 @@ "typeStressLevel": "Niveau de stress", "typeTrainingReadiness": "Disponibilité à l'entraînement", "typeAcuteTrainingLoad": "Charge d'entraînement aiguë", - "sourceGarmin": "Garmin" + "sourceGarmin": "Garmin", + "valueOutOfRange": "Saisissez une valeur entre {min} et {max} {unit}." }, "metricCatalog": { "title": "Ce que HealthLog peut suivre", diff --git a/messages/it.json b/messages/it.json index 02478f1e1..50bc0e36f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -986,7 +986,8 @@ "typeStressLevel": "Livello di stress", "typeTrainingReadiness": "Prontezza all'allenamento", "typeAcuteTrainingLoad": "Carico di allenamento acuto", - "sourceGarmin": "Garmin" + "sourceGarmin": "Garmin", + "valueOutOfRange": "Inserisci un valore tra {min} e {max} {unit}." }, "metricCatalog": { "title": "Cosa può monitorare HealthLog", diff --git a/messages/ko.json b/messages/ko.json index fd4860a74..7fc9fdf61 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -986,7 +986,8 @@ "typeStressLevel": "스트레스 수준", "typeTrainingReadiness": "트레이닝 준비도", "typeAcuteTrainingLoad": "급성 훈련 부하", - "sourceGarmin": "Garmin" + "sourceGarmin": "Garmin", + "valueOutOfRange": "{min}에서 {max} {unit} 사이의 값을 입력해 주세요." }, "metricCatalog": { "title": "HealthLog가 기록할 수 있는 것", diff --git a/messages/nl.json b/messages/nl.json index 6680b0928..0e5b745b5 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -986,7 +986,8 @@ "typeStressLevel": "Stressniveau", "typeTrainingReadiness": "Trainingsgereedheid", "typeAcuteTrainingLoad": "Acute trainingsbelasting", - "sourceGarmin": "Garmin" + "sourceGarmin": "Garmin", + "valueOutOfRange": "Vul een waarde in tussen {min} en {max} {unit}." }, "metricCatalog": { "title": "Wat HealthLog kan bijhouden", diff --git a/messages/pl.json b/messages/pl.json index 02ca079b0..c46749950 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -986,7 +986,8 @@ "typeStressLevel": "Poziom stresu", "typeTrainingReadiness": "Gotowość do treningu", "typeAcuteTrainingLoad": "Ostre obciążenie treningowe", - "sourceGarmin": "Garmin" + "sourceGarmin": "Garmin", + "valueOutOfRange": "Wpisz wartość od {min} do {max} {unit}." }, "metricCatalog": { "title": "Co HealthLog potrafi śledzić", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 18df65df1..cbe230a62 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -986,7 +986,8 @@ "typeStressLevel": "Nível de estresse", "typeTrainingReadiness": "Prontidão para treinar", "typeAcuteTrainingLoad": "Carga de treino aguda", - "sourceGarmin": "Garmin" + "sourceGarmin": "Garmin", + "valueOutOfRange": "Informe um valor entre {min} e {max} {unit}." }, "metricCatalog": { "title": "O que o HealthLog pode acompanhar", diff --git a/messages/ru.json b/messages/ru.json index 4cf579664..4fabe3cfa 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -986,7 +986,8 @@ "typeStressLevel": "Уровень стресса", "typeTrainingReadiness": "Готовность к тренировке", "typeAcuteTrainingLoad": "Острая тренировочная нагрузка", - "sourceGarmin": "Garmin" + "sourceGarmin": "Garmin", + "valueOutOfRange": "Введите значение от {min} до {max} {unit}." }, "metricCatalog": { "title": "Что умеет отслеживать HealthLog", diff --git a/src/components/measurements/__tests__/measurement-edit-range.test.ts b/src/components/measurements/__tests__/measurement-edit-range.test.ts new file mode 100644 index 000000000..fa62aef1d --- /dev/null +++ b/src/components/measurements/__tests__/measurement-edit-range.test.ts @@ -0,0 +1,74 @@ +/** + * The edit sheet refuses an implausible value at the value field, before the + * request, in the unit the input shows. The server checks the same band and + * used to be the only one that did: its 422 reached the sheet as the English + * banner "Validation failed", in every locale, with no field marked. + */ +import { describe, expect, it } from "vitest"; + +import type { UnitDisplay } from "@/hooks/use-unit-display"; +import { getUnitForType } from "@/lib/measurements/unit-map"; +import { editValueRange } from "../list/use-measurement-edit"; +import { rowDisplayFor } from "../list/use-measurement-row-display"; + +const KG_PER_LB = 0.45359237; + +function display( + preference: "metric" | "imperial", + glucose: "mg/dL" | "mmol/L", +) { + const imperial = preference === "imperial"; + const unitDisplay = { + preference, + isTransformed: (type: string) => imperial && type === "WEIGHT", + toDisplay: (type: string, v: number) => + imperial && type === "WEIGHT" ? v / KG_PER_LB : v, + fromDisplay: (type: string, v: number) => + imperial && type === "WEIGHT" ? v * KG_PER_LB : v, + unitFor: (type: string) => + imperial && type === "WEIGHT" ? "lb" : getUnitForType(type), + } as unknown as UnitDisplay; + return rowDisplayFor(glucose, unitDisplay); +} + +const row = (type: string, unit: string) => ({ + id: "m1", + type, + value: 72, + unit, + source: "MANUAL", + measuredAt: "2026-11-10T08:00:00.000Z", + notes: null, +}); + +describe("editValueRange", () => { + it("names the band in the stored unit for a metric reader", () => { + expect( + editValueRange(display("metric", "mg/dL"), row("WEIGHT", "kg")), + ).toEqual({ min: 1, max: 500, unit: "kg" }); + }); + + it("names it in pounds for an imperial reader, who types pounds", () => { + const range = editValueRange( + display("imperial", "mg/dL"), + row("WEIGHT", "kg"), + ); + expect(range?.unit).toBe("lb"); + expect(range?.max).toBeCloseTo(1102.3, 1); + }); + + it("names it in mmol/L for a glucose edit in mmol/L", () => { + const range = editValueRange( + display("metric", "mmol/L"), + row("BLOOD_GLUCOSE", "mg/dL"), + ); + expect(range?.unit).toBe("mmol/L"); + expect(range!.max).toBeLessThan(100); + }); + + it("has nothing to say for a type without a band", () => { + expect( + editValueRange(display("metric", "mg/dL"), row("NO_SUCH_TYPE", "x")), + ).toBeNull(); + }); +}); diff --git a/src/components/measurements/list/measurement-edit-sheet.tsx b/src/components/measurements/list/measurement-edit-sheet.tsx index 8a8200370..f0576b6e6 100644 --- a/src/components/measurements/list/measurement-edit-sheet.tsx +++ b/src/components/measurements/list/measurement-edit-sheet.tsx @@ -3,6 +3,7 @@ import { createPortal } from "react-dom"; import { Loader2, MoreHorizontal, Trash2 } from "lucide-react"; +import { FieldError } from "@/components/forms/field-error"; import { SeededFormDiscardDialog } from "@/components/forms/use-seeded-form-dismissal"; import { AlertDialog, @@ -57,6 +58,8 @@ export function MeasurementEditSheet({ setEditValue, editError, editErrorDescriptor, + editValueError, + editValueErrorId, editMeasuredAt, setEditMeasuredAt, editNotes, @@ -103,9 +106,12 @@ export function MeasurementEditSheet({ onChange={(e) => setEditValue(e.target.value)} required aria-required="true" - aria-invalid={!!editError || undefined} - aria-describedby={editErrorDescriptor} + aria-invalid={!!(editValueError || editError) || undefined} + aria-describedby={ + editValueError ? editValueErrorId : editErrorDescriptor + } /> +
diff --git a/src/components/measurements/list/use-measurement-edit.ts b/src/components/measurements/list/use-measurement-edit.ts index f4213ba97..8a524db5c 100644 --- a/src/components/measurements/list/use-measurement-edit.ts +++ b/src/components/measurements/list/use-measurement-edit.ts @@ -5,9 +5,14 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useSeededFormDismissal } from "@/components/forms/use-seeded-form-dismissal"; import { ApiError, apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { toCanonicalMgdl } from "@/lib/glucose"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { getUnitForType } from "@/lib/measurements/unit-map"; +import { + VALUE_RANGES, + validateMeasurementRange, +} from "@/lib/measurements/value-ranges"; import { invalidateKeys, measurementDependentKeys, @@ -78,6 +83,58 @@ export function storedValueFor( return parsedValue; } +/** + * The stored value's plausible band for the row's type, in the unit the edit + * input shows, or null for a type without one. The same table the server + * checks the edit against. + */ +export function editValueRange( + { rowDisplay }: Pick, + measurement: Measurement, +): { min: number; max: number; unit: string } | null { + const range = VALUE_RANGES[measurement.type]; + if (!range) return null; + const low = rowDisplay({ ...measurement, value: range.min }); + const high = rowDisplay({ ...measurement, value: range.max }); + const round = (n: number) => Math.round(n * 10) / 10; + return { min: round(low.value), max: round(high.value), unit: low.unit }; +} + +type Translate = ReturnType["t"]; + +/** The sentence for a value outside its band, in the unit the input shows. */ +function outOfRangeMessage( + display: Pick, + measurement: Measurement, + t: Translate, + formatNumber: (value: number) => string, +): string { + const range = editValueRange(display, measurement); + return range + ? t("measurements.valueOutOfRange", { + min: formatNumber(range.min), + max: formatNumber(range.max), + unit: range.unit, + }) + : t("measurements.invalidValue"); +} + +/** + * The banner for a refused save. A 409 names its reason in `meta.errorCode`: + * the edit collides with another reading's (type, time, source, stage), or + * the reading belongs to a server-owned source (connector, import, computed). + */ +function saveRefusal(err: unknown, t: Translate): string { + const code = err instanceof ApiError ? err.meta?.errorCode : undefined; + if (code === "measurement.duplicate_timestamp") { + return t("measurements.duplicateTimestamp"); + } + if (code === "measurement.update.server_owned_source") { + return t("measurements.serverOwnedSource"); + } + return localizedApiError(err, t, "measurements.saveError"); +} + /** * The edit sheet's state: the reading being edited, the draft seeded in the * person's display unit, the save (converted back to the stored unit) and @@ -94,6 +151,7 @@ export function useMeasurementEdit({ deleteMutation: MeasurementListData["deleteMutation"]; }) { const { t } = useTranslations(); + const fmt = useFormatters(); const queryClient = useQueryClient(); const [editing, setEditing] = useState(null); const [editValue, setEditValue] = useState(""); @@ -105,10 +163,13 @@ export function useMeasurementEdit({ notes: "", }); const [editError, setEditError] = useState(null); + // A refused value is said under the value input, not in the banner. + const [editValueError, setEditValueError] = useState(null); const [editDeleteDialogOpen, setEditDeleteDialogOpen] = useState(false); // The error banner is linked to the inputs through `aria-describedby`, so a // screen reader announces why a submit was refused. const editErrorId = useId(); + const editValueErrorId = useId(); // The sheet's footer is a slot the form portals its buttons into. const editFormId = useId(); const [editFooterEl, setEditFooterEl] = useState(null); @@ -138,28 +199,19 @@ export function useMeasurementEdit({ void refetchInactiveDailyReads(queryClient); }, onError: (err) => { - // A 409 names its reason in `meta.errorCode`: the edit collides with - // another reading's (type, time, source, stage), or the reading belongs - // to a server-owned source (connector, import, computed). - if ( - err instanceof ApiError && - err.meta?.errorCode === "measurement.duplicate_timestamp" - ) { - setEditError(t("measurements.duplicateTimestamp")); - return; + if (err instanceof ApiError && err.issuePaths.includes("value")) { + setEditValueError(valueRefusal()); + } else { + setEditError(saveRefusal(err, t)); } - if ( - err instanceof ApiError && - err.meta?.errorCode === "measurement.update.server_owned_source" - ) { - setEditError(t("measurements.serverOwnedSource")); - return; - } - setEditError( - err instanceof Error ? err.message : t("measurements.saveError"), - ); }, }); + + function valueRefusal(): string { + return editing + ? outOfRangeMessage(display, editing, t, fmt.number) + : t("measurements.invalidValue"); + } const editDismissal = useSeededFormDismissal({ seed: editSeed, value: { @@ -188,11 +240,13 @@ export function useMeasurementEdit({ setEditNotes(seed.notes); setEditSeed(seed); setEditError(null); + setEditValueError(null); } function dismissEdit() { setEditing(null); setEditError(null); + setEditValueError(null); setEditDeleteDialogOpen(false); } @@ -216,9 +270,11 @@ export function useMeasurementEdit({ e.preventDefault(); if (!editing) return; + setEditError(null); + setEditValueError(null); const parsedValue = Number(editValue); if (!Number.isFinite(parsedValue)) { - setEditError(t("measurements.invalidValue")); + setEditValueError(t("measurements.invalidValue")); return; } @@ -229,8 +285,16 @@ export function useMeasurementEdit({ } const canonicalValue = storedValueFor(display, editing, parsedValue); + // The server refuses a changed value outside the type's band; say so at + // the field before sending, in the unit the input shows. + if ( + editValue !== editSeed.value && + validateMeasurementRange(editing.type, canonicalValue) !== null + ) { + setEditValueError(valueRefusal()); + return; + } - setEditError(null); updateMutation.mutate({ id: editing.id, value: canonicalValue, @@ -250,6 +314,8 @@ export function useMeasurementEdit({ editError, editErrorId, editErrorDescriptor: editError ? editErrorId : undefined, + editValueError, + editValueErrorId, editFormId, editFooterEl, setEditFooterEl, diff --git a/src/lib/api/__tests__/api-fetch.test.ts b/src/lib/api/__tests__/api-fetch.test.ts index e526e6f12..e876a0ee2 100644 --- a/src/lib/api/__tests__/api-fetch.test.ts +++ b/src/lib/api/__tests__/api-fetch.test.ts @@ -190,6 +190,38 @@ describe("apiFetchEnvelope", () => { }); }); +describe("ApiError.issuePaths", () => { + it("names the fields a 422's issue list refused", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse( + { + data: null, + error: "Validation failed", + details: { + issues: [ + { path: "value", code: "custom", message: "out of range" }, + ], + }, + }, + 422, + ), + ); + const error = await apiPut("/api/measurements/m1", { value: 720 }).catch( + (e: unknown) => e, + ); + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).issuePaths).toEqual(["value"]); + }); + + it("is empty when the refusal named no field", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ data: null, error: "Nope" }, 409), + ); + const error = await apiGet("/api/x").catch((e: unknown) => e); + expect((error as ApiError).issuePaths).toEqual([]); + }); +}); + describe("apiFetchRaw", () => { it("returns the raw Response without ok-check or unwrap", async () => { const res = new Response("plain", { status: 500 }); diff --git a/src/lib/api/api-fetch.ts b/src/lib/api/api-fetch.ts index 6f2648c1d..2a6adcd8b 100644 --- a/src/lib/api/api-fetch.ts +++ b/src/lib/api/api-fetch.ts @@ -17,7 +17,8 @@ * through `readError` (server envelope `error` string, falling back * to `Request failed ()`), `status` carrying the HTTP code, * `meta` carrying the error envelope's `meta` object when present - * (e.g. `meta.errorCode`). + * (e.g. `meta.errorCode`), `issuePaths` the fields a 422's + * `details.issues` names. * - Network failures reject with the native `TypeError` from `fetch`, * exactly as before — only HTTP-level failures become `ApiError`. * - 401 / 403 / 429 are NOT special-cased: the wrapper throws the same @@ -70,17 +71,24 @@ export class ApiError extends Error { * server). Its first characters are the error id a person reads out. */ readonly requestId: string | null; + /** + * The fields a refusal's `details.issues` names (`"value"`, `"measuredAt"`), + * so a form can mark the one the server refused. Empty when it named none. + */ + readonly issuePaths: readonly string[]; constructor( message: string, status: number, meta?: Record, requestId?: string | null, + issuePaths: readonly string[] = [], ) { super(message); this.name = "ApiError"; this.status = status; this.meta = meta; + this.issuePaths = issuePaths; const fromMeta = typeof meta?.requestId === "string" && meta.requestId.length > 0 ? meta.requestId @@ -97,11 +105,21 @@ interface Envelope> { async function throwApiError(res: Response): Promise { let meta: Record | undefined; + let issuePaths: string[] = []; try { - const json = (await res.clone().json()) as { meta?: unknown }; + const json = (await res.clone().json()) as { + meta?: unknown; + details?: { issues?: unknown }; + }; if (json && typeof json.meta === "object" && json.meta !== null) { meta = json.meta as Record; } + const issues = json?.details?.issues; + if (Array.isArray(issues)) { + issuePaths = issues.flatMap((issue: { path?: unknown }) => + typeof issue?.path === "string" ? [issue.path] : [], + ); + } } catch { /* non-JSON body — readError supplies the fallback message */ } @@ -110,6 +128,7 @@ async function throwApiError(res: Response): Promise { res.status, meta, res.headers.get("x-request-id"), + issuePaths, ); // A server-side failure is worth an id in the session's support info, // whether or not the call site ends up showing it. From 121b23f4a2aaff6ec8c724464e8f0b071d38b4cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 19:57:06 +0100 Subject: [PATCH 07/10] fix(forms): show the form's own sentence when a save is refused The custom metric, allergy, vaccination, booster, visit, address-book, workout and quick-add dose forms printed the server's error string as it came: English in every locale, and for a network failure the browser's own TypeError text. They now resolve the error code through localizedApiError like the other entry forms, so a known refusal reads in the person's language and anything else falls back to the form's "could not save" sentence. A custom metric whose name is taken keeps its specific message, now translated. The workout form's browser test expected the server prose and now expects the form's sentence. --- messages/de.json | 3 ++- messages/en.json | 3 ++- messages/es.json | 3 ++- messages/fr.json | 3 ++- messages/it.json | 3 ++- messages/ko.json | 3 ++- messages/nl.json | 3 ++- messages/pl.json | 3 ++- messages/pt-BR.json | 3 ++- messages/ru.json | 3 ++- .../custom-metrics/custom-metric-entry-form.tsx | 9 +++------ src/components/custom-metrics/custom-metric-form.tsx | 11 ++++++----- .../custom-metrics/custom-metric-history-list.tsx | 9 +++------ .../dashboard/medication-intake-quick-add.tsx | 11 +++++++---- src/components/encounters/encounter-sheet.tsx | 4 ++-- src/components/practitioners/practitioner-sheet.tsx | 4 ++-- src/components/records/allergy-form.tsx | 9 +++------ src/components/vaccinations/booster-mint-prompt.tsx | 8 ++++---- src/components/vaccinations/vaccination-sheet.tsx | 4 ++-- .../__tests__/manual-workout-form.browser.test.tsx | 3 ++- src/components/workouts/manual-workout-form.tsx | 7 +++---- 21 files changed, 57 insertions(+), 52 deletions(-) diff --git a/messages/de.json b/messages/de.json index 38a6915f3..c1908fddc 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12536,7 +12536,8 @@ "save": "Speichern", "savedToast": "Eigene Metrik gespeichert", "updatedToast": "Eigene Metrik aktualisiert", - "saveError": "Eigene Metrik konnte nicht gespeichert werden" + "saveError": "Eigene Metrik konnte nicht gespeichert werden", + "nameTaken": "Es gibt bereits eine eigene Metrik mit diesem Namen." }, "entry": { "value": "Wert", diff --git a/messages/en.json b/messages/en.json index f237a82cf..ca52adbf4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12536,7 +12536,8 @@ "save": "Save", "savedToast": "Custom metric saved", "updatedToast": "Custom metric updated", - "saveError": "Could not save the custom metric" + "saveError": "Could not save the custom metric", + "nameTaken": "A custom metric with this name already exists." }, "entry": { "value": "Value", diff --git a/messages/es.json b/messages/es.json index 78b9b5535..17dde5112 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12536,7 +12536,8 @@ "save": "Guardar", "savedToast": "Métrica personalizada guardada", "updatedToast": "Métrica personalizada actualizada", - "saveError": "No se pudo guardar la métrica personalizada" + "saveError": "No se pudo guardar la métrica personalizada", + "nameTaken": "Ya existe una métrica personalizada con este nombre." }, "entry": { "value": "Valor", diff --git a/messages/fr.json b/messages/fr.json index ca9d45fbf..c73ad4505 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12536,7 +12536,8 @@ "save": "Enregistrer", "savedToast": "Mesure personnalisée enregistrée", "updatedToast": "Mesure personnalisée mise à jour", - "saveError": "Impossible d'enregistrer la mesure personnalisée" + "saveError": "Impossible d'enregistrer la mesure personnalisée", + "nameTaken": "Une mesure personnalisée portant ce nom existe déjà." }, "entry": { "value": "Valeur", diff --git a/messages/it.json b/messages/it.json index 50bc0e36f..7883cfb66 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12536,7 +12536,8 @@ "save": "Salva", "savedToast": "Metrica personalizzata salvata", "updatedToast": "Metrica personalizzata aggiornata", - "saveError": "Impossibile salvare la metrica personalizzata" + "saveError": "Impossibile salvare la metrica personalizzata", + "nameTaken": "Esiste già una metrica personalizzata con questo nome." }, "entry": { "value": "Valore", diff --git a/messages/ko.json b/messages/ko.json index 7fc9fdf61..23255f05e 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12536,7 +12536,8 @@ "save": "저장", "savedToast": "사용자 지표를 저장했어요", "updatedToast": "사용자 지표를 수정했어요", - "saveError": "사용자 지표를 저장하지 못했어요" + "saveError": "사용자 지표를 저장하지 못했어요", + "nameTaken": "이 이름의 사용자 지표가 이미 있어요." }, "entry": { "value": "값", diff --git a/messages/nl.json b/messages/nl.json index 0e5b745b5..6387a747e 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -12536,7 +12536,8 @@ "save": "Opslaan", "savedToast": "Eigen meting opgeslagen", "updatedToast": "Eigen meting bijgewerkt", - "saveError": "De eigen meting kon niet worden opgeslagen" + "saveError": "De eigen meting kon niet worden opgeslagen", + "nameTaken": "Er bestaat al een eigen meting met deze naam." }, "entry": { "value": "Waarde", diff --git a/messages/pl.json b/messages/pl.json index c46749950..505248df9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12536,7 +12536,8 @@ "save": "Zapisz", "savedToast": "Zapisano metrykę własną", "updatedToast": "Zaktualizowano metrykę własną", - "saveError": "Nie udało się zapisać metryki własnej" + "saveError": "Nie udało się zapisać metryki własnej", + "nameTaken": "Metryka własna o tej nazwie już istnieje." }, "entry": { "value": "Wartość", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index cbe230a62..512eb208f 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -12536,7 +12536,8 @@ "save": "Salvar", "savedToast": "Métrica personalizada salva", "updatedToast": "Métrica personalizada atualizada", - "saveError": "Não foi possível salvar a métrica personalizada" + "saveError": "Não foi possível salvar a métrica personalizada", + "nameTaken": "Já existe uma métrica personalizada com este nome." }, "entry": { "value": "Valor", diff --git a/messages/ru.json b/messages/ru.json index 4fabe3cfa..478b17ba2 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -12536,7 +12536,8 @@ "save": "Сохранить", "savedToast": "Свой показатель сохранён", "updatedToast": "Свой показатель обновлён", - "saveError": "Не удалось сохранить свой показатель" + "saveError": "Не удалось сохранить свой показатель", + "nameTaken": "Свой показатель с таким названием уже существует." }, "entry": { "value": "Значение", diff --git a/src/components/custom-metrics/custom-metric-entry-form.tsx b/src/components/custom-metrics/custom-metric-entry-form.tsx index b8236d3cf..01dbc2212 100644 --- a/src/components/custom-metrics/custom-metric-entry-form.tsx +++ b/src/components/custom-metrics/custom-metric-entry-form.tsx @@ -11,7 +11,8 @@ import { DateTimeField } from "@/components/ui/date-time-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; -import { ApiError, apiPost } from "@/lib/api/api-fetch"; +import { apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -99,11 +100,7 @@ export function CustomMetricEntryForm({ }); onSuccess?.(); } catch (err) { - const message = - err instanceof ApiError - ? err.message - : t("customMetrics.entry.saveError"); - setError(message); + setError(localizedApiError(err, t, "customMetrics.entry.saveError")); } finally { setSubmitting(false); } diff --git a/src/components/custom-metrics/custom-metric-form.tsx b/src/components/custom-metrics/custom-metric-form.tsx index 5c59f4e1e..ee599f7b1 100644 --- a/src/components/custom-metrics/custom-metric-form.tsx +++ b/src/components/custom-metrics/custom-metric-form.tsx @@ -11,6 +11,7 @@ import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { ApiError, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import type { CustomMetricDto } from "./types"; @@ -122,11 +123,11 @@ export function CustomMetricForm({ ); onSuccess?.(saved); } catch (err) { - const message = - err instanceof ApiError - ? err.message - : t("customMetrics.form.saveError"); - setError(message); + setError( + err instanceof ApiError && err.status === 409 + ? t("customMetrics.form.nameTaken") + : localizedApiError(err, t, "customMetrics.form.saveError"), + ); } finally { setSubmitting(false); } diff --git a/src/components/custom-metrics/custom-metric-history-list.tsx b/src/components/custom-metrics/custom-metric-history-list.tsx index 5e2b60dfa..01930fea3 100644 --- a/src/components/custom-metrics/custom-metric-history-list.tsx +++ b/src/components/custom-metrics/custom-metric-history-list.tsx @@ -22,7 +22,8 @@ import { } from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; import { useTableSort } from "@/hooks/use-table-sort"; -import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { formatDateShort } from "@/lib/format"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -140,11 +141,7 @@ export function CustomMetricHistoryList({ closeEdit(); }, onError: (err) => { - setEditError( - err instanceof ApiError - ? err.message - : t("customMetrics.entry.editError"), - ); + setEditError(localizedApiError(err, t, "customMetrics.entry.editError")); }, }); diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx index 38bd99fcf..1c63d9ae6 100644 --- a/src/components/dashboard/medication-intake-quick-add.tsx +++ b/src/components/dashboard/medication-intake-quick-add.tsx @@ -27,7 +27,8 @@ import { } from "@/lib/medications/default-medication"; import { useAuth } from "@/hooks/use-auth"; import { useActiveRecordName } from "@/hooks/use-record-capabilities"; -import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { apiGet, apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { intakeToastOptions, runUndoIntake, @@ -270,9 +271,11 @@ export function MedicationIntakeQuickAdd({ void invalidateMedicationReads(queryClient); } catch (err) { setError( - err instanceof ApiError && err.message - ? err.message - : t("dashboard.medicationIntakeQuickAdd.saveError"), + localizedApiError( + err, + t, + "dashboard.medicationIntakeQuickAdd.saveError", + ), ); } finally { setLoading(false); diff --git a/src/components/encounters/encounter-sheet.tsx b/src/components/encounters/encounter-sheet.tsx index 4c0eefcd0..378636604 100644 --- a/src/components/encounters/encounter-sheet.tsx +++ b/src/components/encounters/encounter-sheet.tsx @@ -49,6 +49,7 @@ import { import { Button } from "@/components/ui/button"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { useEncounterMutations, type Encounter } from "@/hooks/use-encounters"; import { @@ -128,8 +129,7 @@ export function EncounterSheet({ onSaved?.(saved); onOpenChange(false); } catch (err) { - const message = - err instanceof Error ? err.message : t("encounters.saveFailed"); + const message = localizedApiError(err, t, "encounters.saveFailed"); setError(message); showErrorToast(message, { cause: err }); } diff --git a/src/components/practitioners/practitioner-sheet.tsx b/src/components/practitioners/practitioner-sheet.tsx index f9654f691..54118fa5a 100644 --- a/src/components/practitioners/practitioner-sheet.tsx +++ b/src/components/practitioners/practitioner-sheet.tsx @@ -20,6 +20,7 @@ import { FieldGroup } from "@/components/ui/field-group"; import { Input } from "@/components/ui/input"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Textarea } from "@/components/ui/textarea"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { usePractitionerMutations, @@ -104,8 +105,7 @@ export function PractitionerSheet({ onSaved?.(saved); onOpenChange(false); } catch (err) { - const message = - err instanceof Error ? err.message : t("practitioners.saveFailed"); + const message = localizedApiError(err, t, "practitioners.saveFailed"); setError(message); showErrorToast(message, { cause: err }); } diff --git a/src/components/records/allergy-form.tsx b/src/components/records/allergy-form.tsx index 341c01b3d..1270ce001 100644 --- a/src/components/records/allergy-form.tsx +++ b/src/components/records/allergy-form.tsx @@ -17,7 +17,8 @@ import { SelectValue, } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; -import { ApiError, apiPost, apiPut } from "@/lib/api/api-fetch"; +import { apiPost, apiPut } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { allergyCategoryEnum, allergySeverityEnum, @@ -113,11 +114,7 @@ export function AllergyForm({ ); onSuccess?.(saved); } catch (err) { - const message = - err instanceof ApiError - ? err.message - : t("records.allergies.form.saveError"); - setError(message); + setError(localizedApiError(err, t, "records.allergies.form.saveError")); } finally { setSubmitting(false); } diff --git a/src/components/vaccinations/booster-mint-prompt.tsx b/src/components/vaccinations/booster-mint-prompt.tsx index 49707c3ef..3f1d38390 100644 --- a/src/components/vaccinations/booster-mint-prompt.tsx +++ b/src/components/vaccinations/booster-mint-prompt.tsx @@ -33,6 +33,7 @@ import { import { FieldGroup } from "@/components/ui/field-group"; import { Input } from "@/components/ui/input"; import { NativeSelect } from "@/components/ui/native-select"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { customLookupOf, @@ -105,10 +106,9 @@ export function BoosterMintPrompt({ }); toastWrittenOutcome("success", t("vaccinations.booster.planned")); } catch (err) { - showErrorToast( - err instanceof Error ? err.message : t("vaccinations.booster.failed"), - { cause: err }, - ); + showErrorToast(localizedApiError(err, t, "vaccinations.booster.failed"), { + cause: err, + }); } finally { onClose(); } diff --git a/src/components/vaccinations/vaccination-sheet.tsx b/src/components/vaccinations/vaccination-sheet.tsx index 40a8cce42..7bc0cbb6a 100644 --- a/src/components/vaccinations/vaccination-sheet.tsx +++ b/src/components/vaccinations/vaccination-sheet.tsx @@ -36,6 +36,7 @@ import { import { Button } from "@/components/ui/button"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { RelatedRecordsBlock } from "@/components/links/related-records"; import type { RelatedLink } from "@/lib/links/related"; @@ -99,8 +100,7 @@ export function VaccinationSheet({ onCreated?.(created); } } catch (err) { - const message = - err instanceof Error ? err.message : t("vaccinations.saveFailed"); + const message = localizedApiError(err, t, "vaccinations.saveFailed"); setError(message); showErrorToast(message, { cause: err }); } diff --git a/src/components/workouts/__tests__/manual-workout-form.browser.test.tsx b/src/components/workouts/__tests__/manual-workout-form.browser.test.tsx index 305a63e6d..de5105900 100644 --- a/src/components/workouts/__tests__/manual-workout-form.browser.test.tsx +++ b/src/components/workouts/__tests__/manual-workout-form.browser.test.tsx @@ -128,9 +128,10 @@ describe("", () => { await pickSport("Running"); await screen.getByLabelText("Minutes").fill("30"); await save.click(); + // The form's own sentence, never the server's English prose. await expect .element(screen.getByRole("alert")) - .toHaveTextContent("Service unavailable"); + .toHaveTextContent("The workout could not be saved"); expect(onSuccess).not.toHaveBeenCalled(); api.on("POST", BATCH, { diff --git a/src/components/workouts/manual-workout-form.tsx b/src/components/workouts/manual-workout-form.tsx index b10aafc1d..363d200c6 100644 --- a/src/components/workouts/manual-workout-form.tsx +++ b/src/components/workouts/manual-workout-form.tsx @@ -21,7 +21,8 @@ import { } from "@/components/ui/select"; import { useAuth } from "@/hooks/use-auth"; import { useUnitDisplay } from "@/hooks/use-unit-display"; -import { ApiError, apiPost } from "@/lib/api/api-fetch"; +import { apiPost } from "@/lib/api/api-fetch"; +import { localizedApiError } from "@/lib/api/localized-error"; import { useTranslations } from "@/lib/i18n/context"; import { getQuantityTransform } from "@/lib/measurements/display-transform"; import { @@ -232,9 +233,7 @@ export function ManualWorkoutForm({ onSuccess?.(); } catch (err) { setSaveError( - err instanceof ApiError && err.message - ? err.message - : t("insights.workouts.manual.saveError"), + localizedApiError(err, t, "insights.workouts.manual.saveError"), ); } finally { setSaving(false); From f761500247eb3bccc98905eba4eacc197af47b2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:02:40 +0100 Subject: [PATCH 08/10] fix(forms): send one Idempotency-Key per opened create form A mood, symptom, lab value, custom metric value, unscheduled dose, allergy, vaccination, visit or measurement saved on a weak signal could be stored twice: the POST committed, the answer missed the 15 s timeout, the form said it could not save, and the second tap was a new call with a new key, so the server ran it again. useIdempotentPost keeps one key until a save lands and renews it for the next entry, so the retry replays the first answer. The create forms and the three create hooks post through it; the comment on the per-call key in api-fetch now says what that key does and does not cover. LabForm and MoodForm each gain the one line that mounts the key, so their function-size baselines go up by one. The intake toast guard now recognises a dose posted through the hook. --- .../baselines/source-function-lines.json | 4 +- .../intake-toast-single-decision.test.ts | 21 +++++- .../custom-metric-entry-form.tsx | 5 +- .../medication-intake-quick-add.test.tsx | 2 +- .../dashboard/medication-intake-quick-add.tsx | 6 +- src/components/labs/lab-form.tsx | 6 +- .../measurements/measurement-form.tsx | 9 ++- src/components/mood/mood-form.tsx | 5 +- src/components/records/allergy-form.tsx | 6 +- src/components/symptoms/use-symptoms.ts | 5 +- .../vaccinations/use-vaccinations.ts | 6 +- src/hooks/use-encounters.ts | 13 ++-- .../__tests__/use-idempotent-post.test.tsx | 75 +++++++++++++++++++ src/lib/api/api-fetch.ts | 11 ++- src/lib/api/use-idempotent-post.ts | 33 ++++++++ 15 files changed, 172 insertions(+), 35 deletions(-) create mode 100644 src/lib/api/__tests__/use-idempotent-post.test.tsx create mode 100644 src/lib/api/use-idempotent-post.ts diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 7c156b9d6..97ec5e369 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -134,7 +134,7 @@ "components/labs/biomarker-merge-sheet.tsx :: BiomarkerMergeSheet": 239, "components/labs/lab-biomarker-chart.tsx :: LabBiomarkerChart": 370, "components/labs/lab-biomarker-detail.tsx :: LabBiomarkerDetail": 481, - "components/labs/lab-form.tsx :: LabForm": 473, + "components/labs/lab-form.tsx :: LabForm": 474, "components/labs/lab-history-list.tsx :: LabHistoryList": 534, "components/labs/ocr-review-dialog.tsx :: OcrReviewDialog": 359, "components/labs/ocr-row-editor.tsx :: OcrRowEditor": 378, @@ -173,7 +173,7 @@ "components/mental-health/mental-wellbeing.tsx :: MentalWellbeing": 202, "components/mood/manage/tag-groups-card.tsx :: TagGroupsCard": 256, "components/mood/manage/tag-manager-card.tsx :: TagManagerCard": 270, - "components/mood/mood-form.tsx :: MoodForm": 513, + "components/mood/mood-form.tsx :: MoodForm": 514, "components/mood/mood-list.tsx :: MoodList": 1109, "components/mood/mood-tag-picker.tsx :: MoodTagPicker": 257, "components/onboarding/confirm-screen.tsx :: ConfirmScreen": 255, diff --git a/src/__tests__/intake-toast-single-decision.test.ts b/src/__tests__/intake-toast-single-decision.test.ts index 0c3927a20..37d5faf6d 100644 --- a/src/__tests__/intake-toast-single-decision.test.ts +++ b/src/__tests__/intake-toast-single-decision.test.ts @@ -111,12 +111,31 @@ export function intakeFacts(source: string, fileName: string): IntakeFacts { callsHelper: false, }; + // A create form posts through the function `useIdempotentPost()` returns + // (one key per opened form), bound to a local name. + const posters = new Set(); + const findPosters = (node: ts.Node): void => { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer && + ts.isCallExpression(node.initializer) && + ts.isIdentifier(node.initializer.expression) && + (aliases.get(node.initializer.expression.text) ?? + node.initializer.expression.text) === "useIdempotentPost" + ) { + posters.add(node.name.text); + } + node.forEachChild(findPosters); + }; + parsed.forEachChild(findPosters); + const visit = (node: ts.Node): void => { if (ts.isCallExpression(node)) { const callee = node.expression; if (ts.isIdentifier(callee)) { const name = aliases.get(callee.text) ?? callee.text; - if (name === "apiPost") { + if (name === "apiPost" || posters.has(callee.text)) { const url = urlText(node); if (url !== null && url.endsWith("/intake")) facts.postsIntake = true; } diff --git a/src/components/custom-metrics/custom-metric-entry-form.tsx b/src/components/custom-metrics/custom-metric-entry-form.tsx index 01dbc2212..15078ba6e 100644 --- a/src/components/custom-metrics/custom-metric-entry-form.tsx +++ b/src/components/custom-metrics/custom-metric-entry-form.tsx @@ -11,8 +11,8 @@ import { DateTimeField } from "@/components/ui/date-time-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; -import { apiPost } from "@/lib/api/api-fetch"; import { localizedApiError } from "@/lib/api/localized-error"; +import { useIdempotentPost } from "@/lib/api/use-idempotent-post"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -56,6 +56,7 @@ export function CustomMetricEntryForm({ const { t } = useTranslations(); const formId = useId(); const queryClient = useQueryClient(); + const post = useIdempotentPost(); const [value, setValue] = useState(""); const [measuredAt, setMeasuredAt] = useState(() => nowLocal()); @@ -86,7 +87,7 @@ export function CustomMetricEntryForm({ setSubmitting(true); try { - await apiPost( + await post( `/api/custom-metrics/${customMetricId}/entries`, body, ); diff --git a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx index 1d7d1eed5..28ab5a317 100644 --- a/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx +++ b/src/components/dashboard/__tests__/medication-intake-quick-add.test.tsx @@ -399,7 +399,7 @@ describe("submit invalidation — the quick-add is a capture-picker surface, not // AFTER the intake POST landed, and after the sheet was told it is done: // the save does not wait for the refetches before it closes. expect(componentSrc).toMatch( - /async function handleSubmit\([\s\S]*?apiPost[\s\S]*?onSuccess\?\.\(\);\s*(?:\/\/[^\n]*\n\s*)*void invalidateMedicationReads\(queryClient\);/, + /async function handleSubmit\([\s\S]*?await post<[\s\S]*?onSuccess\?\.\(\);\s*(?:\/\/[^\n]*\n\s*)*void invalidateMedicationReads\(queryClient\);/, ); // The bundle carries every inline compliance chart, so no second sweep. expect(medicationDependentKeys).toContainEqual( diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx index 1c63d9ae6..0c662b618 100644 --- a/src/components/dashboard/medication-intake-quick-add.tsx +++ b/src/components/dashboard/medication-intake-quick-add.tsx @@ -27,7 +27,8 @@ import { } from "@/lib/medications/default-medication"; import { useAuth } from "@/hooks/use-auth"; import { useActiveRecordName } from "@/hooks/use-record-capabilities"; -import { apiGet, apiPost } from "@/lib/api/api-fetch"; +import { apiGet } from "@/lib/api/api-fetch"; +import { useIdempotentPost } from "@/lib/api/use-idempotent-post"; import { localizedApiError } from "@/lib/api/localized-error"; import { intakeToastOptions, @@ -119,6 +120,7 @@ export function MedicationIntakeQuickAdd({ }: MedicationIntakeQuickAddProps) { const { t } = useTranslations(); const queryClient = useQueryClient(); + const post = useIdempotentPost(); // v1.16.9 — the auto-pick reasons in the PROFILE timezone (the same // source the cards use); Berlin stays the last-resort fallback so // logged-out mounts behave unchanged. @@ -226,7 +228,7 @@ export function MedicationIntakeQuickAdd({ // v1.30.1 — the route returns the created event // (`apiSuccess(event, 201)`), same shape the card's take/skip path // already reads; the id drives the new Undo action below. - const created = await apiPost<{ id?: string } | undefined>( + const created = await post<{ id?: string } | undefined>( `/api/medications/${medicationId}/intake`, { takenAt: timestamp, diff --git a/src/components/labs/lab-form.tsx b/src/components/labs/lab-form.tsx index 83652c303..f28ccc36e 100644 --- a/src/components/labs/lab-form.tsx +++ b/src/components/labs/lab-form.tsx @@ -21,7 +21,8 @@ import { SelectValue, } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; -import { apiGet, apiPost } from "@/lib/api/api-fetch"; +import { apiGet } from "@/lib/api/api-fetch"; +import { useIdempotentPost } from "@/lib/api/use-idempotent-post"; import { localizedApiError } from "@/lib/api/localized-error"; import { isUnreadableRange, @@ -144,6 +145,7 @@ export function LabForm({ const labNumber = useLabNumber(); const recordName = useActiveRecordName(); const queryClient = useQueryClient(); + const post = useIdempotentPost(); const formId = useId(); const { data: catalog, isLoading: catalogLoading } = useQuery({ @@ -265,7 +267,7 @@ export function LabForm({ const printed = isQualitative ? null : parseReferenceRange(sourceRange, selected?.unit); - const created = await apiPost("/api/labs", { + const created = await post("/api/labs", { biomarkerId, ...(isQualitative ? { valueText: qualitativeValue } diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index 9f7ba309b..c3a89b4a6 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -33,8 +33,8 @@ import { measurementDependentKeys, refetchInactiveDailyReads, } from "@/lib/query-keys"; -import { apiPost } from "@/lib/api/api-fetch"; import { localizedApiError } from "@/lib/api/localized-error"; +import { useIdempotentPost } from "@/lib/api/use-idempotent-post"; import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/measurements/notes-limit"; import { getLastUsedMeasurementType, @@ -327,6 +327,7 @@ export function MeasurementForm({ const { t } = useTranslations(); const recordName = useActiveRecordName(); const queryClient = useQueryClient(); + const post = useIdempotentPost(); const unitDisplay = useUnitDisplay(); const { user } = useAuth(); // Glucose is its own display axis, separate from metric/imperial: it is @@ -448,7 +449,7 @@ export function MeasurementForm({ }); } - await apiPost("/api/measurements", batch); + await post("/api/measurements", batch); } else if (isKetoneMode) { // One fingerstick, posted as one transaction (`ketone-fingerstick.ts`). const batch = ketoneFingerstickBatch({ @@ -463,7 +464,7 @@ export function MeasurementForm({ setError(t("measurements.saveError")); return; } - await apiPost("/api/measurements", batch); + await post("/api/measurements", batch); } else { // Single measurement. Canonical storage stays SI: an imperial user // types in lb / in / °F and we invert to the canonical unit at the @@ -508,7 +509,7 @@ export function MeasurementForm({ : inverted; } canonicalValue = entryValueToCanonical(type, canonicalValue); - await apiPost("/api/measurements", { + await post("/api/measurements", { type, value: canonicalValue, measuredAt: timestamp, diff --git a/src/components/mood/mood-form.tsx b/src/components/mood/mood-form.tsx index e79bf95c6..28c14dbc3 100644 --- a/src/components/mood/mood-form.tsx +++ b/src/components/mood/mood-form.tsx @@ -42,8 +42,8 @@ import { moodDependentKeys, refetchInactiveDailyReads, } from "@/lib/query-keys"; -import { apiPost } from "@/lib/api/api-fetch"; import { localizedApiError } from "@/lib/api/localized-error"; +import { useIdempotentPost } from "@/lib/api/use-idempotent-post"; import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section"; import { MoodTagPicker } from "./mood-tag-picker"; import { @@ -125,6 +125,7 @@ export function MoodForm({ }: MoodFormProps) { const { t } = useTranslations(); const queryClient = useQueryClient(); + const post = useIdempotentPost(); const [mood, setMood] = useState(""); const { getRadioProps: getMoodRadioProps } = useRovingRadioGroup({ @@ -236,7 +237,7 @@ export function MoodForm({ const trimmedNote = note.trim(); - const saved = await apiPost("/api/mood-entries", { + const saved = await post("/api/mood-entries", { mood, tags: tags.length > 0 ? tags : undefined, tagKeys: tagKeys.length > 0 ? tagKeys : undefined, diff --git a/src/components/records/allergy-form.tsx b/src/components/records/allergy-form.tsx index 1270ce001..c4b979e2a 100644 --- a/src/components/records/allergy-form.tsx +++ b/src/components/records/allergy-form.tsx @@ -17,7 +17,8 @@ import { SelectValue, } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; -import { apiPost, apiPut } from "@/lib/api/api-fetch"; +import { apiPut } from "@/lib/api/api-fetch"; +import { useIdempotentPost } from "@/lib/api/use-idempotent-post"; import { localizedApiError } from "@/lib/api/localized-error"; import { allergyCategoryEnum, @@ -67,6 +68,7 @@ export function AllergyForm({ footerSlot, }: AllergyFormProps) { const { t } = useTranslations(); + const post = useIdempotentPost(); const formId = useId(); const [substance, setSubstance] = useState(existing?.substance ?? ""); @@ -106,7 +108,7 @@ export function AllergyForm({ try { const saved = existing ? await apiPut(`/api/allergies/${existing.id}`, body) - : await apiPost("/api/allergies", body); + : await post("/api/allergies", body); toast.success( existing ? t("records.allergies.form.updatedToast") diff --git a/src/components/symptoms/use-symptoms.ts b/src/components/symptoms/use-symptoms.ts index 711a7c96a..286cdaed1 100644 --- a/src/components/symptoms/use-symptoms.ts +++ b/src/components/symptoms/use-symptoms.ts @@ -17,6 +17,7 @@ import { } from "@tanstack/react-query"; import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { useIdempotentPost } from "@/lib/api/use-idempotent-post"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; import type { SymptomDefinitionDTO, @@ -184,11 +185,13 @@ export function useDeleteSymptomDefinition() { }); } +/** Under one key for as long as the entry form that mounts it is open. */ export function useLogSymptomEvent() { const invalidate = useInvalidateSymptoms(); + const post = useIdempotentPost(); return useMutation({ mutationFn: (input: SymptomEventCreateInput) => - apiPost("/api/symptoms/events", input), + post("/api/symptoms/events", input), onSuccess: invalidate, }); } diff --git a/src/components/vaccinations/use-vaccinations.ts b/src/components/vaccinations/use-vaccinations.ts index a2c9c8656..5d4258401 100644 --- a/src/components/vaccinations/use-vaccinations.ts +++ b/src/components/vaccinations/use-vaccinations.ts @@ -21,6 +21,7 @@ import { apiPatch, apiPost, } from "@/lib/api/api-fetch"; +import { useIdempotentPost } from "@/lib/api/use-idempotent-post"; import { invalidateReminderReads } from "@/hooks/use-measurement-reminders"; import { invalidateKeys, @@ -133,10 +134,11 @@ export function useVaccinationMutations() { invalidateReminderReads(qc), ]); + // One key for as long as the sheet that mounts this is open. + const post = useIdempotentPost(); const create = useMutation({ mutationKey: queryKeys.vaccinationCreate(), - mutationFn: (body: VaccinationWriteBody) => - apiPost(BASE, body), + mutationFn: (body: VaccinationWriteBody) => post(BASE, body), onSuccess: invalidate, }); diff --git a/src/hooks/use-encounters.ts b/src/hooks/use-encounters.ts index d140be832..6cb207374 100644 --- a/src/hooks/use-encounters.ts +++ b/src/hooks/use-encounters.ts @@ -19,13 +19,8 @@ import { useQueryClient, } from "@tanstack/react-query"; -import { - apiDelete, - apiGet, - apiGetList, - apiPatch, - apiPost, -} from "@/lib/api/api-fetch"; +import { apiDelete, apiGet, apiGetList, apiPatch } from "@/lib/api/api-fetch"; +import { useIdempotentPost } from "@/lib/api/use-idempotent-post"; import { invalidateReminderReads } from "@/hooks/use-measurement-reminders"; import { encounterDependentKeys, @@ -175,9 +170,11 @@ export function useEncounterMutations() { invalidateReminderReads(qc), ]); + // One key for as long as the sheet that mounts this is open. + const post = useIdempotentPost(); const create = useMutation({ mutationKey: queryKeys.encounterCreate(), - mutationFn: (body: EncounterWriteBody) => apiPost(BASE, body), + mutationFn: (body: EncounterWriteBody) => post(BASE, body), onSuccess: invalidate, }); diff --git a/src/lib/api/__tests__/use-idempotent-post.test.tsx b/src/lib/api/__tests__/use-idempotent-post.test.tsx new file mode 100644 index 000000000..cd307c480 --- /dev/null +++ b/src/lib/api/__tests__/use-idempotent-post.test.tsx @@ -0,0 +1,75 @@ +/** + * A second tap on Save after the first answer was lost must not store the + * entry twice. The per-call key the transport mints changes with every + * call, so a create form sends one key until a save lands. + */ +import { renderToStaticMarkup } from "react-dom/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useIdempotentPost } from "../use-idempotent-post"; + +type Post = ReturnType; + +function openForm(): Post { + let post: Post | null = null; + function Probe() { + post = useIdempotentPost(); + return null; + } + renderToStaticMarkup(); + return post!; +} + +const fetchMock = vi.fn(); + +function keyOf(call: number): string | null { + const init = fetchMock.mock.calls[call]![1] as RequestInit; + return new Headers(init.headers).get("Idempotency-Key"); +} + +const stored = () => + new Response(JSON.stringify({ data: { id: "e1" }, error: null }), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + +beforeEach(() => vi.stubGlobal("fetch", fetchMock)); +afterEach(() => { + fetchMock.mockReset(); + vi.unstubAllGlobals(); +}); + +describe("useIdempotentPost", () => { + it("sends the first key again when the first answer never arrived", async () => { + fetchMock + .mockRejectedValueOnce(new DOMException("timed out", "TimeoutError")) + .mockResolvedValueOnce(stored()); + const post = openForm(); + + await expect(post("/api/mood-entries", { mood: "GOOD" })).rejects.toThrow(); + await post("/api/mood-entries", { mood: "GOOD" }); + + expect(keyOf(0)).toBeTruthy(); + expect(keyOf(1)).toBe(keyOf(0)); + }); + + it("takes a new key for the next entry once a save landed", async () => { + fetchMock.mockResolvedValueOnce(stored()).mockResolvedValueOnce(stored()); + const post = openForm(); + + await post("/api/mood-entries", { mood: "GOOD" }); + await post("/api/mood-entries", { mood: "GOOD" }); + + expect(keyOf(1)).not.toBe(keyOf(0)); + }); + + it("keeps a header the caller set", async () => { + fetchMock.mockResolvedValueOnce(stored()); + const post = openForm(); + + await post("/api/x", {}, { headers: { Prefer: "respond-async" } }); + + const init = fetchMock.mock.calls[0]![1] as RequestInit; + expect(new Headers(init.headers).get("Prefer")).toBe("respond-async"); + }); +}); diff --git a/src/lib/api/api-fetch.ts b/src/lib/api/api-fetch.ts index 2a6adcd8b..15f2e28a2 100644 --- a/src/lib/api/api-fetch.ts +++ b/src/lib/api/api-fetch.ts @@ -262,12 +262,11 @@ export function apiGet( * Attach a per-call `Idempotency-Key` unless the caller already set one. * * Every `apiHandler` route accepts the header, and the write routes - * wrapped in `withIdempotency` dedupe on it — but almost no client call - * site sent one, so a transport-level replay of a create (a retried - * request, a re-sent form submission) could land a duplicate dose or - * vaccination and shift every derived series. Minting the key here - * protects every `apiPost` / `apiPatch` surface at once; a caller with a - * real natural key still wins by setting the header itself. + * wrapped in `withIdempotency` dedupe on it. A key minted per call covers a + * transport-level replay of that one request. A second tap on Save is a + * new call and gets a new key, so a create form sends one key for as long + * as it is open (`useIdempotentPost`); a caller that sets the header + * itself always wins. */ function withDefaultIdempotencyKey(init: RequestInit): RequestInit { const headers = new Headers(init.headers); diff --git a/src/lib/api/use-idempotent-post.ts b/src/lib/api/use-idempotent-post.ts new file mode 100644 index 000000000..4bd1ac40a --- /dev/null +++ b/src/lib/api/use-idempotent-post.ts @@ -0,0 +1,33 @@ +"use client"; + +import { useRef } from "react"; + +import { apiPost } from "@/lib/api/api-fetch"; +import { randomId } from "@/lib/random-id"; + +/** + * `apiPost` for a create form, under one `Idempotency-Key` until a save lands. + * + * The transport mints a fresh key per call, which covers a replayed request + * but not a second tap on Save: when the first answer never arrived (the 15 s + * timeout on a weak signal) the entry is already stored, and the retry is a + * new call with a new key, so the server stored it twice. Every submit + * through this function sends the same key until one succeeds, so the retry + * replays the first answer instead; a retry with a changed body is a + * different request and runs as one. The next entry gets a new key. + */ +export function useIdempotentPost() { + const key = useRef(null); + return async function post( + path: string, + body?: unknown, + init?: RequestInit, + ): Promise { + key.current ??= randomId(); + const headers = new Headers(init?.headers); + headers.set("Idempotency-Key", key.current); + const result = await apiPost(path, body, { ...init, headers }); + key.current = null; + return result; + }; +} From d204b4344b12962ef85f5ef5b492ead9983dde22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:06:02 +0100 Subject: [PATCH 09/10] fix(records): offer Undo after deleting a vaccination, visit or entry Deleting a vaccination, a doctor visit or an address-book entry closed the sheet with no way back, although all three are soft deletes with a documented restore route. The vaccination hook had a restore mutation nobody called; visits and the address book had none. The two hooks gain a restore mutation, and each delete now confirms with an Undo toast, the same pattern the illness episode uses. The Undo runs after the sheet has closed, so it awaits the restore itself and the hook's own onSuccess refreshes the lists, instead of relying on callbacks a closed sheet never receives. Restoring an address-book entry brings the entry back, not the links the delete cleared on the visits that named it. The three new success toasts are pinned in the success-affordance guard. --- messages/de.json | 12 +++- messages/en.json | 12 +++- messages/es.json | 12 +++- messages/fr.json | 12 +++- messages/it.json | 12 +++- messages/ko.json | 12 +++- messages/nl.json | 12 +++- messages/pl.json | 12 +++- messages/pt-BR.json | 12 +++- messages/ru.json | 12 +++- .../success-affordance-guard.test.ts | 3 + src/components/encounters/encounter-sheet.tsx | 24 ++++++- .../practitioner-delete-undo.test.ts | 63 +++++++++++++++++++ .../practitioners/practitioner-list.tsx | 37 ++++++++--- .../vaccinations/vaccination-sheet.tsx | 27 +++++++- src/hooks/use-encounters.ts | 18 +++++- src/hooks/use-practitioners.ts | 11 +++- src/lib/query-keys/encounters.ts | 2 + 18 files changed, 259 insertions(+), 46 deletions(-) create mode 100644 src/components/practitioners/__tests__/practitioner-delete-undo.test.ts diff --git a/messages/de.json b/messages/de.json index c1908fddc..4e0812d27 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10980,7 +10980,9 @@ "deleteTitle": "Eigenen Impfstoff entfernen?", "sectionDescription": "Selbst angelegte Impfstoffe, die der Katalog nicht kennt.", "catalogGroup": "Katalog" - } + }, + "deleted": "Impfung gelöscht", + "restoreFailed": "Impfung konnte nicht wiederhergestellt werden." }, "illness": { "title": "Krankheit", @@ -13492,7 +13494,9 @@ "defaultReason": "Kein früherer Termin bei derselben Praxis, in derselben Fachrichtung oder derselben Art.", "capped": "Die letzten zwei Jahre", "cappedReason": "Der letzte passende Termin liegt mehr als zwei Jahre zurück, und ein Bericht umfasst höchstens zwei Jahre." - } + }, + "deleted": "Termin gelöscht", + "restoreFailed": "Termin konnte nicht wiederhergestellt werden." }, "checkups": { "viewSwitchLabel": "Vorsorge, Termine, Eingriffe oder Körperstellen anzeigen", @@ -13525,7 +13529,9 @@ "deleteTitle": "Eintrag löschen?", "deleteDescription": "Deine bisherigen Termine bleiben erhalten und behalten diesen Namen.", "deleteFailed": "Eintrag konnte nicht gelöscht werden.", - "noSpecialtyGroup": "Ohne Fachrichtung" + "noSpecialtyGroup": "Ohne Fachrichtung", + "deleted": "Eintrag gelöscht", + "restoreFailed": "Eintrag konnte nicht wiederhergestellt werden." }, "links": { "picker": { diff --git a/messages/en.json b/messages/en.json index ca52adbf4..aa3c7dcd8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10980,7 +10980,9 @@ "deleteTitle": "Remove own vaccine?", "sectionDescription": "Vaccines you added yourself because the catalogue does not list them.", "catalogGroup": "Catalogue" - } + }, + "deleted": "Vaccination deleted", + "restoreFailed": "The vaccination could not be restored." }, "illness": { "title": "Illness", @@ -13492,7 +13494,9 @@ "defaultReason": "No earlier visit with the same practice, specialty or kind.", "capped": "The last two years", "cappedReason": "The last matching visit is more than two years ago, and a report covers two years at most." - } + }, + "deleted": "Visit deleted", + "restoreFailed": "The visit could not be restored." }, "checkups": { "viewSwitchLabel": "Show checkups, visits, procedures or body sites", @@ -13525,7 +13529,9 @@ "deleteTitle": "Delete this entry?", "deleteDescription": "Your existing visits stay, and they keep this name.", "deleteFailed": "The entry could not be deleted.", - "noSpecialtyGroup": "No specialty" + "noSpecialtyGroup": "No specialty", + "deleted": "Entry deleted", + "restoreFailed": "The entry could not be restored." }, "links": { "picker": { diff --git a/messages/es.json b/messages/es.json index 17dde5112..edf57f570 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10980,7 +10980,9 @@ "deleteTitle": "¿Quitar la vacuna propia?", "sectionDescription": "Vacunas que añadiste tú porque el catálogo no las recoge.", "catalogGroup": "Catálogo" - } + }, + "deleted": "Vacuna eliminada", + "restoreFailed": "No se pudo restaurar la vacuna." }, "illness": { "title": "Enfermedad", @@ -13492,7 +13494,9 @@ "defaultReason": "No hay ninguna visita anterior con la misma consulta, especialidad o tipo.", "capped": "Los últimos dos años", "cappedReason": "La última visita coincidente fue hace más de dos años, y un informe abarca como máximo dos años." - } + }, + "deleted": "Cita eliminada", + "restoreFailed": "No se pudo restaurar la cita." }, "checkups": { "viewSwitchLabel": "Mostrar revisiones, citas, procedimientos o zonas del cuerpo", @@ -13525,7 +13529,9 @@ "deleteTitle": "¿Eliminar esta entrada?", "deleteDescription": "Tus citas anteriores se mantienen y conservan este nombre.", "deleteFailed": "No se pudo eliminar la entrada.", - "noSpecialtyGroup": "Sin especialidad" + "noSpecialtyGroup": "Sin especialidad", + "deleted": "Entrada eliminada", + "restoreFailed": "No se pudo restaurar la entrada." }, "links": { "picker": { diff --git a/messages/fr.json b/messages/fr.json index c73ad4505..f44944f05 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10980,7 +10980,9 @@ "deleteTitle": "Retirer ce vaccin personnel ?", "sectionDescription": "Les vaccins que vous avez ajoutés parce que le catalogue ne les connaît pas.", "catalogGroup": "Catalogue" - } + }, + "deleted": "Vaccination supprimée", + "restoreFailed": "La vaccination n'a pas pu être restaurée." }, "illness": { "title": "Maladie", @@ -13492,7 +13494,9 @@ "defaultReason": "Aucune visite antérieure au même cabinet, dans la même spécialité ou du même type.", "capped": "Les deux dernières années", "cappedReason": "La dernière consultation correspondante date de plus de deux ans, et un rapport couvre au plus deux ans." - } + }, + "deleted": "Rendez-vous supprimé", + "restoreFailed": "Le rendez-vous n'a pas pu être restauré." }, "checkups": { "viewSwitchLabel": "Afficher les dépistages, les rendez-vous, les interventions ou les parties du corps", @@ -13525,7 +13529,9 @@ "deleteTitle": "Supprimer cette entrée ?", "deleteDescription": "Vos rendez-vous existants restent et conservent ce nom.", "deleteFailed": "L'entrée n'a pas pu être supprimée.", - "noSpecialtyGroup": "Sans spécialité" + "noSpecialtyGroup": "Sans spécialité", + "deleted": "Entrée supprimée", + "restoreFailed": "L'entrée n'a pas pu être restaurée." }, "links": { "picker": { diff --git a/messages/it.json b/messages/it.json index 7883cfb66..ca17d5d5d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10980,7 +10980,9 @@ "deleteTitle": "Rimuovere il vaccino personale?", "sectionDescription": "Vaccini aggiunti da te perché il catalogo non li elenca.", "catalogGroup": "Catalogo" - } + }, + "deleted": "Vaccinazione eliminata", + "restoreFailed": "Impossibile ripristinare la vaccinazione." }, "illness": { "title": "Malattia", @@ -13492,7 +13494,9 @@ "defaultReason": "Nessuna visita precedente presso lo stesso studio, nella stessa specialità o dello stesso tipo.", "capped": "Gli ultimi due anni", "cappedReason": "L'ultima visita corrispondente risale a più di due anni fa, e un report copre al massimo due anni." - } + }, + "deleted": "Appuntamento eliminato", + "restoreFailed": "Non è stato possibile ripristinare l'appuntamento." }, "checkups": { "viewSwitchLabel": "Mostra controlli, appuntamenti, interventi o parti del corpo", @@ -13525,7 +13529,9 @@ "deleteTitle": "Eliminare questa voce?", "deleteDescription": "I tuoi appuntamenti esistenti restano e conservano questo nome.", "deleteFailed": "Non è stato possibile eliminare la voce.", - "noSpecialtyGroup": "Senza specializzazione" + "noSpecialtyGroup": "Senza specializzazione", + "deleted": "Voce eliminata", + "restoreFailed": "Non è stato possibile ripristinare la voce." }, "links": { "picker": { diff --git a/messages/ko.json b/messages/ko.json index 23255f05e..3dd8f5bb9 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10980,7 +10980,9 @@ "deleteTitle": "직접 추가한 백신을 삭제할까요?", "sectionDescription": "목록에 없어서 직접 추가한 백신이에요.", "catalogGroup": "목록" - } + }, + "deleted": "예방접종 기록을 삭제했어요", + "restoreFailed": "예방접종 기록을 복원하지 못했어요." }, "illness": { "title": "아픈 날", @@ -13492,7 +13494,9 @@ "defaultReason": "같은 병원, 진료과 또는 유형의 이전 방문이 없어요.", "capped": "최근 2년", "cappedReason": "마지막으로 일치하는 진료가 2년 이상 지났으며, 보고서는 최대 2년까지 다룹니다." - } + }, + "deleted": "진료를 삭제했어요", + "restoreFailed": "진료를 복원하지 못했어요." }, "checkups": { "viewSwitchLabel": "건강검진, 진료, 시술 또는 신체 부위 보기", @@ -13525,7 +13529,9 @@ "deleteTitle": "이 항목을 삭제할까요?", "deleteDescription": "기존 진료 기록은 그대로 남고, 이 이름도 유지돼요.", "deleteFailed": "항목을 삭제하지 못했어요.", - "noSpecialtyGroup": "진료과 없음" + "noSpecialtyGroup": "진료과 없음", + "deleted": "항목을 삭제했어요", + "restoreFailed": "항목을 복원하지 못했어요." }, "links": { "picker": { diff --git a/messages/nl.json b/messages/nl.json index 6387a747e..cfe154d6e 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -10980,7 +10980,9 @@ "deleteTitle": "Eigen vaccin verwijderen?", "sectionDescription": "Vaccins die je zelf hebt toegevoegd omdat ze niet in de catalogus staan.", "catalogGroup": "Catalogus" - } + }, + "deleted": "Vaccinatie verwijderd", + "restoreFailed": "De vaccinatie kon niet worden teruggezet." }, "illness": { "title": "Ziekte", @@ -13492,7 +13494,9 @@ "defaultReason": "Geen eerder bezoek bij dezelfde praktijk, in hetzelfde specialisme of van hetzelfde soort.", "capped": "De afgelopen twee jaar", "cappedReason": "Het laatste passende bezoek is meer dan twee jaar geleden, en een rapport beslaat hooguit twee jaar." - } + }, + "deleted": "Bezoek verwijderd", + "restoreFailed": "Het bezoek kon niet worden teruggezet." }, "checkups": { "viewSwitchLabel": "Controles, bezoeken, ingrepen of lichaamsdelen tonen", @@ -13525,7 +13529,9 @@ "deleteTitle": "Deze vermelding verwijderen?", "deleteDescription": "Je bestaande bezoeken blijven bestaan en houden deze naam.", "deleteFailed": "De vermelding kon niet worden verwijderd.", - "noSpecialtyGroup": "Geen specialisme" + "noSpecialtyGroup": "Geen specialisme", + "deleted": "Vermelding verwijderd", + "restoreFailed": "De vermelding kon niet worden teruggezet." }, "links": { "picker": { diff --git a/messages/pl.json b/messages/pl.json index 505248df9..b426ef853 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10980,7 +10980,9 @@ "deleteTitle": "Usunąć własną szczepionkę?", "sectionDescription": "Szczepionki dodane przez Ciebie, bo nie ma ich w katalogu.", "catalogGroup": "Katalog" - } + }, + "deleted": "Szczepienie usunięte", + "restoreFailed": "Nie udało się przywrócić szczepienia." }, "illness": { "title": "Choroba", @@ -13492,7 +13494,9 @@ "defaultReason": "Brak wcześniejszej wizyty w tym samym gabinecie, tej samej specjalizacji lub tego samego rodzaju.", "capped": "Ostatnie dwa lata", "cappedReason": "Ostatnia pasująca wizyta była ponad dwa lata temu, a raport obejmuje najwyżej dwa lata." - } + }, + "deleted": "Wizyta usunięta", + "restoreFailed": "Nie udało się przywrócić wizyty." }, "checkups": { "viewSwitchLabel": "Pokaż badania profilaktyczne, wizyty, zabiegi lub miejsca na ciele", @@ -13525,7 +13529,9 @@ "deleteTitle": "Usunąć ten wpis?", "deleteDescription": "Twoje dotychczasowe wizyty pozostają i zachowują tę nazwę.", "deleteFailed": "Nie udało się usunąć wpisu.", - "noSpecialtyGroup": "Bez specjalizacji" + "noSpecialtyGroup": "Bez specjalizacji", + "deleted": "Wpis usunięty", + "restoreFailed": "Nie udało się przywrócić wpisu." }, "links": { "picker": { diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 512eb208f..ce959a62c 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -10980,7 +10980,9 @@ "deleteTitle": "Remover vacina própria?", "sectionDescription": "Vacinas que você adicionou porque o catálogo não as lista.", "catalogGroup": "Catálogo" - } + }, + "deleted": "Vacina excluída", + "restoreFailed": "Não foi possível restaurar a vacina." }, "illness": { "title": "Doenças", @@ -13492,7 +13494,9 @@ "defaultReason": "Nenhuma consulta anterior com o mesmo consultório, especialidade ou tipo.", "capped": "Os últimos dois anos", "cappedReason": "A última consulta correspondente foi há mais de dois anos, e um relatório cobre no máximo dois anos." - } + }, + "deleted": "Consulta excluída", + "restoreFailed": "Não foi possível restaurar a consulta." }, "checkups": { "viewSwitchLabel": "Mostrar check-ups, consultas, procedimentos ou locais do corpo", @@ -13525,7 +13529,9 @@ "deleteTitle": "Excluir esta entrada?", "deleteDescription": "Suas consultas existentes permanecem e mantêm este nome.", "deleteFailed": "Não foi possível excluir a entrada.", - "noSpecialtyGroup": "Sem especialidade" + "noSpecialtyGroup": "Sem especialidade", + "deleted": "Entrada excluída", + "restoreFailed": "Não foi possível restaurar a entrada." }, "links": { "picker": { diff --git a/messages/ru.json b/messages/ru.json index 478b17ba2..d98b8a0b2 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -10980,7 +10980,9 @@ "deleteTitle": "Убрать свою вакцину?", "sectionDescription": "Вакцины, которые вы добавили сами, потому что их нет в каталоге.", "catalogGroup": "Каталог" - } + }, + "deleted": "Вакцинация удалена", + "restoreFailed": "Не удалось восстановить вакцинацию." }, "illness": { "title": "Заболевание", @@ -13492,7 +13494,9 @@ "defaultReason": "Нет более раннего визита с той же клиникой, специальностью или видом.", "capped": "Последние два года", "cappedReason": "Последний подходящий визит был более двух лет назад, а отчёт охватывает не больше двух лет." - } + }, + "deleted": "Визит удалён", + "restoreFailed": "Визит не удалось восстановить." }, "checkups": { "viewSwitchLabel": "Показать осмотры, визиты, процедуры или области тела", @@ -13525,7 +13529,9 @@ "deleteTitle": "Удалить эту запись?", "deleteDescription": "Ваши существующие визиты остаются, и они сохраняют это имя.", "deleteFailed": "Запись не удалось удалить.", - "noSpecialtyGroup": "Без специальности" + "noSpecialtyGroup": "Без специальности", + "deleted": "Запись удалена", + "restoreFailed": "Запись не удалось восстановить." }, "links": { "picker": { diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 44e33938c..18636c146 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -216,6 +216,7 @@ const PINNED_AFFORDANCES: Record< "src/components/documents/sources/document-source-picker.tsx": { "text-success": 1, }, + "src/components/encounters/encounter-sheet.tsx": { "toast.success": 1 }, "src/components/error-details.tsx": { CheckCircle2: 2, "text-success": 1 }, "src/components/illness/episode-menu.tsx": { CheckCircle2: 2, @@ -347,8 +348,10 @@ const PINNED_AFFORDANCES: Record< "src/components/symptoms/symptom-entry-form.tsx": { "toast.success": 1 }, "src/components/mood/mood-list.tsx": { "toast.success": 3 }, "src/components/onboarding/done-screen.tsx": { CheckCircle2: 2 }, + "src/components/practitioners/practitioner-list.tsx": { "toast.success": 1 }, "src/components/records/allergy-form.tsx": { "toast.success": 1 }, "src/components/records/allergy-manager.tsx": { "toast.success": 1 }, + "src/components/vaccinations/vaccination-sheet.tsx": { "toast.success": 1 }, "src/components/records/conditions-manager.tsx": { "toast.success": 1 }, "src/components/records/family-history-form.tsx": { "toast.success": 1 }, "src/components/records/family-history-manager.tsx": { "toast.success": 1 }, diff --git a/src/components/encounters/encounter-sheet.tsx b/src/components/encounters/encounter-sheet.tsx index 378636604..8ce3a0bf1 100644 --- a/src/components/encounters/encounter-sheet.tsx +++ b/src/components/encounters/encounter-sheet.tsx @@ -33,6 +33,7 @@ */ import { useState } from "react"; import { Trash2 } from "lucide-react"; +import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { @@ -85,7 +86,7 @@ export function EncounterSheet({ onSaved?: (saved: Encounter) => void; }) { const { t } = useTranslations(); - const { create, update, remove } = useEncounterMutations(); + const { create, update, remove, restore } = useEncounterMutations(); const [confirmingDelete, setConfirmingDelete] = useState(false); // Seeded once, at mount. The caller remounts this component on every open by // varying its `key`, so a second edit never shows the first one's values and @@ -210,8 +211,25 @@ export function EncounterSheet({ { - remove.mutate(encounter.id, { - onSuccess: () => onOpenChange(false), + const id = encounter.id; + remove.mutate(id, { + onSuccess: () => { + onOpenChange(false); + // The delete is soft. The Undo runs after this sheet + // closed, so it awaits the restore itself rather than + // passing callbacks a closed sheet never receives. + toast.success(t("encounters.deleted"), { + action: { + label: t("common.undo"), + onClick: () => + void restore.mutateAsync(id).catch((failure) => + showErrorToast(t("encounters.restoreFailed"), { + cause: failure, + }), + ), + }, + }); + }, onError: (failure) => showErrorToast(t("encounters.deleteFailed"), { cause: failure, diff --git a/src/components/practitioners/__tests__/practitioner-delete-undo.test.ts b/src/components/practitioners/__tests__/practitioner-delete-undo.test.ts new file mode 100644 index 000000000..fbe6aa48c --- /dev/null +++ b/src/components/practitioners/__tests__/practitioner-delete-undo.test.ts @@ -0,0 +1,63 @@ +/** + * Deleting an address-book entry is a soft delete with a restore route, and + * nothing on the web offered it: a mistaken delete could not be taken back. + * The delete now confirms with an Undo that calls the restore. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const toastSuccess = vi.hoisted(() => vi.fn()); +const errorToast = vi.hoisted(() => vi.fn()); +vi.mock("sonner", () => ({ toast: { success: toastSuccess } })); +vi.mock("@/lib/errors/error-toast", () => ({ showErrorToast: errorToast })); + +import { deleteWithUndo } from "../practitioner-list"; + +type Mutations = Parameters[0]; + +const t = (key: string) => key; + +function mutations(restoreResult: Promise) { + const remove = { + mutate: vi.fn((_id: string, options: { onSuccess?: () => void }) => + options.onSuccess?.(), + ), + }; + const restore = { mutateAsync: vi.fn(() => restoreResult) }; + return { remove, restore, all: { remove, restore } as unknown as Mutations }; +} + +beforeEach(() => { + toastSuccess.mockReset(); + errorToast.mockReset(); +}); + +describe("deleteWithUndo", () => { + it("confirms the delete with an Undo that restores the entry", async () => { + const m = mutations(Promise.resolve({ id: "p1" })); + + deleteWithUndo(m.all, "p1", t); + + expect(m.remove.mutate).toHaveBeenCalledWith("p1", expect.anything()); + const [message, options] = toastSuccess.mock.calls[0]!; + expect(message).toBe("practitioners.deleted"); + expect(options.action.label).toBe("common.undo"); + options.action.onClick(); + expect(m.restore.mutateAsync).toHaveBeenCalledWith("p1"); + await Promise.resolve(); + expect(errorToast).not.toHaveBeenCalled(); + }); + + it("says so when the restore fails", async () => { + const m = mutations(Promise.reject(new Error("offline"))); + + deleteWithUndo(m.all, "p1", t); + toastSuccess.mock.calls[0]![1].action.onClick(); + + await vi.waitFor(() => + expect(errorToast).toHaveBeenCalledWith( + "practitioners.restoreFailed", + expect.anything(), + ), + ); + }); +}); diff --git a/src/components/practitioners/practitioner-list.tsx b/src/components/practitioners/practitioner-list.tsx index b305ed15a..af361afe1 100644 --- a/src/components/practitioners/practitioner-list.tsx +++ b/src/components/practitioners/practitioner-list.tsx @@ -13,6 +13,7 @@ */ import { ContactRound, Pencil, Plus, Search, Trash2 } from "lucide-react"; import { useMemo, useState } from "react"; +import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { @@ -43,6 +44,33 @@ import { VISITS_PATH } from "@/lib/encounters/visit-links"; import { useTranslations } from "@/lib/i18n/context"; import { PractitionerSheet } from "./practitioner-sheet"; +/** + * Delete an entry, with an Undo: the delete is soft, and the restore brings + * the entry back. + */ +export function deleteWithUndo( + { remove, restore }: ReturnType, + id: string, + t: (key: string) => string, +) { + remove.mutate(id, { + onSuccess: () => + toast.success(t("practitioners.deleted"), { + action: { + label: t("common.undo"), + onClick: () => + void restore.mutateAsync(id).catch((failure) => + showErrorToast(t("practitioners.restoreFailed"), { + cause: failure, + }), + ), + }, + }), + onError: (failure) => + showErrorToast(t("practitioners.deleteFailed"), { cause: failure }), + }); +} + export function PractitionerList({ enabled = true }: { enabled?: boolean }) { const { t } = useTranslations(); const { canWriteDomain, canManageDomain } = useRecordCapabilities(); @@ -52,7 +80,7 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) { const [term, setTerm] = useState(""); const debounced = useDebouncedValue(term, 250); const list = usePractitioners(debounced, enabled); - const { remove } = usePractitionerMutations(); + const mutations = usePractitionerMutations(); const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(null); @@ -257,12 +285,7 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) { if (!deleting) return; const id = deleting.id; setDeleting(null); - remove.mutate(id, { - onError: (failure) => - showErrorToast(t("practitioners.deleteFailed"), { - cause: failure, - }), - }); + deleteWithUndo(mutations, id, t); }} > {t("common.delete")} diff --git a/src/components/vaccinations/vaccination-sheet.tsx b/src/components/vaccinations/vaccination-sheet.tsx index 7bc0cbb6a..a63972299 100644 --- a/src/components/vaccinations/vaccination-sheet.tsx +++ b/src/components/vaccinations/vaccination-sheet.tsx @@ -20,6 +20,7 @@ */ import { useState } from "react"; import { Trash2 } from "lucide-react"; +import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { @@ -69,7 +70,7 @@ export function VaccinationSheet({ onCreated?: (created: Vaccination) => void; }) { const { t } = useTranslations(); - const { create, update, remove } = useVaccinationMutations(); + const { create, update, remove, restore } = useVaccinationMutations(); // The list row carries no document links; the detail read does. Until it // answers, the draft's links stay "untouched" and Save leaves them alone. const detail = useVaccination(vaccination?.id ?? null); @@ -198,8 +199,28 @@ export function VaccinationSheet({ { - remove.mutate(vaccination.id, { - onSuccess: () => onOpenChange(false), + const id = vaccination.id; + remove.mutate(id, { + onSuccess: () => { + onOpenChange(false); + // The delete is soft. The Undo runs after this sheet + // closed, so it awaits the restore itself rather than + // passing callbacks a closed sheet never receives. + toast.success(t("vaccinations.deleted"), { + action: { + label: t("common.undo"), + onClick: () => + void restore.mutateAsync(id).catch((failure) => + showErrorToast( + t("vaccinations.restoreFailed"), + { + cause: failure, + }, + ), + ), + }, + }); + }, onError: (failure) => showErrorToast(t("vaccinations.deleteFailed"), { cause: failure, diff --git a/src/hooks/use-encounters.ts b/src/hooks/use-encounters.ts index 6cb207374..a9cccc0b1 100644 --- a/src/hooks/use-encounters.ts +++ b/src/hooks/use-encounters.ts @@ -19,7 +19,13 @@ import { useQueryClient, } from "@tanstack/react-query"; -import { apiDelete, apiGet, apiGetList, apiPatch } from "@/lib/api/api-fetch"; +import { + apiDelete, + apiGet, + apiGetList, + apiPatch, + apiPost, +} from "@/lib/api/api-fetch"; import { useIdempotentPost } from "@/lib/api/use-idempotent-post"; import { invalidateReminderReads } from "@/hooks/use-measurement-reminders"; import { @@ -192,5 +198,13 @@ export function useEncounterMutations() { onSuccess: invalidate, }); - return { create, update, remove }; + // The Undo of a delete. A restored visit re-anchors its reminder only + // while its date is still ahead. + const restore = useMutation({ + mutationKey: queryKeys.encounterRestore(), + mutationFn: (id: string) => apiPost(`${BASE}/${id}/restore`, {}), + onSuccess: invalidate, + }); + + return { create, update, remove, restore }; } diff --git a/src/hooks/use-practitioners.ts b/src/hooks/use-practitioners.ts index 4b083a47f..3fdd4145e 100644 --- a/src/hooks/use-practitioners.ts +++ b/src/hooks/use-practitioners.ts @@ -91,5 +91,14 @@ export function usePractitionerMutations() { onSuccess: invalidate, }); - return { create, update, remove }; + // The Undo of a delete. It brings the entry back, not the links the + // delete cleared on the visits that named it. + const restore = useMutation({ + mutationKey: queryKeys.practitionerRestore(), + mutationFn: (id: string) => + apiPost(`${BASE}/${id}/restore`, {}), + onSuccess: invalidate, + }); + + return { create, update, remove, restore }; } diff --git a/src/lib/query-keys/encounters.ts b/src/lib/query-keys/encounters.ts index 84fab90e2..7abc4b10a 100644 --- a/src/lib/query-keys/encounters.ts +++ b/src/lib/query-keys/encounters.ts @@ -95,7 +95,9 @@ export const encounterKeys = { encounterCreate: () => ["encounters", "create"] as const, encounterUpdate: () => ["encounters", "update"] as const, encounterDelete: () => ["encounters", "delete"] as const, + encounterRestore: () => ["encounters", "restore"] as const, practitionerCreate: () => ["practitioners", "create"] as const, practitionerUpdate: () => ["practitioners", "update"] as const, practitionerDelete: () => ["practitioners", "delete"] as const, + practitionerRestore: () => ["practitioners", "restore"] as const, }; From 53f4ecc2c5b25558e10d300563430833b91132f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:08:24 +0100 Subject: [PATCH 10/10] fix(documents): say when the document picker list is cut The picker for linking documents to an illness and for choosing documents for a share link reads the newest fifty and dropped the route's nextCursor, so with a longer vault an older report seemed not to exist unless one already knew to search for it. A cut list now ends with a line that says how many are shown and that the search reaches the rest. Paging was left out: the coach attachment picker shares the picker's cache key with a single-page read. --- messages/de.json | 3 + messages/en.json | 3 + messages/es.json | 3 + messages/fr.json | 3 + messages/it.json | 3 + messages/ko.json | 3 + messages/nl.json | 3 + messages/pl.json | 3 + messages/pt-BR.json | 3 + messages/ru.json | 3 + .../client-fetch-limit-guard.test.ts | 2 +- .../document-picker-sheet-cap.test.tsx | 71 +++++++++++++++++++ .../documents/document-picker-sheet.tsx | 17 ++++- 13 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 src/components/documents/__tests__/document-picker-sheet-cap.test.tsx diff --git a/messages/de.json b/messages/de.json index 4e0812d27..31dee6efe 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12491,6 +12491,9 @@ "purgeFailed": "Das Dokument konnte nicht gelöscht werden.", "purgeConfirmTitle": "Dieses Dokument endgültig löschen?", "purgeConfirmBody": "Es wird jetzt entfernt statt am Ende der Wartezeit und lässt sich nicht wiederherstellen." + }, + "picker": { + "capped": "Hier stehen die neuesten {count}. Ältere Dokumente findest du über die Suche." } }, "selfDescription": { diff --git a/messages/en.json b/messages/en.json index aa3c7dcd8..bda36e4f1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12491,6 +12491,9 @@ "purgeFailed": "Couldn't delete the document.", "purgeConfirmTitle": "Delete this document for good?", "purgeConfirmBody": "It is removed now instead of at the end of the waiting period, and cannot be restored." + }, + "picker": { + "capped": "Showing the newest {count}. Search to find an older document." } }, "selfDescription": { diff --git a/messages/es.json b/messages/es.json index edf57f570..66b49ced7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12491,6 +12491,9 @@ "purgeFailed": "No se pudo eliminar el documento.", "purgeConfirmTitle": "¿Eliminar este documento definitivamente?", "purgeConfirmBody": "Se elimina ahora en lugar de al final del periodo de espera y no se puede restaurar." + }, + "picker": { + "capped": "Se muestran los {count} más recientes. Usa la búsqueda para encontrar un documento más antiguo." } }, "selfDescription": { diff --git a/messages/fr.json b/messages/fr.json index f44944f05..496a689ea 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12491,6 +12491,9 @@ "purgeFailed": "Impossible de supprimer le document.", "purgeConfirmTitle": "Supprimer ce document définitivement ?", "purgeConfirmBody": "Il est supprimé maintenant plutôt qu'à la fin du délai d'attente et ne peut pas être restauré." + }, + "picker": { + "capped": "Les {count} plus récents sont affichés. Utilisez la recherche pour trouver un document plus ancien." } }, "selfDescription": { diff --git a/messages/it.json b/messages/it.json index ca17d5d5d..f0291f68c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12491,6 +12491,9 @@ "purgeFailed": "Impossibile eliminare il documento.", "purgeConfirmTitle": "Eliminare definitivamente questo documento?", "purgeConfirmBody": "Viene rimosso ora anziché alla fine del periodo di attesa e non può essere ripristinato." + }, + "picker": { + "capped": "Sono mostrati i {count} più recenti. Usa la ricerca per trovare un documento più vecchio." } }, "selfDescription": { diff --git a/messages/ko.json b/messages/ko.json index 3dd8f5bb9..d725f3894 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12491,6 +12491,9 @@ "purgeFailed": "문서를 삭제하지 못했어요.", "purgeConfirmTitle": "이 문서를 완전히 삭제할까요?", "purgeConfirmBody": "대기 기간이 끝나기 전에 지금 삭제되며 복구할 수 없습니다." + }, + "picker": { + "capped": "최신 {count}개를 보여 줘요. 이전 문서는 검색으로 찾아 주세요." } }, "selfDescription": { diff --git a/messages/nl.json b/messages/nl.json index cfe154d6e..eb10a6fe9 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -12491,6 +12491,9 @@ "purgeFailed": "Het document kon niet worden verwijderd.", "purgeConfirmTitle": "Dit document definitief verwijderen?", "purgeConfirmBody": "Het wordt nu verwijderd in plaats van aan het einde van de wachttijd en kan niet worden hersteld." + }, + "picker": { + "capped": "De nieuwste {count} staan hier. Zoek om een ouder document te vinden." } }, "selfDescription": { diff --git a/messages/pl.json b/messages/pl.json index b426ef853..a68ec2c75 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12491,6 +12491,9 @@ "purgeFailed": "Nie udało się usunąć dokumentu.", "purgeConfirmTitle": "Usunąć ten dokument na stałe?", "purgeConfirmBody": "Zostanie usunięty teraz zamiast po okresie oczekiwania i nie będzie można go przywrócić." + }, + "picker": { + "capped": "Widać {count} najnowszych. Starszy dokument znajdziesz przez wyszukiwanie." } }, "selfDescription": { diff --git a/messages/pt-BR.json b/messages/pt-BR.json index ce959a62c..46ffffbe4 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -12491,6 +12491,9 @@ "purgeFailed": "Não foi possível excluir o documento.", "purgeConfirmTitle": "Excluir este documento para sempre?", "purgeConfirmBody": "Ele é removido agora, e não no fim do período de espera, e não pode ser restaurado." + }, + "picker": { + "capped": "Mostrando os {count} mais recentes. Use a busca para encontrar um documento mais antigo." } }, "selfDescription": { diff --git a/messages/ru.json b/messages/ru.json index d98b8a0b2..786103b21 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -12491,6 +12491,9 @@ "purgeFailed": "Не удалось удалить документ.", "purgeConfirmTitle": "Удалить этот документ навсегда?", "purgeConfirmBody": "Он будет удалён сейчас, а не в конце периода ожидания, и его нельзя будет восстановить." + }, + "picker": { + "capped": "Показаны {count} самых новых. Более старый документ найдите через поиск." } }, "selfDescription": { diff --git a/src/__tests__/client-fetch-limit-guard.test.ts b/src/__tests__/client-fetch-limit-guard.test.ts index 95e2363c7..514275f8b 100644 --- a/src/__tests__/client-fetch-limit-guard.test.ts +++ b/src/__tests__/client-fetch-limit-guard.test.ts @@ -83,7 +83,7 @@ const ALLOWLIST: readonly Allowed[] = [ file: "components/documents/document-picker-sheet.tsx", limit: 50, reason: - "Search-backed picker (episode links and share links): the query narrows the whole document vault server-side.", + "Search-backed picker (episode links and share links): the query narrows the whole document vault server-side, and a cut list says so and points to the search.", }, { file: "hooks/use-mood-day.ts", diff --git a/src/components/documents/__tests__/document-picker-sheet-cap.test.tsx b/src/components/documents/__tests__/document-picker-sheet-cap.test.tsx new file mode 100644 index 000000000..a698cb5be --- /dev/null +++ b/src/components/documents/__tests__/document-picker-sheet-cap.test.tsx @@ -0,0 +1,71 @@ +/** + * The document picker reads one page of the vault, the newest fifty. With a + * longer vault it listed those and said nothing, so an older report seemed + * not to exist unless one already knew to search for it. + * + * ResponsiveSheet portals its body, which static markup does not render, so it + * is mocked down to a wrapper that always shows its children. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const page = vi.hoisted(() => ({ nextCursor: null as string | null })); + +vi.mock("@/components/ui/responsive-sheet", () => ({ + ResponsiveSheet: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: { + // A cut page is a full one. + documents: Array.from({ length: page.nextCursor ? 50 : 3 }, (_, i) => ({ + id: `d${i}`, + kind: "LAB_RESULT", + title: `Blood panel ${i}`, + filename: null, + documentDate: "2026-10-01", + createdAt: "2026-10-01T08:00:00.000Z", + })), + nextCursor: page.nextCursor, + }, + isPending: false, + isError: false, + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { DocumentPickerSheet } from "../document-picker-sheet"; + +function render() { + return renderToStaticMarkup( + + undefined} + title="Link documents" + description="Pick documents" + searchPlaceholder="Search" + emptyLabel="No documents" + isPicked={() => false} + onToggle={() => undefined} + /> + , + ); +} + +describe("DocumentPickerSheet", () => { + it("says the list is cut, and how to reach the rest", () => { + page.nextCursor = "d49"; + const html = render(); + expect(html).toContain('data-slot="document-picker-capped"'); + expect(html).toContain("Showing the newest 50"); + }); + + it("says nothing when every document is listed", () => { + page.nextCursor = null; + expect(render()).not.toContain('data-slot="document-picker-capped"'); + }); +}); diff --git a/src/components/documents/document-picker-sheet.tsx b/src/components/documents/document-picker-sheet.tsx index 6794e3650..a14908f5a 100644 --- a/src/components/documents/document-picker-sheet.tsx +++ b/src/components/documents/document-picker-sheet.tsx @@ -3,8 +3,9 @@ /** * The vault document picker sheet: a `ResponsiveSheet` (bottom sheet on * phones) with a debounced search over the person's own vault — the same - * recall path as the vault page, one page (50) of matches, the search input - * as the way to an older document — and one toggle row per document. + * recall path as the vault page, one page (50) of matches with a line that + * says when more exist, the search input as the way to an older document — + * and one toggle row per document. * * Two pickers used to carry this whole body as copies: linking documents to * an illness episode (each toggle writes at once) and choosing documents for @@ -97,6 +98,8 @@ export function DocumentPickerSheet({ }); const documents = list.data?.documents ?? []; + // More match than one page: say so, and that the search reaches the rest. + const cut = Boolean(list.data?.nextCursor); return ( )} + {cut && !list.isError ? ( +

+ {t("documents.picker.capped", { + count: String(documents.length), + })} +

+ ) : null}
);