mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
fix(query-keys): refresh the day view and timeline after every write
Symptoms, workouts, lab results, allergies, documents and questionnaires evicted only their own lists. An open day, its calendar dots and the timeline kept the pre-write answer for up to five minutes, so "Capture for this day" saved a symptom or a workout and the day showed the old list, and a deleted row stayed on it. Workouts, labs, allergies and documents get a bundle beside the seven that already carry the day and timeline roots; the six lab lists become one (the OCR dialog's own copy of the commit hook's list goes with it). Symptoms and questionnaires add the two roots to their list. Symptoms stay off the illness bundle on purpose: nothing on the dashboard or the Today digest reads them. Custom metrics need nothing: neither the day nor the timeline reads them. invalidateKeys now drops a key listed twice, since spread bundles share the two roots.
This commit is contained in:
1 parent
aae029d94e
commit
2e73b0ffec
24 files changed
+257
-118
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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".
|
||||
|
||||
+3
-15
@@ -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}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -91,7 +91,6 @@ function render(
|
||||
mode={options.mode ?? "vision"}
|
||||
pdfSupported={options.pdfSupported ?? true}
|
||||
consentRequired={options.consentRequired}
|
||||
onCommitted={() => {}}
|
||||
/>
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 : []),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* helpers on their own:
|
||||
*
|
||||
* 1. the save posts ONE entry to `POST /api/workouts/batch`, MANUAL, with
|
||||
* the form's external id, and refreshes the workout reads;
|
||||
* the form's external id, and refreshes the workout reads, the day and
|
||||
* the timeline;
|
||||
* 2. a `duplicate` (a second tap) is a success, a `skipped` entry is not;
|
||||
* 3. delete is offered for a hand-entered workout in one's own record only,
|
||||
* and calls `DELETE /api/workouts/{id}`.
|
||||
@@ -44,9 +45,16 @@ function fakeClient() {
|
||||
return {
|
||||
invalidateQueries: vi.fn().mockResolvedValue(undefined),
|
||||
removeQueries: vi.fn(),
|
||||
getQueryCache: () => ({ findAll: () => [] }),
|
||||
};
|
||||
}
|
||||
|
||||
function invalidatedKeys(client: ReturnType<typeof fakeClient>): string[] {
|
||||
return client.invalidateQueries.mock.calls.map(([arg]) =>
|
||||
JSON.stringify((arg as { queryKey: unknown }).queryKey),
|
||||
);
|
||||
}
|
||||
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -80,7 +88,7 @@ describe("saveManualWorkout — the wire", () => {
|
||||
expect(body.workouts[0].externalId).toBe(ENTRY.externalId);
|
||||
});
|
||||
|
||||
it("refreshes every workout read, and the daily reads", async () => {
|
||||
it("refreshes every workout read, the daily reads, the day and the timeline", async () => {
|
||||
fetchSpy.mockResolvedValue(
|
||||
envelope({ entries: [{ index: 0, status: "inserted" }] }),
|
||||
);
|
||||
@@ -88,12 +96,13 @@ describe("saveManualWorkout — the wire", () => {
|
||||
|
||||
await saveManualWorkout(ENTRY, client as unknown as QueryClient);
|
||||
|
||||
const keys = client.invalidateQueries.mock.calls.map(([arg]) =>
|
||||
JSON.stringify((arg as { queryKey: unknown }).queryKey),
|
||||
);
|
||||
const keys = invalidatedKeys(client);
|
||||
expect(keys).toContain(JSON.stringify(queryKeys.workouts()));
|
||||
expect(keys).toContain(JSON.stringify(queryKeys.dailyDigest()));
|
||||
expect(keys).toContain(JSON.stringify(queryKeys.dashboardSnapshot()));
|
||||
// "Capture for this day" saves into an open day view.
|
||||
expect(keys).toContain(JSON.stringify(queryKeys.dayRoot()));
|
||||
expect(keys).toContain(JSON.stringify(queryKeys.timelineRoot()));
|
||||
});
|
||||
|
||||
it("treats a duplicate (the same form submitted twice) as saved", async () => {
|
||||
@@ -177,8 +186,9 @@ describe("delete — a hand-entered workout in one's own record only", () => {
|
||||
expect(client.removeQueries).toHaveBeenCalledWith({
|
||||
queryKey: queryKeys.workoutDetail("w-1"),
|
||||
});
|
||||
expect(client.invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: queryKeys.workouts(),
|
||||
});
|
||||
const keys = invalidatedKeys(client);
|
||||
expect(keys).toContain(JSON.stringify(queryKeys.workouts()));
|
||||
expect(keys).toContain(JSON.stringify(queryKeys.dayRoot()));
|
||||
expect(keys).toContain(JSON.stringify(queryKeys.timelineRoot()));
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,12 @@ import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { apiDelete } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
|
||||
import {
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
refetchInactiveDailyReads,
|
||||
workoutDependentKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
@@ -38,8 +43,8 @@ export function canDeleteWorkout(
|
||||
|
||||
/**
|
||||
* Delete, then drop the deleted detail from the cache and refresh the rest of
|
||||
* the workout reads (list, dashboard tile) and the two daily reads the route
|
||||
* evicted server-side. The detail is removed rather than invalidated, so
|
||||
* the workout reads (list, dashboard tile, the day and the timeline) and the
|
||||
* two daily reads the route evicted server-side. The detail is removed rather than invalidated, so
|
||||
* nothing refetches a row that no longer exists.
|
||||
*/
|
||||
export async function deleteManualWorkout(
|
||||
@@ -48,7 +53,7 @@ export async function deleteManualWorkout(
|
||||
): Promise<void> {
|
||||
await apiDelete(`/api/workouts/${encodeURIComponent(id)}`);
|
||||
queryClient.removeQueries({ queryKey: queryKeys.workoutDetail(id) });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.workouts() });
|
||||
await invalidateKeys(queryClient, workoutDependentKeys);
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,11 @@ import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { getQuantityTransform } from "@/lib/measurements/display-transform";
|
||||
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
|
||||
import {
|
||||
invalidateKeys,
|
||||
refetchInactiveDailyReads,
|
||||
workoutDependentKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import {
|
||||
buildManualWorkoutEntry,
|
||||
@@ -105,11 +109,10 @@ export async function saveManualWorkout(
|
||||
if (status !== "inserted" && status !== "duplicate" && status !== "updated") {
|
||||
throw new Error(`workout entry not stored: ${status ?? "unknown"}`);
|
||||
}
|
||||
// The list, the dashboard tile and every detail read sit under this
|
||||
// prefix. The form also opens from the capture picker on any page, where
|
||||
// the Today hero (which names a workout that just landed) and the dashboard
|
||||
// snapshot are unmounted, so those two are refetched outright.
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.workouts() });
|
||||
// The form also opens from the capture picker on any page, where the
|
||||
// Today hero and the dashboard snapshot are unmounted, so those two are
|
||||
// refetched outright.
|
||||
await invalidateKeys(queryClient, workoutDependentKeys);
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -359,6 +359,52 @@ export const vaccinationDependentKeys = [
|
||||
queryKeys.timelineRoot(),
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys invalidated when a workout is logged, edited or deleted. The Today
|
||||
* digest names a workout that just arrived and the write evicts the server's
|
||||
* daily caches, so the daily reads ride along; pair call sites with
|
||||
* `refetchInactiveDailyReads`, because both are unmounted wherever the form
|
||||
* opens outside the dashboard.
|
||||
*/
|
||||
export const workoutDependentKeys = [
|
||||
queryKeys.workouts(),
|
||||
queryKeys.dashboardSnapshot(),
|
||||
queryKeys.dailyDigest(),
|
||||
queryKeys.dayRoot(),
|
||||
queryKeys.timelineRoot(),
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys invalidated when a lab reading is created, edited, deleted or restored,
|
||||
* or a marker is renamed, hidden, merged or deleted. The marker list carries
|
||||
* each marker's latest reading, and the "what changed since your last panel"
|
||||
* card compares the two newest panels.
|
||||
*/
|
||||
export const labDependentKeys = [
|
||||
queryKeys.labResults(),
|
||||
queryKeys.biomarkers(),
|
||||
queryKeys.insightsLabsChanges(),
|
||||
queryKeys.dayRoot(),
|
||||
queryKeys.timelineRoot(),
|
||||
];
|
||||
|
||||
/** Keys invalidated when an allergy is created, edited or deleted. */
|
||||
export const allergyDependentKeys = [
|
||||
queryKeys.allergies(),
|
||||
queryKeys.dayRoot(),
|
||||
queryKeys.timelineRoot(),
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys invalidated when a document is uploaded, imported, edited, deleted or
|
||||
* restored. A document's date places it on its day and on the timeline.
|
||||
*/
|
||||
export const documentDependentKeys = [
|
||||
queryKeys.documents(),
|
||||
queryKeys.dayRoot(),
|
||||
queryKeys.timelineRoot(),
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys invalidated when an input of the `ai` block on `GET /api/auth/me`
|
||||
* changes: the account's own provider (key, local model, compatible endpoint,
|
||||
@@ -385,7 +431,8 @@ export const aiInputDependentKeys = [
|
||||
/**
|
||||
* Invalidate every key in the bundle in parallel. Use this from mutation
|
||||
* `onSuccess` handlers so the call site stays a one-liner instead of repeating
|
||||
* `Promise.all(keys.map(...))` everywhere.
|
||||
* `Promise.all(keys.map(...))` everywhere. A key listed twice (two bundles
|
||||
* spread into one list share the day and timeline roots) is invalidated once.
|
||||
*
|
||||
* Uses `allSettled` so one transient network failure doesn't abort subsequent
|
||||
* invalidations (cache would otherwise be left half-stale) and so the `void
|
||||
@@ -404,8 +451,11 @@ export function invalidateKeys(
|
||||
queryClient: QueryClient,
|
||||
keys: readonly QueryKey[],
|
||||
): Promise<PromiseSettledResult<unknown>[]> {
|
||||
const unique = new Map(keys.map((key) => [JSON.stringify(key), key]));
|
||||
return Promise.allSettled(
|
||||
keys.map((queryKey) => invalidateKey(queryClient, queryKey)),
|
||||
[...unique.values()].map((queryKey) =>
|
||||
invalidateKey(queryClient, queryKey),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -441,9 +491,8 @@ async function invalidateKey(
|
||||
* remount re-fetches under `refetchOnMount: false`.
|
||||
*
|
||||
* Originates from the v1.16.11 dashboard-snapshot fix and the v1.29.1
|
||||
* medication-intake fix (`invalidateMedicationReads` in
|
||||
* `components/medications/use-medication-intake.ts`, which predates this
|
||||
* shared helper and keeps its own inline copy). Call this alongside
|
||||
* medication-intake fix (`invalidateMedicationReads` below wraps the pair
|
||||
* for medication writes). Call this alongside
|
||||
* `invalidateKeys(queryClient, xDependentKeys)` from any write whose server
|
||||
* route already hard-evicts the snapshot bucket — the forced refetch then
|
||||
* returns post-write data immediately, no server change needed.
|
||||
|
||||
Reference in new issue
Block a user