mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
fix(links): offer the whole vault when linking a document to a record
The visit and vaccination forms asked the document list for a page of 200 while the route accepts at most 100. The refusal read as an empty list, so the picker said "No documents yet." to anyone with a full vault, and the only way a scan reached a dose was the seven-day suggestion offered at upload. The visit form's condition picker had the same problem against the episode list. The document pickers now walk the vault page by page at the route's own limit, so any document can be linked from a vaccination or a visit, and one document can sit on as many records as it covers. Documents dated within seven days of the record stay on top under their own heading, the rest follow by month and are searchable. A failed read now shows an error with a retry instead of passing for an empty list. Refs #1024, #1005
This commit is contained in:
1 parent
8f889cf942
commit
f46ed99def
17 files changed
+443
-85
No files matched your search
@@ -140,6 +140,24 @@ describe("<EntityLinkPicker> inline summary", () => {
|
||||
expect(html).not.toContain('data-slot="test-link-add"');
|
||||
});
|
||||
|
||||
it("a failed read shows an error row with retry, never the empty label", () => {
|
||||
const html = render(
|
||||
<EntityLinkPicker
|
||||
{...base}
|
||||
options={[]}
|
||||
pending={true}
|
||||
error
|
||||
errorLabel="Could not load documents"
|
||||
onRetry={() => undefined}
|
||||
selected={[]}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="test-link-error"');
|
||||
expect(html).toContain("Could not load documents");
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).not.toContain("Nothing to link");
|
||||
});
|
||||
|
||||
it("renders a skeleton while the options load", () => {
|
||||
const html = render(
|
||||
<EntityLinkPicker {...base} options={[]} pending={true} selected={[]} />,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
documentOptionsWithSuggestions,
|
||||
isNearAnchor,
|
||||
type VaultDocument,
|
||||
} from "../vault-document-options";
|
||||
|
||||
function doc(
|
||||
id: string,
|
||||
documentDate: string | null,
|
||||
extra: Partial<VaultDocument> = {},
|
||||
): VaultDocument {
|
||||
return {
|
||||
id,
|
||||
title: `Doc ${id}`,
|
||||
filename: null,
|
||||
documentDate,
|
||||
reportDate: null,
|
||||
createdAt: "2026-01-01T10:00:00.000Z",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const LABELS = {
|
||||
suggested: "Suggested",
|
||||
month: (iso: string) => iso.slice(0, 7),
|
||||
date: (iso: string) => iso.slice(0, 10),
|
||||
};
|
||||
|
||||
describe("isNearAnchor", () => {
|
||||
const anchor = "2026-05-10T00:00:00.000Z";
|
||||
it("is inclusive at seven days either side", () => {
|
||||
expect(isNearAnchor("2026-05-03", anchor)).toBe(true);
|
||||
expect(isNearAnchor("2026-05-17", anchor)).toBe(true);
|
||||
expect(isNearAnchor("2026-05-02", anchor)).toBe(false);
|
||||
expect(isNearAnchor("2026-05-18", anchor)).toBe(false);
|
||||
});
|
||||
it("never matches without an anchor or a date", () => {
|
||||
expect(isNearAnchor(null, anchor)).toBe(false);
|
||||
expect(isNearAnchor("2026-05-10", null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("documentOptionsWithSuggestions", () => {
|
||||
it("offers the WHOLE vault, not only the documents near the record's date", () => {
|
||||
// A childhood record dated years before the dose it also covers is the
|
||||
// case the old picker could not reach.
|
||||
const options = documentOptionsWithSuggestions(
|
||||
[doc("near", "2026-05-12"), doc("old", "1991-03-01")],
|
||||
"2026-05-10T00:00:00.000Z",
|
||||
LABELS,
|
||||
);
|
||||
expect(options.map((o) => o.id)).toEqual(["near", "old"]);
|
||||
});
|
||||
|
||||
it("puts the documents within the window on top under one suggestion group", () => {
|
||||
const options = documentOptionsWithSuggestions(
|
||||
[
|
||||
doc("recent", "2026-09-01"),
|
||||
doc("near", "2026-05-12"),
|
||||
doc("old", "1991-03-01"),
|
||||
],
|
||||
"2026-05-10T00:00:00.000Z",
|
||||
LABELS,
|
||||
);
|
||||
expect(options[0]).toMatchObject({
|
||||
id: "near",
|
||||
group: { key: "suggested", label: "Suggested" },
|
||||
});
|
||||
expect(options.slice(1).map((o) => o.id)).toEqual(["recent", "old"]);
|
||||
expect(options[1]!.group).toEqual({ key: "2026-09", label: "2026-09" });
|
||||
});
|
||||
|
||||
it("reads a report date when the filing date is missing", () => {
|
||||
const options = documentOptionsWithSuggestions(
|
||||
[doc("lab", null, { reportDate: "2026-05-09" })],
|
||||
"2026-05-10T00:00:00.000Z",
|
||||
LABELS,
|
||||
);
|
||||
expect(options[0]!.group?.key).toBe("suggested");
|
||||
});
|
||||
|
||||
it("without an anchor keeps the plain month grouping", () => {
|
||||
const options = documentOptionsWithSuggestions(
|
||||
[doc("a", "2026-05-12")],
|
||||
null,
|
||||
LABELS,
|
||||
);
|
||||
expect(options[0]!.group?.key).toBe("2026-05");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchWholeVault", () => {
|
||||
it("asks only for pages the list route accepts, and walks every cursor", async () => {
|
||||
const { documentListQuerySchema } =
|
||||
await import("@/lib/validations/inbound-documents");
|
||||
const apiFetch = await import("@/lib/api/api-fetch");
|
||||
const urls: string[] = [];
|
||||
const pages = [
|
||||
{ documents: [doc("a", "2026-05-01")], nextCursor: "a" },
|
||||
{ documents: [doc("b", "1991-03-01")], nextCursor: null },
|
||||
];
|
||||
const spy = vi
|
||||
.spyOn(apiFetch, "apiGet")
|
||||
.mockImplementation(async (url: string) => {
|
||||
urls.push(url);
|
||||
return pages.shift() as never;
|
||||
});
|
||||
try {
|
||||
const { fetchWholeVault } = await import("../vault-document-options");
|
||||
const documents = await fetchWholeVault();
|
||||
expect(documents.map((d) => d.id)).toEqual(["a", "b"]);
|
||||
expect(urls).toHaveLength(2);
|
||||
for (const url of urls) {
|
||||
// The route parses its query with this schema and answers a 422 to
|
||||
// anything it refuses; the picker used to read that 422 as an empty
|
||||
// vault.
|
||||
const params = Object.fromEntries(
|
||||
new URL(url, "http://local").searchParams,
|
||||
);
|
||||
expect(documentListQuerySchema.safeParse(params).success).toBe(true);
|
||||
}
|
||||
expect(urls[1]).toContain("cursor=a");
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -131,6 +132,9 @@ export function EntityLinkPicker({
|
||||
onChange,
|
||||
searchPlaceholder,
|
||||
emptyLabel,
|
||||
error = false,
|
||||
errorLabel,
|
||||
onRetry,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
@@ -142,6 +146,14 @@ export function EntityLinkPicker({
|
||||
onChange: (ids: string[]) => void;
|
||||
searchPlaceholder: string;
|
||||
emptyLabel: string;
|
||||
/**
|
||||
* The option read failed. Renders an error row with Retry instead of the
|
||||
* empty label: a failed read must never pass for "nothing to link", which
|
||||
* is exactly how a refused page size once hid a whole vault.
|
||||
*/
|
||||
error?: boolean;
|
||||
errorLabel?: string;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -175,8 +187,14 @@ export function EntityLinkPicker({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{pending ? (
|
||||
{pending && !error ? (
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
) : error ? (
|
||||
<QueryErrorRow
|
||||
message={errorLabel}
|
||||
onRetry={onRetry}
|
||||
slot={`${slot}-error`}
|
||||
/>
|
||||
) : options.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">{emptyLabel}</p>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The vault as a link-picker option list, with the documents near the
|
||||
* record's own date on top.
|
||||
*
|
||||
* The visit and vaccination forms used to fetch their document options with a
|
||||
* page size the list route refuses (`limit=200` against a ceiling of 100). The
|
||||
* 422 read as an empty list, so the picker said "No documents yet." to a
|
||||
* person with a full vault, and the only way a scan ever got attached was the
|
||||
* ±7-day suggestion offered at upload. A childhood record covering eight doses
|
||||
* from one sheet of paper could therefore be filed against exactly one of
|
||||
* them.
|
||||
*
|
||||
* Now the whole vault is offered, paged through at the route's own ceiling,
|
||||
* and a read failure says so instead of pretending the vault is empty. The
|
||||
* documents dated within the same ±{@link ENCOUNTER_SUGGEST_WINDOW_DAYS}-day
|
||||
* window the upload suggestion uses stay on top under their own heading, so
|
||||
* the common case is still one tap; everything else follows, grouped by month
|
||||
* and searchable.
|
||||
*/
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { EntityLinkOption } from "@/components/links/entity-link-picker";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { ENCOUNTER_SUGGEST_WINDOW_DAYS } from "@/lib/encounters/suggest-window";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
DOCUMENT_LIST_MAX_LIMIT,
|
||||
type InboundDocumentDto,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
/**
|
||||
* How many pages the picker walks. At the route's ceiling of 100 that is a
|
||||
* thousand documents, past which the vault's own search is the better tool.
|
||||
*/
|
||||
const VAULT_PICKER_MAX_PAGES = 10;
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/** The fields the option list reads off a document. */
|
||||
export type VaultDocument = Pick<
|
||||
InboundDocumentDto,
|
||||
"id" | "title" | "filename" | "documentDate" | "reportDate" | "createdAt"
|
||||
>;
|
||||
|
||||
interface ListPage {
|
||||
documents: InboundDocumentDto[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
/** Walk the vault newest filing date first, at the route's own page ceiling. */
|
||||
export async function fetchWholeVault(): Promise<VaultDocument[]> {
|
||||
const documents: VaultDocument[] = [];
|
||||
let cursor: string | null = null;
|
||||
for (let page = 0; page < VAULT_PICKER_MAX_PAGES; page++) {
|
||||
const params = new URLSearchParams({
|
||||
sort: "documentDate",
|
||||
order: "desc",
|
||||
limit: String(DOCUMENT_LIST_MAX_LIMIT),
|
||||
});
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
const result: ListPage = await apiGet<ListPage>(
|
||||
`/api/documents/inbound?${params.toString()}`,
|
||||
);
|
||||
documents.push(...result.documents);
|
||||
cursor = result.nextCursor;
|
||||
if (!cursor) break;
|
||||
}
|
||||
return documents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is a document dated within the suggestion window of the record's anchor?
|
||||
* Day-granular and inclusive on both edges, like the upload suggestion. A
|
||||
* document with no date of its own is never "near" anything.
|
||||
*/
|
||||
export function isNearAnchor(
|
||||
documentDay: string | null,
|
||||
anchorIso: string | null,
|
||||
): boolean {
|
||||
if (!documentDay || !anchorIso) return false;
|
||||
const day = Date.parse(`${documentDay.slice(0, 10)}T00:00:00.000Z`);
|
||||
const anchor = Date.parse(`${anchorIso.slice(0, 10)}T00:00:00.000Z`);
|
||||
if (Number.isNaN(day) || Number.isNaN(anchor)) return false;
|
||||
return Math.abs(day - anchor) <= ENCOUNTER_SUGGEST_WINDOW_DAYS * DAY_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the option list: suggestions first, under one heading, then the rest
|
||||
* of the vault in the route's order (newest filing date first), by month.
|
||||
*
|
||||
* `labels` carries the already-localised strings, so this stays a pure
|
||||
* function a test can call without a provider.
|
||||
*/
|
||||
export function documentOptionsWithSuggestions(
|
||||
documents: readonly VaultDocument[],
|
||||
anchorIso: string | null,
|
||||
labels: {
|
||||
suggested: string;
|
||||
month: (iso: string) => string;
|
||||
date: (iso: string) => string;
|
||||
},
|
||||
): EntityLinkOption[] {
|
||||
const suggested: EntityLinkOption[] = [];
|
||||
const rest: EntityLinkOption[] = [];
|
||||
for (const doc of documents) {
|
||||
const own = doc.documentDate ?? doc.reportDate;
|
||||
const date = own ?? doc.createdAt;
|
||||
const base = {
|
||||
id: doc.id,
|
||||
label: doc.title ?? doc.filename ?? doc.id,
|
||||
dateLabel: date ? labels.date(date) : null,
|
||||
};
|
||||
if (isNearAnchor(own, anchorIso)) {
|
||||
suggested.push({
|
||||
...base,
|
||||
group: { key: "suggested", label: labels.suggested },
|
||||
});
|
||||
} else {
|
||||
rest.push({
|
||||
...base,
|
||||
group: date
|
||||
? { key: date.slice(0, 7), label: labels.month(date) }
|
||||
: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...suggested, ...rest];
|
||||
}
|
||||
|
||||
/**
|
||||
* The picker's data: the whole vault as options, the suggestion group on top
|
||||
* when the record has a date, and the query state an error row needs.
|
||||
*/
|
||||
export function useVaultDocumentOptions({
|
||||
enabled,
|
||||
anchor,
|
||||
}: {
|
||||
enabled: boolean;
|
||||
/** The record's own date (ISO), or null when it has none yet. */
|
||||
anchor: string | null;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const query = useQuery({
|
||||
queryKey: queryKeys.inboundDocumentVaultPicker(),
|
||||
enabled,
|
||||
queryFn: fetchWholeVault,
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
documentOptionsWithSuggestions(query.data ?? [], anchor, {
|
||||
suggested: t("links.picker.suggested"),
|
||||
month: (iso) => `${format.monthShort(iso)} ${iso.slice(0, 4)}`,
|
||||
date: (iso) => format.date(iso),
|
||||
}),
|
||||
[query.data, anchor, t, format],
|
||||
);
|
||||
|
||||
return {
|
||||
options,
|
||||
pending: query.isPending,
|
||||
error: query.isError,
|
||||
retry: () => void query.refetch(),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user