diff --git a/src/components/documents/__tests__/document-record-links.test.tsx b/src/components/documents/__tests__/document-record-links.test.tsx index c93b34c62..d06b0653e 100644 --- a/src/components/documents/__tests__/document-record-links.test.tsx +++ b/src/components/documents/__tests__/document-record-links.test.tsx @@ -103,7 +103,12 @@ describe("recordOptions", () => { describe("", () => { it("lets a manager link the page to doses and visits", () => { const html = render( - undefined} />, + undefined} + />, ); expect(html).toContain('data-slot="document-vaccination-links"'); expect(html).toContain('data-slot="document-vaccination-links-add"'); @@ -124,6 +129,7 @@ describe("", () => { ], })} canManage={false} + seedFresh onChange={() => undefined} />, ); @@ -137,9 +143,26 @@ describe("", () => { undefined} />, ); expect(html).not.toContain("document-vaccination-links"); }); + + it("offers no picker until the document has been read since the sheet opened", () => { + // Each change is a replace-set write seeded from the document's links. + // Seeded from a cached copy that predates a link made on the dose's or + // the visit's own form, the first tap would silently delete that link. + const html = render( + undefined} + />, + ); + expect(html).not.toContain('data-slot="document-vaccination-links-add"'); + expect(html).not.toContain('data-slot="document-visit-links-add"'); + }); }); diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index c1b216982..780105bfa 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -353,11 +353,30 @@ export function DocumentDetailSheet({ const detail = useQuery({ queryKey: queryKeys.inboundDocument(documentId ?? "none"), enabled: open && documentId !== null, + // Always read again on open: the link pickers below write replace-sets + // seeded from this copy (see `seedFresh`). + staleTime: 0, queryFn: () => apiGet(`/api/documents/inbound/${documentId}`), }); const doc = detail.data; + // The detail's timestamp when the sheet opened on this document. A read + // that lands after it is fresh enough to seed the link pickers from; a + // cached copy from before the sheet opened is not. + const [openedOn, setOpenedOn] = useState<{ + id: string; + seenAt: number; + } | null>(null); + const openKey = open && documentId !== null ? documentId : null; + if (openKey !== (openedOn?.id ?? null)) { + setOpenedOn( + openKey === null ? null : { id: openKey, seenAt: detail.dataUpdatedAt }, + ); + } + const linkSeedFresh = + openedOn !== null && detail.dataUpdatedAt > openedOn.seenAt; + // 2026-07-17 UX/IA audit M5 / F5-5 — the document → lab-values direction // had no UI link at all: an auto-staged (PENDING) or OCR-confirmed // (APPROVED, see `linkOcrLabsToVaultDocument`) OBSERVATION fact already @@ -1111,6 +1130,9 @@ export function DocumentDetailSheet({ key={`${doc.id}:${linkResets}`} doc={doc} canManage={canManageDocuments} + seedFresh={linkSeedFresh} + seedError={!linkSeedFresh && detail.isRefetchError} + onRetrySeed={() => void detail.refetch()} onChange={(part) => patch.mutate(part)} /> diff --git a/src/components/documents/document-record-links.tsx b/src/components/documents/document-record-links.tsx index 7899443c2..cbf9e0b0a 100644 --- a/src/components/documents/document-record-links.tsx +++ b/src/components/documents/document-record-links.tsx @@ -91,10 +91,24 @@ function doseName( export function DocumentRecordLinks({ doc, canManage, + seedFresh, + seedError = false, + onRetrySeed, onChange, }: { doc: InboundDocumentDetailDto; canManage: boolean; + /** + * Whether `doc` was read from the server since the sheet opened. Every + * change is a replace-set write seeded from `doc`'s links, so a seed from a + * cached copy that predates a link made on the dose's or the visit's own + * form would delete that link on the first tap. The pickers wait for a + * fresh read instead. + */ + seedFresh: boolean; + /** That fresh read failed; the pickers show the retry row instead. */ + seedError?: boolean; + onRetrySeed?: () => void; onChange: (part: { encounterIds?: string[]; vaccinationIds?: string[]; @@ -155,10 +169,13 @@ export function DocumentRecordLinks({ icon={CalendarDays} title={t("documents.detail.visitsLabel")} slot="document-visit-links" - pending={encounters.isPending} - error={encounters.isError} + pending={encounters.isPending || !seedFresh} + error={encounters.isError || seedError} errorLabel={t("links.picker.loadError")} - onRetry={() => void encounters.refetch()} + onRetry={() => { + if (encounters.isError) void encounters.refetch(); + if (seedError) onRetrySeed?.(); + }} selected={visitIds ?? doc.encounterLinks.map((l) => l.encounterId)} onChange={(ids) => { setVisitIds(ids); @@ -199,10 +216,13 @@ export function DocumentRecordLinks({ icon={Syringe} title={t("documents.detail.vaccinationsLabel")} slot="document-vaccination-links" - pending={vaccinations.isPending} - error={vaccinations.isError} + pending={vaccinations.isPending || !seedFresh} + error={vaccinations.isError || seedError} errorLabel={t("links.picker.loadError")} - onRetry={() => void vaccinations.refetch()} + onRetry={() => { + if (vaccinations.isError) void vaccinations.refetch(); + if (seedError) onRetrySeed?.(); + }} selected={ doseIds ?? (doc.vaccinationLinks ?? []).map((link) => link.vaccinationId) diff --git a/src/lib/query-keys/__tests__/link-dependent-keys.test.ts b/src/lib/query-keys/__tests__/link-dependent-keys.test.ts new file mode 100644 index 000000000..e6654cc6e --- /dev/null +++ b/src/lib/query-keys/__tests__/link-dependent-keys.test.ts @@ -0,0 +1,25 @@ +/** + * A visit or a dose written from its own form can link or unlink documents, + * and the document's sheet seeds its replace-set pickers from its cached + * links. A record write that left the document reads alone kept that seed + * stale, and the next tap on the sheet deleted the new link. Both record + * bundles therefore evict the document root, and the document's own link + * write evicts the record roots (pinned in `document-detail-sheet`). + */ +import { describe, expect, it } from "vitest"; + +import { + encounterDependentKeys, + queryKeys, + vaccinationDependentKeys, +} from "@/lib/query-keys"; + +describe("record writes reach the document reads", () => { + it("a dose write evicts the documents root", () => { + expect(vaccinationDependentKeys).toContainEqual(queryKeys.documents()); + }); + + it("a visit write evicts the documents root", () => { + expect(encounterDependentKeys).toContainEqual(queryKeys.documents()); + }); +}); diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index ed83c7676..861b4b276 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -281,6 +281,10 @@ export const encounterDependentKeys = [ // `invalidateReminderReads` helper, which carries it) — the digest is // typically unmounted while the user is on the visits surface. queryKeys.dailyDigest(), + // A visit's form links and unlinks documents, and the document's sheet + // seeds its replace-set visit picker from its own cached links: left + // stale, the next tap there would delete the link just made. + queryKeys.documents(), ]; /** @@ -300,6 +304,10 @@ export const encounterDependentKeys = [ export const vaccinationDependentKeys = [ queryKeys.vaccinations(), queryKeys.measurementReminders(), + // A dose's form links and unlinks documents, and the document's sheet + // seeds its replace-set dose picker from its own cached links: left stale, + // the next tap there would delete the link just made. + queryKeys.documents(), ]; /**