mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Merge branch 'fix/v1473-writes' into release/v1.47.3
This commit is contained in:
commit
6a02cda210
73 files changed
+1414
-356
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,
|
||||
@@ -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,
|
||||
@@ -172,7 +172,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,
|
||||
@@ -183,7 +183,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,
|
||||
|
||||
@@ -1069,7 +1069,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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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<string>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -344,8 +345,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 },
|
||||
|
||||
+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}
|
||||
|
||||
|
||||
@@ -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 { 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";
|
||||
|
||||
@@ -55,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());
|
||||
@@ -85,7 +87,7 @@ export function CustomMetricEntryForm({
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await apiPost<CustomMetricEntryDto>(
|
||||
await post<CustomMetricEntryDto>(
|
||||
`/api/custom-metrics/${customMetricId}/entries`,
|
||||
body,
|
||||
);
|
||||
@@ -99,11 +101,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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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]*?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(
|
||||
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.
|
||||
|
||||
@@ -27,7 +27,9 @@ 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 } from "@/lib/api/api-fetch";
|
||||
import { useIdempotentPost } from "@/lib/api/use-idempotent-post";
|
||||
import { localizedApiError } from "@/lib/api/localized-error";
|
||||
import {
|
||||
intakeToastOptions,
|
||||
runUndoIntake,
|
||||
@@ -118,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.
|
||||
@@ -225,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,
|
||||
@@ -235,13 +238,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,11 +268,16 @@ 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
|
||||
? err.message
|
||||
: t("dashboard.medicationIntakeQuickAdd.saveError"),
|
||||
localizedApiError(
|
||||
err,
|
||||
t,
|
||||
"dashboard.medicationIntakeQuickAdd.saveError",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }) => (
|
||||
<div data-slot="mock-sheet">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
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(
|
||||
<I18nProvider initialLocale="en">
|
||||
<DocumentPickerSheet
|
||||
open
|
||||
onOpenChange={() => undefined}
|
||||
title="Link documents"
|
||||
description="Pick documents"
|
||||
searchPlaceholder="Search"
|
||||
emptyLabel="No documents"
|
||||
isPicked={() => false}
|
||||
onToggle={() => undefined}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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"');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<ResponsiveSheet
|
||||
@@ -188,6 +191,16 @@ export function DocumentPickerSheet({
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{cut && !list.isError ? (
|
||||
<p
|
||||
data-slot="document-picker-capped"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("documents.picker.capped", {
|
||||
count: String(documents.length),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -49,6 +50,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 {
|
||||
@@ -84,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
|
||||
@@ -128,8 +130,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 });
|
||||
}
|
||||
@@ -210,8 +211,25 @@ export function EncounterSheet({
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
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,
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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<LabResultDto>("/api/labs", {
|
||||
const created = await post<LabResultDto>("/api/labs", {
|
||||
biomarkerId,
|
||||
...(isQualitative
|
||||
? { valueText: qualitativeValue }
|
||||
|
||||
@@ -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 : []),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
<FieldError id={editValueErrorId} message={editValueError} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -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<MeasurementRowDisplay, "rowDisplay">,
|
||||
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<typeof useTranslations>["t"];
|
||||
|
||||
/** The sentence for a value outside its band, in the unit the input shows. */
|
||||
function outOfRangeMessage(
|
||||
display: Pick<MeasurementRowDisplay, "rowDisplay">,
|
||||
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<Measurement | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
@@ -105,10 +163,13 @@ export function useMeasurementEdit({
|
||||
notes: "",
|
||||
});
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
// A refused value is said under the value input, not in the banner.
|
||||
const [editValueError, setEditValueError] = useState<string | null>(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<HTMLDivElement | null>(null);
|
||||
@@ -127,39 +188,30 @@ 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
|
||||
// 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,
|
||||
|
||||
@@ -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,
|
||||
@@ -528,8 +529,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 +543,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 {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<DroppedLinkKeysWire>("/api/mood-entries", {
|
||||
const saved = await post<DroppedLinkKeysWire>("/api/mood-entries", {
|
||||
mood,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
tagKeys: tagKeys.length > 0 ? tagKeys : undefined,
|
||||
@@ -257,13 +258,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 {
|
||||
|
||||
@@ -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<typeof deleteWithUndo>[0];
|
||||
|
||||
const t = (key: string) => key;
|
||||
|
||||
function mutations(restoreResult: Promise<unknown>) {
|
||||
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(),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof usePractitionerMutations>,
|
||||
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<Practitioner | "new" | null>(null);
|
||||
const [deleting, setDeleting] = useState<Practitioner | null>(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")}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ApiError, 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,
|
||||
allergySeverityEnum,
|
||||
@@ -66,6 +68,7 @@ export function AllergyForm({
|
||||
footerSlot,
|
||||
}: AllergyFormProps) {
|
||||
const { t } = useTranslations();
|
||||
const post = useIdempotentPost();
|
||||
const formId = useId();
|
||||
|
||||
const [substance, setSubstance] = useState(existing?.substance ?? "");
|
||||
@@ -105,7 +108,7 @@ export function AllergyForm({
|
||||
try {
|
||||
const saved = existing
|
||||
? await apiPut<AllergyDTO>(`/api/allergies/${existing.id}`, body)
|
||||
: await apiPost<AllergyDTO>("/api/allergies", body);
|
||||
: await post<AllergyDTO>("/api/allergies", body);
|
||||
toast.success(
|
||||
existing
|
||||
? t("records.allergies.form.updatedToast")
|
||||
@@ -113,11 +116,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);
|
||||
}
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -6,11 +6,18 @@
|
||||
* 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 { useIdempotentPost } from "@/lib/api/use-idempotent-post";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import type {
|
||||
SymptomDefinitionDTO,
|
||||
@@ -118,9 +125,24 @@ 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(),
|
||||
]);
|
||||
}
|
||||
|
||||
// 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 () => invalidateKeys(qc, [queryKeys.illness()]);
|
||||
return () => void invalidateSymptomReads(qc);
|
||||
}
|
||||
|
||||
export function useCreateSymptomDefinition() {
|
||||
@@ -163,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<SymptomEventDTO>("/api/symptoms/events", input),
|
||||
post<SymptomEventDTO>("/api/symptoms/events", input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -125,16 +126,19 @@ 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),
|
||||
]);
|
||||
|
||||
// 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<Vaccination>(BASE, body),
|
||||
mutationFn: (body: VaccinationWriteBody) => post<Vaccination>(BASE, body),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -36,6 +37,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";
|
||||
@@ -68,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);
|
||||
@@ -99,8 +101,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 });
|
||||
}
|
||||
@@ -198,8 +199,28 @@ export function VaccinationSheet({
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
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,
|
||||
|
||||
@@ -128,9 +128,10 @@ describe("<ManualWorkoutForm>", () => {
|
||||
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, {
|
||||
|
||||
@@ -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,29 @@ 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("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 () => {
|
||||
@@ -177,8 +202,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,8 +53,8 @@ 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 refetchInactiveDailyReads(queryClient);
|
||||
void invalidateKeys(queryClient, workoutDependentKeys);
|
||||
void refetchInactiveDailyReads(queryClient);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,10 +21,15 @@ 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 { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
|
||||
import {
|
||||
invalidateKeys,
|
||||
refetchInactiveDailyReads,
|
||||
workoutDependentKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import {
|
||||
buildManualWorkoutEntry,
|
||||
@@ -90,7 +95,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.
|
||||
*/
|
||||
@@ -105,12 +111,11 @@ 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() });
|
||||
await refetchInactiveDailyReads(queryClient);
|
||||
// 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.
|
||||
void invalidateKeys(queryClient, workoutDependentKeys);
|
||||
void refetchInactiveDailyReads(queryClient);
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -228,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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,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 {
|
||||
encounterDependentKeys,
|
||||
@@ -166,17 +167,20 @@ 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),
|
||||
]);
|
||||
|
||||
// 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<Encounter>(BASE, body),
|
||||
mutationFn: (body: EncounterWriteBody) => post<Encounter>(BASE, body),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
@@ -194,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<Encounter>(`${BASE}/${id}/restore`, {}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { create, update, remove, restore };
|
||||
}
|
||||
@@ -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<unknown> {
|
||||
return Promise.all([
|
||||
qc.invalidateQueries({ queryKey: queryKeys.measurementReminders() }),
|
||||
invalidateKeys(qc, [
|
||||
queryKeys.measurementReminders(),
|
||||
queryKeys.dailyDigest(),
|
||||
queryKeys.dashboardSnapshot(),
|
||||
queryKeys.dayRoot(),
|
||||
]),
|
||||
refetchInactiveDailyReads(qc),
|
||||
]);
|
||||
}
|
||||
@@ -103,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(),
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
@@ -90,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<Practitioner>(`${BASE}/${id}/restore`, {}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
return { create, update, remove, restore };
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof useIdempotentPost>;
|
||||
|
||||
function openForm(): Post {
|
||||
let post: Post | null = null;
|
||||
function Probe() {
|
||||
post = useIdempotentPost();
|
||||
return null;
|
||||
}
|
||||
renderToStaticMarkup(<Probe />);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,8 @@
|
||||
* through `readError` (server envelope `error` string, falling back
|
||||
* to `Request failed (<status>)`), `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<string, unknown>,
|
||||
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<T, M = Record<string, unknown>> {
|
||||
|
||||
async function throwApiError(res: Response): Promise<never> {
|
||||
let meta: Record<string, unknown> | 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<string, unknown>;
|
||||
}
|
||||
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<never> {
|
||||
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.
|
||||
@@ -243,12 +262,11 @@ export function apiGet<T = unknown>(
|
||||
* 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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
return async function post<T = unknown>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
key.current ??= randomId();
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Idempotency-Key", key.current);
|
||||
const result = await apiPost<T>(path, body, { ...init, headers });
|
||||
key.current = null;
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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