Merge branch 'v1424/fixes' into release/v1.42.4

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 12:23:55 +02:00
commit de40db7ad0
49 files changed
+2579 -269

No files matched your search

+43
View File
@@ -14833,6 +14833,12 @@ paths:
type: string
minLength: 1
maxLength: 120
- in: query
name: q
schema:
type: string
minLength: 1
maxLength: 120
- in: query
name: from
schema:
@@ -15372,6 +15378,12 @@ paths:
type: integer
minimum: 1
maximum: 200
- in: query
name: offset
schema:
type: integer
minimum: 0
maximum: 100000
responses:
"200":
description: The caller's visits.
@@ -16315,6 +16327,37 @@ paths:
const: "true"
- type: string
const: "false"
- in: query
name: offset
schema:
type: integer
minimum: 0
maximum: 100000
- in: query
name: from
schema:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- in: query
name: to
schema:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- in: query
name: sortDir
schema:
type: string
enum:
- asc
- desc
- in: query
name: q
schema:
type: string
minLength: 1
maxLength: 120
responses:
"200":
description: The caller's illness episodes.
+1 -1
View File
@@ -560,7 +560,7 @@ test("a chosen range lives in the URL: reload keeps it, Back returns to it", asy
// A parameter it cannot read opens the same default as no zoom: this
// record has entries in the last three months, so three months.
await page.goto("/timeline?zoom=range&from=nonsense&to=2026-01-01");
await page.goto(`/timeline?zoom=range&from=nonsense&to=${TODAY}`);
await expect(zoom("quarter")).toHaveAttribute("aria-checked", "true", {
timeout: 20_000,
});
+10 -2
View File
@@ -23,13 +23,21 @@ function correlation(r: number, strength: string, n: number) {
};
}
/**
* A calendar day this many days back, so the heatmap cells stay inside its
* 90-day window whenever the suite runs.
*/
function dayKeyDaysAgo(days: number): string {
return new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10);
}
const MOOD_INSIGHTS_PAYLOAD = {
summary: { totalEntries: 269, inTargetPct: 62 },
heatmap: {
windowDays: 90,
cells: [
{ date: "2026-07-01", score: 4, samples: 2 },
{ date: "2026-07-02", score: 3, samples: 1 },
{ date: dayKeyDaysAgo(14), score: 4, samples: 2 },
{ date: dayKeyDaysAgo(13), score: 3, samples: 1 },
],
},
distribution: [],
+4 -1
View File
@@ -12174,7 +12174,10 @@
"suggested": "Um dieses Datum",
"loadError": "Die Liste konnte nicht geladen werden.",
"removeNamed": "{name} entfernen",
"removed": "Verknüpfung mit {name} gelöst"
"removed": "Verknüpfung mit {name} gelöst",
"linked": "Verknüpft",
"loadNewer": "Neuere laden",
"loadOlder": "Ältere laden"
}
},
"bodySites": {
+4 -1
View File
@@ -12174,7 +12174,10 @@
"suggested": "Around this date",
"loadError": "The list could not be loaded.",
"removeNamed": "Remove {name}",
"removed": "Unlinked {name}"
"removed": "Unlinked {name}",
"linked": "Linked",
"loadNewer": "Load newer",
"loadOlder": "Load older"
}
},
"bodySites": {
+4 -1
View File
@@ -12174,7 +12174,10 @@
"suggested": "En torno a esta fecha",
"loadError": "No se pudo cargar la lista.",
"removeNamed": "Quitar {name}",
"removed": "Se desvinculó {name}"
"removed": "Se desvinculó {name}",
"linked": "Vinculados",
"loadNewer": "Cargar más recientes",
"loadOlder": "Cargar anteriores"
}
},
"bodySites": {
+4 -1
View File
@@ -12174,7 +12174,10 @@
"suggested": "Autour de cette date",
"loadError": "Impossible de charger la liste.",
"removeNamed": "Retirer {name}",
"removed": "{name} dissocié"
"removed": "{name} dissocié",
"linked": "Liés",
"loadNewer": "Charger les plus récents",
"loadOlder": "Charger les plus anciens"
}
},
"bodySites": {
+4 -1
View File
@@ -12174,7 +12174,10 @@
"suggested": "Intorno a questa data",
"loadError": "Impossibile caricare l’elenco.",
"removeNamed": "Rimuovi {name}",
"removed": "Collegamento a {name} rimosso"
"removed": "Collegamento a {name} rimosso",
"linked": "Collegati",
"loadNewer": "Carica più recenti",
"loadOlder": "Carica meno recenti"
}
},
"bodySites": {
+4 -1
View File
@@ -12174,7 +12174,10 @@
"suggested": "이 날짜 전후",
"loadError": "목록을 불러오지 못했습니다.",
"removeNamed": "{name} 제거",
"removed": "{name} 연결 해제됨"
"removed": "{name} 연결 해제됨",
"linked": "연결됨",
"loadNewer": "최근 항목 더 보기",
"loadOlder": "이전 항목 더 보기"
}
},
"bodySites": {
+4 -1
View File
@@ -12174,7 +12174,10 @@
"suggested": "Wokół tej daty",
"loadError": "Nie udało się wczytać listy.",
"removeNamed": "Usuń {name}",
"removed": "Odłączono: {name}"
"removed": "Odłączono: {name}",
"linked": "Połączone",
"loadNewer": "Wczytaj nowsze",
"loadOlder": "Wczytaj starsze"
}
},
"bodySites": {
@@ -0,0 +1,203 @@
/**
* Guard: an integration or e2e fixture dated near "now" must not feed a time
* window.
*
* A fixture written today with an absolute date (`new Date("2026-07-15…")`,
* `?date=2026-07-02`) next to a window parameter passes on the day it is
* written and fails months later, when the date slides out of a window the
* route counts back from the clock. The suite then goes red on a release
* branch that changed nothing near it, and the fix is archaeology. That has
* happened more than once (chart tabs, the 90-day share-link window).
*
* The rule: an absolute ISO date within the last 400 days (or in the future)
* that sits within two lines of a window parameter (`days=`, `from=`, `to=`,
* `since`, `until`, `date=`, `range=`, `rangeDays`, `windowDays`,
* `lookbackDays`) is a
* finding. Fix it by dating the fixture relative to now
* (`new Date(Date.now() - 3 * DAY_MS)`, a `daysAgo(n)` helper), or, when the
* window is fixed on both ends and the route never measures it against the
* clock, allowlist the file below with that reason and the number of findings
* it may carry, so a new one in the same file still fails.
*
* What it cannot see: a fixture date and a relative window that are further
* apart than two lines (a seed helper at the top of the file, `days=90` in a
* request at the bottom). It is a textual net for the common shape, not a
* proof; the clock-shifted run described in the allowlist reasons is what
* proved the allowlisted files fixed.
*/
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = path.resolve(__dirname, "..", "..");
const SCAN_ROOTS = ["tests/integration", "e2e"];
const DAY_MS = 86_400_000;
/** How far back an absolute date still counts as "near now". */
const HORIZON_DAYS = 400;
/** Lines either side of a date searched for a window parameter. */
const NEIGHBOURHOOD = 2;
const ISO_DATE = /\b(20\d\d)-(\d\d)-(\d\d)/g;
const WINDOW_PARAM =
/[?&](?:days|from|to|since|until|date|range|rangeDays|windowDays)=|\b(?:since|until|rangeDays|windowDays|lookbackDays)\b\s*[:=]/;
/**
* Files whose flagged dates feed windows fixed on both ends, which the routes
* never compare with the clock. `max` is the number of findings the file may
* carry; one more fails.
*/
const ALLOWLIST: Record<string, { max: number; reason: string }> = {
// Each entry passed with the test clock moved a year forward, which is what
// an aging fixture fails under; the reason says why it cannot age.
"tests/integration/day-view.test.ts": {
max: 21,
reason:
"day index, timeline and notable reads are asked for explicit from/to windows; the reminder since/until params are fixture data, not a window counted from now",
},
"tests/integration/sharing-delegable-routes.test.ts": {
max: 5,
reason:
"day-log reads name one explicit calendar day (?date=), never a window counted from now",
},
"tests/integration/measurements-aggregate-daily.test.ts": {
max: 5,
reason: "every series read passes both from and to",
},
"tests/integration/cycle-routes.test.ts": {
max: 5,
reason:
"day-log reads name one explicit day and the calendar read passes both from and to",
},
"tests/integration/medication-auto-mark-taken.test.ts": {
max: 2,
reason: "the cases pin the clock with fake timers around the fixed dates",
},
"tests/integration/symptoms.test.ts": {
max: 1,
reason:
"the discovery matrix reads from a fixed since forward; the fixture days stay inside it as now moves on",
},
};
export interface AgingFinding {
file: string;
line: number;
text: string;
}
/** Every near-now absolute date within reach of a window parameter. */
export function findAgingFixtureDates(
files: ReadonlyArray<{ file: string; source: string }>,
now: number,
): AgingFinding[] {
const floor = now - HORIZON_DAYS * DAY_MS;
const findings: AgingFinding[] = [];
for (const { file, source } of files) {
const lines = source.split("\n");
lines.forEach((text, index) => {
for (const match of text.matchAll(ISO_DATE)) {
const at = Date.UTC(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
);
if (Number.isNaN(at) || at < floor) continue;
const around = lines
.slice(Math.max(0, index - NEIGHBOURHOOD), index + NEIGHBOURHOOD + 1)
.join("\n");
if (WINDOW_PARAM.test(around)) {
findings.push({ file, line: index + 1, text: text.trim() });
break;
}
}
});
}
return findings;
}
function walk(dir: string): string[] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return walk(full);
return /\.(ts|tsx|mts)$/.test(entry.name) ? [full] : [];
});
}
function scannedFiles() {
return SCAN_ROOTS.flatMap((root) => walk(path.join(ROOT, root))).map(
(full) => ({
file: path.relative(ROOT, full),
source: fs.readFileSync(full, "utf8"),
}),
);
}
describe("aging fixture dates", () => {
const files = scannedFiles();
it("scans the integration and e2e trees (never vacuously)", () => {
expect(files.length).toBeGreaterThan(300);
expect(files.some((f) => f.file.startsWith("e2e/"))).toBe(true);
});
it("no near-now absolute date feeds a window outside the allowlist", () => {
const findings = findAgingFixtureDates(files, Date.now());
const byFile = new Map<string, AgingFinding[]>();
for (const finding of findings) {
const list = byFile.get(finding.file) ?? [];
list.push(finding);
byFile.set(finding.file, list);
}
const offenders: string[] = [];
for (const [file, list] of byFile) {
const allowed = ALLOWLIST[file]?.max ?? 0;
if (list.length > allowed) {
offenders.push(
...list.map(
(f) =>
`${f.file}:${f.line} ${f.text}\n -> date it relative to now (Date.now() - n * DAY_MS), or allowlist a fixed window with its reason`,
),
);
}
}
expect(offenders, offenders.join("\n")).toEqual([]);
});
it("every allowlist entry still names a file with findings", () => {
// A stale entry would quietly allow the next finding in that file.
const findings = findAgingFixtureDates(files, Date.now());
for (const file of Object.keys(ALLOWLIST)) {
expect(
findings.some((f) => f.file === file),
`${file} has no findings left; drop its allowlist entry`,
).toBe(true);
}
});
it("flags a near-now fixture next to a window, and nothing older", () => {
const now = Date.UTC(2026, 9, 15);
const findings = findAgingFixtureDates(
[
{
file: "a.test.ts",
source: [
'const takenAt = new Date("2026-07-15T08:00:00Z");',
'await get("/api/series?kind=pulse&days=90");',
].join("\n"),
},
{ file: "b.test.ts", source: 'get("/api/day-logs?date=2026-07-02");' },
{
file: "c.test.ts",
source: 'get("/api/labs?from=2019-04-01T00:00:00Z&to=2019-05-01");',
},
{ file: "d.test.ts", source: 'const at = new Date("2026-07-15");' },
],
now,
);
expect(findings.map((f) => `${f.file}:${f.line}`)).toEqual([
"a.test.ts:1",
"b.test.ts:1",
]);
});
});
+6 -2
View File
@@ -63,6 +63,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
practitionerId: params.get("practitionerId") ?? undefined,
episodeId: params.get("episodeId") ?? undefined,
limit: params.get("limit") ?? undefined,
offset: params.get("offset") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
@@ -72,6 +73,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
const query = parsed.data;
const take = query.limit ?? 100;
const skip = query.offset ?? 0;
const now = new Date();
const where = {
@@ -112,8 +114,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
// `gt` and `gte` are different keys, so the caller's lower bound and this
// arm's floor both survive the spread; only the `lte` pair collided.
where: { ...where, occurredAt: { ...where.occurredAt, gt: now } },
orderBy: { occurredAt: "asc" },
orderBy: [{ occurredAt: "asc" }, { id: "asc" }],
take,
skip,
include: ENCOUNTER_INCLUDE,
}),
prisma.encounter.findMany({
@@ -121,8 +124,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
...where,
occurredAt: { ...where.occurredAt, lte: pastCeiling },
},
orderBy: { occurredAt: "desc" },
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take,
skip,
include: ENCOUNTER_INCLUDE,
}),
]);
+19 -1
View File
@@ -43,6 +43,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
const parsed = illnessEpisodeListQuerySchema.safeParse({
limit: params.get("limit") ?? undefined,
includeResolved: params.get("includeResolved") ?? undefined,
offset: params.get("offset") ?? undefined,
from: params.get("from") ?? undefined,
to: params.get("to") ?? undefined,
sortDir: params.get("sortDir") ?? undefined,
q: params.get("q") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
@@ -50,15 +55,28 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
}
const { from, to, q, offset, sortDir } = parsed.data;
const limit = parsed.data.limit ?? 50;
const order = sortDir ?? "desc";
const rows = await prisma.illnessEpisode.findMany({
where: {
userId: user.id,
deletedAt: null,
...(parsed.data.includeResolved === "false" ? { resolvedAt: null } : {}),
...(from || to
? {
onsetAt: {
...(from ? { gte: new Date(from) } : {}),
...(to ? { lte: new Date(to) } : {}),
},
}
: {}),
...(q ? { label: { contains: q, mode: "insensitive" as const } } : {}),
},
orderBy: { onsetAt: "desc" },
// `id` breaks ties so offset paging never repeats or drops a row.
orderBy: [{ onsetAt: order }, { id: order }],
take: limit,
skip: offset ?? 0,
});
annotate({
+7 -1
View File
@@ -85,7 +85,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
const { biomarkerId, analyte, panel, from, to, limit, offset, sortDir } =
const { biomarkerId, analyte, panel, q, from, to, limit, offset, sortDir } =
parsed.data;
const where = {
@@ -94,6 +94,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
...(biomarkerId && { biomarkerId }),
...(analyte && { analyte }),
...(panel && { panel }),
...(q && {
OR: [
{ analyte: { contains: q, mode: "insensitive" as const } },
{ panel: { contains: q, mode: "insensitive" as const } },
],
}),
...(from || to
? {
takenAt: {
@@ -1380,9 +1380,11 @@ describe("POST /api/medications/intake/bulk — v1.28 Apple Health dose events",
});
it("stamps the externalId onto the pending slot row when the Apple dose converges by band", async () => {
// Dedup pre-check first (empty), then the canonical-slot find returns
// the worker-minted pending REMINDER row for the 19:00 slot.
// Dedup pre-check first (empty), then the held-dose probe (no take on
// the slot yet), then the canonical-slot find returns the worker-minted
// pending REMINDER row for the 19:00 slot.
vi.mocked(prisma.medicationIntakeEvent.findMany)
.mockResolvedValueOnce([] as never)
.mockResolvedValueOnce([] as never)
.mockResolvedValueOnce([
{
@@ -65,6 +65,7 @@ import {
resolveForcedSlotForWrite,
resolveSlotForWriteByBand,
resolveSlotInstantForWrite,
slotHeldByAnotherDose,
} from "@/lib/medications/scheduling/slot-upsert";
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
import {
@@ -338,6 +339,9 @@ async function postBulk(request: NextRequest): Promise<Response> {
let inserted = 0;
let updated = 0;
let duplicates = 0;
// External dose records kept as their own unscheduled dose because their
// band slot already carried a different one.
let extraDosesUnscheduled = 0;
const skipped: Array<{ index: number; reason: string }> = [];
const unstableShapes: UnstableExternalIdShape[] = [];
// v1.4.39 W-MED — collect distinct `(medicationId, dayKey)` pairs
@@ -443,6 +447,9 @@ async function postBulk(request: NextRequest): Promise<Response> {
// a band decision. Pending echoes / skips carry no decision
// (undefined → the upsert leaves an existing USER_PIN untouched).
let attributionSource: "AUTO" | "USER_PIN" | undefined;
// Set when the band slot is held by a different dose and this take is
// recorded unscheduled; it must not converge onto any slot row below.
let heldByAnotherDose = false;
if (isExplicitTaken && entry.forceSlotInstant !== undefined) {
canonicalSlot = await resolveForcedSlotForWrite({
userId: user.id,
@@ -486,6 +493,26 @@ async function postBulk(request: NextRequest): Promise<Response> {
});
canonicalSlot = attribution.slotInstant;
attributionSource = "AUTO";
// An external dose record (a HealthKit dose event) whose band slot
// already carries a different recorded dose is an extra dose, not a
// correction of that one. Converging would overwrite the slot's
// take last-write-wins and the extra dose would exist nowhere; it is
// recorded as its own unscheduled dose instead.
if (
canonicalSlot !== null &&
entry.externalId !== undefined &&
(await slotHeldByAnotherDose({
userId: user.id,
medicationId: entry.medicationId,
canonicalSlot,
takenAt: entry.takenAt as Date,
externalId: entry.externalId,
}))
) {
canonicalSlot = null;
heldByAnotherDose = true;
extraDosesUnscheduled += 1;
}
} else if (isExplicitSkip && entry.scheduledFor === undefined) {
// v1.16.9 — a slot-less deliberate skip resolves by band
// membership on the skip moment, exactly like a take does. The
@@ -666,6 +693,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
// late-morning dose consuming the evening slot). It records
// standalone (ad-hoc) instead.
const existingSlotRow =
!heldByAnotherDose &&
entry.scheduledFor !== undefined &&
mayConvergeOntoSuppliedSlot({
skipped: entry.skipped,
@@ -878,6 +906,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
updated,
duplicates,
skipped: skipped.length,
extra_doses_unscheduled: extraDosesUnscheduled,
...(unstableShapes.length > 0
? unstableExternalIdMeta("medication.intake.bulk", unstableShapes)
: {}),
@@ -4,12 +4,26 @@ import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: { modules: { vaccinations: true } } }),
}));
vi.mock("@/hooks/use-encounters", () => ({
useEncounters: () => ({
data: { upcoming: [], past: [] },
isPending: false,
isError: false,
refetch: vi.fn(),
vi.mock("@/components/links/use-link-picker-pages", async (importOriginal) => ({
...(await importOriginal<
typeof import("@/components/links/use-link-picker-pages")
>()),
// The visit picker's pages: none loaded, nothing more to page.
useLinkPickerPages: () => ({
items: [],
pending: false,
error: false,
retry: vi.fn(),
term: "",
setTerm: vi.fn(),
searchPending: false,
filterLocally: true,
hasOlder: false,
hasNewer: false,
loadingOlder: false,
loadingNewer: false,
loadOlder: vi.fn(),
loadNewer: vi.fn(),
}),
}));
vi.mock("@/components/vaccinations/use-vaccinations", () => ({
@@ -23,18 +23,31 @@
* reach the doses (`vaccinationLinks: null`) shows no dose block rather than
* an empty one that would claim the page records none.
*/
import { useState } from "react";
import { useMemo, useState } from "react";
import Link from "next/link";
import { CalendarDays, Syringe } from "lucide-react";
import { EntityLinkPicker } from "@/components/links/entity-link-picker";
import {
EntityLinkPicker,
serverPagingFrom,
} from "@/components/links/entity-link-picker";
import type { EntityLinkOption } from "@/components/links/entity-link-picker";
import { isNearAnchor } from "@/components/links/vault-document-options";
import { encounterPickerUrl } from "@/components/links/picker-requests";
import {
PICKER_PAGE_SIZE,
pickerPivot,
useLinkPickerPages,
type PickerPage,
type PickerPageRequest,
} from "@/components/links/use-link-picker-pages";
import { encounterKindText } from "@/components/encounters/encounter-labels";
import { useVaccinations } from "@/components/vaccinations/use-vaccinations";
import { useEncounters } from "@/hooks/use-encounters";
import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { EncounterDTO, EncounterListDTO } from "@/lib/encounters/dto";
import type { EncounterKind } from "@/generated/prisma/client";
import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents";
@@ -75,6 +88,25 @@ export function recordOptions(
return [...near, ...rest];
}
/**
* One picker page of visits. The route answers in two arms, each paged by the
* same offset; another page exists while either arm came back full.
*/
export async function fetchEncounterPage(
request: PickerPageRequest,
): Promise<PickerPage<EncounterDTO>> {
const list = await apiGet<EncounterListDTO>(encounterPickerUrl(request));
const full =
list.upcoming.length >= PICKER_PAGE_SIZE ||
list.past.length >= PICKER_PAGE_SIZE;
return {
items: [...list.upcoming, ...list.past],
next: full
? String(Number(request.cursor ?? "0") + PICKER_PAGE_SIZE)
: null,
};
}
/** Where a visit opens: its sheet on the checkups page. */
function visitHref(encounterId: string): string {
return `/checkups?visit=${encodeURIComponent(encounterId)}`;
@@ -132,7 +164,16 @@ export function DocumentRecordLinks({
const vaccinationsOn = user?.modules?.vaccinations !== false;
const anchor = doc.documentDate ?? doc.reportDate;
const encounters = useEncounters(canManage);
// The visits around the document's date, paged older and newer from
// there. The visit route has no text search (its free text is ciphertext
// at rest), so the term filters what is loaded.
const encounters = useLinkPickerPages<EncounterDTO>({
enabled: canManage,
pivot: pickerPivot(anchor),
queryKey: queryKeys.encounterPicker,
fetchPage: fetchEncounterPage,
serverSearch: false,
});
const vaccinations = useVaccinations(
null,
canManage && vaccinationsOn && doc.vaccinationLinks !== null,
@@ -150,16 +191,16 @@ export function DocumentRecordLinks({
};
const visitOptions = recordOptions(
[
...(encounters.data?.upcoming ?? []),
...(encounters.data?.past ?? []),
].map((visit) => ({
id: visit.id,
label: encounterKindText(t, visit.kind as EncounterKind),
meta: visit.practitioner?.name ?? null,
occurredAt: visit.occurredAt,
href: visitHref(visit.id),
})),
[...encounters.items]
// Two arms per page arrive in two orders; the list reads newest first.
.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt))
.map((visit) => ({
id: visit.id,
label: encounterKindText(t, visit.kind as EncounterKind),
meta: visit.practitioner?.name ?? null,
occurredAt: visit.occurredAt,
href: visitHref(visit.id),
})),
anchor,
labels,
);
@@ -176,6 +217,19 @@ export function DocumentRecordLinks({
const showDoses = vaccinationsOn && doc.vaccinationLinks !== null;
// The visits the document is filed against, as its own read names them,
// so their chips show whatever page of visits is loaded.
const knownVisits = useMemo(
() =>
doc.encounterLinks.map((link) => ({
id: link.encounterId,
label: encounterKindText(t, link.kind as EncounterKind),
dateLabel: link.occurredAt ? format.date(link.occurredAt) : null,
href: visitHref(link.encounterId),
})),
[doc.encounterLinks, t, format],
);
return (
<>
{canManage ? (
@@ -183,11 +237,11 @@ export function DocumentRecordLinks({
icon={CalendarDays}
title={t("documents.detail.visitsLabel")}
slot="document-visit-links"
pending={encounters.isPending || !seedFresh}
error={encounters.isError || seedError}
pending={encounters.pending || !seedFresh}
error={encounters.error || seedError}
errorLabel={t("links.picker.loadError")}
onRetry={() => {
if (encounters.isError) void encounters.refetch();
if (encounters.error) encounters.retry();
if (seedError) onRetrySeed?.();
}}
selected={visitIds ?? doc.encounterLinks.map((l) => l.encounterId)}
@@ -196,6 +250,8 @@ export function DocumentRecordLinks({
onChange({ encounterIds: ids });
}}
options={visitOptions}
server={serverPagingFrom(encounters)}
knownOptions={knownVisits}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("documents.detail.noVisitsToLink")}
/>
@@ -1,32 +0,0 @@
import { describe, expect, it } from "vitest";
import { illnessEpisodeListQuerySchema } from "@/lib/validations/illness";
import { listLabResultsSchema } from "@/lib/validations/labs";
import {
EPISODE_FETCH_LIMIT,
PICKER_FETCH_LIMIT,
} from "../encounter-link-pickers";
/**
* Each picker's page size must be one its list route accepts. A refused size
* comes back as a 422, and a picker that treats a failed read as an empty
* list tells the person there is nothing to link. That is how the visit
* form's condition picker and both document pickers showed nothing at all.
*/
describe("visit link picker page sizes", () => {
it("the condition picker asks for a page the episode route accepts", () => {
expect(
illnessEpisodeListQuerySchema.safeParse({
limit: String(EPISODE_FETCH_LIMIT),
includeResolved: "true",
}).success,
).toBe(true);
});
it("the lab picker asks for a page the lab route accepts", () => {
expect(
listLabResultsSchema.safeParse({ limit: String(PICKER_FETCH_LIMIT) })
.success,
).toBe(true);
});
});
@@ -33,6 +33,7 @@ import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import type { Encounter, EncounterWriteBody } from "@/hooks/use-encounters";
import type { Practitioner } from "@/hooks/use-practitioners";
import type { EncounterLinksDTO } from "@/lib/encounters/dto";
import {
ENCOUNTER_KINDS,
ENCOUNTER_STATUSES,
@@ -57,6 +58,12 @@ export interface EncounterDraft {
documentIds: string[];
labResultIds: string[];
episodeIds: string[];
/**
* The links the visit carried when the sheet opened, as its read names
* them, so their chips show whatever page the pickers have loaded. Display
* only; the id arrays above are what a save writes.
*/
links: EncounterLinksDTO | null;
}
function localValue(instant: Date): string {
@@ -91,6 +98,7 @@ export function emptyDraft(
documentIds: [],
labResultIds: [],
episodeIds: [],
links: null,
...overrides,
};
}
@@ -108,6 +116,7 @@ export function draftFromEncounter(row: Encounter): EncounterDraft {
documentIds: row.links?.documents.map((link) => link.id) ?? [],
labResultIds: row.links?.labResults.map((link) => link.id) ?? [],
episodeIds: row.links?.conditions.map((link) => link.id) ?? [],
links: row.links ?? null,
};
}
@@ -303,6 +312,7 @@ export function EncounterForm({
<EncounterLinkPickers
modules={user?.modules}
anchor={draft.occurredAt || null}
links={draft.links}
documentIds={draft.documentIds}
labResultIds={draft.labResultIds}
episodeIds={draft.episodeIds}
@@ -16,10 +16,17 @@
* those things, which is why the gate lives here and not on the route.
*
* Nothing in this block can block a save. Every list starts empty and stays
* valid empty. The picker raises only the FETCH limit so grouping has enough
* to group; the link cap stays authoritative on the server.
* valid empty, and the link cap stays authoritative on the server.
*
* **Every family is reachable however long the history.** The lab and
* condition pickers used to load the newest 200 results and the newest 100
* conditions and offer only those, so on a long record a visit from a few
* years back could not reach its own lab panel. Each picker now opens on the
* records around the visit's date and pages older and newer from there, and
* its search asks the list route (`useLinkPickerPages`). What the visit
* already links always shows, from the labels its own read carries.
*/
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { FlaskConical, FolderOpen, Stethoscope } from "lucide-react";
import { apiGet } from "@/lib/api/api-fetch";
@@ -27,43 +34,92 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { ModuleKey } from "@/lib/modules/registry";
import type { IllnessEpisodeDTO } from "@/lib/illness/dto";
import type { EncounterLinksDTO } from "@/lib/encounters/dto";
import {
EntityLinkPicker,
serverPagingFrom,
type EntityLinkOption,
} from "@/components/links/entity-link-picker";
import { useVaultDocumentOptions } from "@/components/links/vault-document-options";
import {
linkedDocumentOption,
useVaultDocumentOptions,
} from "@/components/links/vault-document-options";
import {
episodePickerUrl,
labPickerUrl,
} from "@/components/links/picker-requests";
import {
nextOffset,
pickerPivot,
useLinkPickerPages,
type PickerPage,
type PickerPageRequest,
} from "@/components/links/use-link-picker-pages";
import { ImportFromSourceButton } from "@/components/documents/sources/import-from-source-button";
import { mergeIds } from "@/components/documents/sources/merge-ids";
/** Fetch cap for the picker — the grouped sheet needs enough rows to group. */
export const PICKER_FETCH_LIMIT = 200;
/**
* The episode list route caps `limit` at 100 and answers anything above it
* with a 422, which the picker used to read as "nothing here to link".
*/
export const EPISODE_FETCH_LIMIT = 100;
interface LabPickerResult {
id: string;
analyte: string;
panel: string | null;
takenAt: string;
}
interface LabListPage {
results: Array<{
id: string;
analyte: string;
panel: string | null;
takenAt: string;
}>;
results: LabPickerResult[];
}
/** One picker page of lab results. */
export async function fetchLabPage(
request: PickerPageRequest,
): Promise<PickerPage<LabPickerResult>> {
const page = await apiGet<LabListPage>(labPickerUrl(request));
return {
items: page.results,
next: nextOffset(request.cursor, page.results.length),
};
}
/** One picker page of condition episodes. */
export async function fetchEpisodePage(
request: PickerPageRequest,
): Promise<PickerPage<IllnessEpisodeDTO>> {
const rows = await apiGet<IllnessEpisodeDTO[]>(episodePickerUrl(request));
return { items: rows, next: nextOffset(request.cursor, rows.length) };
}
/** A visit's existing link as a chip option, or null when it is withheld. */
function linkedOption(
link: { id: string; label: string | null; date: string | null },
date: (iso: string) => string,
href?: string,
): EntityLinkOption | null {
if (link.label === null) return null;
return {
id: link.id,
label: link.label,
dateLabel: link.date ? date(link.date) : null,
...(href ? { href } : {}),
};
}
export function EncounterLinkPickers({
modules,
anchor,
links,
documentIds,
labResultIds,
episodeIds,
onChange,
}: {
modules: Partial<Record<ModuleKey, boolean>> | undefined;
/** The visit's own date, for the document suggestions on top. */
/** The visit's own date: where every picker opens. */
anchor: string | null;
/**
* What the visit links already, as its own read names it (an edit). Their
* chips show whether or not a loaded page holds them.
*/
links?: EncounterLinksDTO | null;
documentIds: string[];
labResultIds: string[];
episodeIds: string[];
@@ -79,64 +135,76 @@ export function EncounterLinkPickers({
const documentsOn = modules?.inboundDocuments === true;
const labsOn = modules?.labs === true;
const illnessOn = modules?.illness === true;
const pivot = pickerPivot(anchor);
// The whole vault, suggestions near the visit's date on top — see
// `useVaultDocumentOptions` for why this no longer asks for one oversized
// page.
// The vault around the visit's date, suggestions on top — see
// `useVaultDocumentOptions`.
const documents = useVaultDocumentOptions({
enabled: documentsOn,
anchor,
});
const labs = useQuery({
queryKey: queryKeys.labResultsPicker(PICKER_FETCH_LIMIT),
const labs = useLinkPickerPages<LabPickerResult>({
enabled: labsOn,
queryFn: () =>
apiGet<LabListPage>(
`/api/labs?limit=${PICKER_FETCH_LIMIT}&offset=0&sortDir=desc`,
),
pivot,
queryKey: queryKeys.labResultsPicker,
fetchPage: fetchLabPage,
});
const episodes = useQuery({
queryKey: queryKeys.illnessEpisodesPicker(EPISODE_FETCH_LIMIT),
const episodes = useLinkPickerPages<IllnessEpisodeDTO>({
enabled: illnessOn,
queryFn: () =>
apiGet<IllnessEpisodeDTO[]>(
`/api/illness/episodes?includeResolved=true&limit=${EPISODE_FETCH_LIMIT}`,
),
pivot,
queryKey: queryKeys.illnessEpisodesPicker,
fetchPage: fetchEpisodePage,
});
const known = useMemo(() => {
const date = (iso: string) => format.date(iso);
const keep = (
option: EntityLinkOption | null,
): option is EntityLinkOption => option !== null;
return {
documents: (links?.documents ?? [])
.map((link) => linkedDocumentOption(link, date))
.filter(keep),
labResults: (links?.labResults ?? [])
.map((link) => linkedOption(link, date))
.filter(keep),
conditions: (links?.conditions ?? [])
.map((link) =>
linkedOption(link, date, `/illness/${encodeURIComponent(link.id)}`),
)
.filter(keep),
};
}, [links, format]);
if (!documentsOn && !labsOn && !illnessOn) return null;
// Labs group by sample date + panel: "Blutbild · 12.05.2026", the analyte
// underneath. A visit links the whole panel from a day in one "select all".
const labOptions: EntityLinkOption[] = (labs.data?.results ?? []).map(
(result) => {
const dayLabel = format.date(result.takenAt);
const groupLabel = result.panel
? `${result.panel} · ${dayLabel}`
: dayLabel;
return {
id: result.id,
label: result.analyte,
dateLabel: dayLabel,
group: {
key: `${result.takenAt.slice(0, 10)}|${result.panel ?? ""}`,
label: groupLabel,
},
};
},
);
const labOptions: EntityLinkOption[] = labs.items.map((result) => {
const dayLabel = format.date(result.takenAt);
const groupLabel = result.panel
? `${result.panel} · ${dayLabel}`
: dayLabel;
return {
id: result.id,
label: result.analyte,
dateLabel: dayLabel,
group: {
key: `${result.takenAt.slice(0, 10)}|${result.panel ?? ""}`,
label: groupLabel,
},
};
});
// Episodes stay flat (a bounded list), newest onset first from the route.
const episodeOptions: EntityLinkOption[] = (episodes.data ?? []).map(
(episode) => ({
id: episode.id,
label: episode.label,
dateLabel: episode.onsetAt ? format.date(episode.onsetAt) : null,
href: `/illness/${encodeURIComponent(episode.id)}`,
}),
);
// Episodes stay flat, newest onset first.
const episodeOptions: EntityLinkOption[] = episodes.items.map((episode) => ({
id: episode.id,
label: episode.label,
dateLabel: episode.onsetAt ? format.date(episode.onsetAt) : null,
href: `/illness/${encodeURIComponent(episode.id)}`,
}));
return (
<div className="space-y-4 border-t pt-4" data-slot="encounter-link-pickers">
@@ -152,6 +220,8 @@ export function EncounterLinkPickers({
selected={documentIds}
onChange={(ids) => onChange({ documentIds: ids })}
options={documents.options}
server={documents.server}
knownOptions={known.documents}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("encounters.form.linkNothingToOffer")}
extraAction={
@@ -173,13 +243,15 @@ export function EncounterLinkPickers({
icon={FlaskConical}
title={t("encounters.form.linkLabResults")}
slot="encounter-link-labs"
pending={labs.isPending}
error={labs.isError}
pending={labs.pending}
error={labs.error}
errorLabel={t("links.picker.loadError")}
onRetry={() => void labs.refetch()}
onRetry={labs.retry}
selected={labResultIds}
onChange={(ids) => onChange({ labResultIds: ids })}
options={labOptions}
server={serverPagingFrom(labs)}
knownOptions={known.labResults}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("encounters.form.linkNothingToOffer")}
/>
@@ -190,13 +262,15 @@ export function EncounterLinkPickers({
icon={Stethoscope}
title={t("encounters.form.linkConditions")}
slot="encounter-link-conditions"
pending={episodes.isPending}
error={episodes.isError}
pending={episodes.pending}
error={episodes.error}
errorLabel={t("links.picker.loadError")}
onRetry={() => void episodes.refetch()}
onRetry={episodes.retry}
selected={episodeIds}
onChange={(ids) => onChange({ episodeIds: ids })}
options={episodeOptions}
server={serverPagingFrom(episodes)}
knownOptions={known.conditions}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("encounters.form.linkNothingToOffer")}
/>
@@ -0,0 +1,199 @@
/**
* The link picker over a list that pages on the server.
*
* A visit's link pickers used to offer one capped page and nothing past it.
* Paged, three things have to hold: what the record already links shows
* whether or not a loaded page holds it, the sheet offers the paging the
* server reports, and an empty first window is not read as "nothing to link".
*
* 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";
import { FlaskConical } from "lucide-react";
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({ children }: { children: React.ReactNode }) => (
<div data-slot="mock-sheet">{children}</div>
),
}));
import { I18nProvider } from "@/lib/i18n/context";
import {
EntityLinkPicker,
resolvableOptions,
type EntityLinkOption,
type EntityLinkServerPaging,
} from "../entity-link-picker";
import { mergeWindowPages, uniqueById } from "../use-link-picker-pages";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const server = (over: Partial<EntityLinkServerPaging> = {}) => ({
term: "",
onTermChange: () => undefined,
searchPending: false,
hasOlder: false,
hasNewer: false,
loadingOlder: false,
loadingNewer: false,
onOlder: () => undefined,
onNewer: () => undefined,
...over,
});
const base = {
icon: FlaskConical,
title: "Lab results",
slot: "labs",
searchPlaceholder: "Search",
emptyLabel: "Nothing to link",
pending: false,
onChange: () => undefined,
};
const loaded: EntityLinkOption[] = [
{ id: "recent", label: "Glucose", dateLabel: "12.05.2026" },
];
const linked2019: EntityLinkOption = {
id: "ferritin-2019",
label: "Ferritin",
dateLabel: "03.04.2019",
};
describe("paged link picker", () => {
it("a linked record outside every loaded page still shows as a chip and in the sheet", () => {
const html = render(
<EntityLinkPicker
{...base}
options={loaded}
selected={["ferritin-2019"]}
knownOptions={[linked2019]}
server={server({ hasOlder: true })}
/>,
);
expect(html).toContain('data-slot="labs-chip"');
expect(html).toContain("Ferritin");
expect(html).toContain("03.04.2019");
// In the sheet, on top under its own heading.
expect(html).toContain("Linked");
});
it("offers older and newer pages while the server reports them", () => {
const html = render(
<EntityLinkPicker
{...base}
options={loaded}
selected={[]}
server={server({ hasOlder: true, hasNewer: true })}
/>,
);
expect(html).toContain('data-slot="labs-load-older"');
expect(html).toContain('data-slot="labs-load-newer"');
expect(html).toContain("Load older");
expect(html).toContain("Load newer");
});
it("offers no paging once the server has no more", () => {
const html = render(
<EntityLinkPicker
{...base}
options={loaded}
selected={[]}
server={server()}
/>,
);
expect(html).not.toContain('data-slot="labs-load-older"');
expect(html).not.toContain('data-slot="labs-load-newer"');
});
it("an empty first window with more to page is not 'nothing to link'", () => {
const html = render(
<EntityLinkPicker
{...base}
options={[]}
selected={[]}
server={server({ hasNewer: true })}
/>,
);
expect(html).not.toContain("Nothing to link");
expect(html).toContain('data-slot="labs-add"');
});
it("does not filter a server answer again by the term", () => {
// The route matched on the panel name; the option does not render it.
const html = render(
<EntityLinkPicker
{...base}
options={loaded}
selected={[]}
server={server({ term: "metabolic" })}
/>,
);
expect(html).toContain('data-slot="labs-option"');
});
it("a route without search filters what is loaded", () => {
const html = render(
<EntityLinkPicker
{...base}
options={loaded}
selected={[]}
server={server({ term: "nomatch", filterLocally: true })}
/>,
);
expect(html).not.toContain('data-slot="labs-option"');
});
});
describe("resolvableOptions", () => {
it("prefers the loaded option, then the known one, then a remembered pick", () => {
const picked = new Map([
["a", { id: "a", label: "picked" }],
["b", { id: "b", label: "picked" }],
["c", { id: "c", label: "picked" }],
]);
const map = resolvableOptions(
[{ id: "a", label: "loaded" }],
[
{ id: "a", label: "known" },
{ id: "b", label: "known" },
],
picked,
);
expect(map.get("a")?.label).toBe("loaded");
expect(map.get("b")?.label).toBe("known");
expect(map.get("c")?.label).toBe("picked");
});
});
describe("mergeWindowPages", () => {
it("puts the newer window, newest first, above the older one", () => {
const merged = mergeWindowPages(
[
{ items: [{ id: "n1" }, { id: "n2" }], next: "2" },
{ items: [{ id: "n3" }], next: null },
],
[{ items: [{ id: "o1" }, { id: "o2" }], next: null }],
);
expect(merged.map((item) => item.id)).toEqual([
"n3",
"n2",
"n1",
"o1",
"o2",
]);
});
it("drops a record the two windows both returned", () => {
expect(uniqueById([{ id: "x" }, { id: "y" }, { id: "x" }])).toEqual([
{ id: "x" },
{ id: "y" },
]);
});
});
@@ -0,0 +1,122 @@
/**
* Every request a link picker can send must be one its list route accepts.
*
* A refused parameter comes back as a 422, and a picker that reads a failed
* read as an empty list tells the person there is nothing to link. That is how
* the visit form's condition picker and both document pickers once showed
* nothing at all. Each builder is checked in every direction, with and without
* a date, against the very schema its route parses with.
*/
import { describe, expect, it } from "vitest";
import { documentListQuerySchema } from "@/lib/validations/inbound-documents";
import { encounterListQuerySchema } from "@/lib/validations/encounters";
import { illnessEpisodeListQuerySchema } from "@/lib/validations/illness";
import { listLabResultsSchema } from "@/lib/validations/labs";
import {
documentPickerUrl,
encounterPickerUrl,
episodePickerUrl,
labPickerUrl,
} from "../picker-requests";
import {
PICKER_PAGE_SIZE,
nextOffset,
pickerPivot,
type PickerPageRequest,
} from "../use-link-picker-pages";
const pivot = pickerPivot("2019-05-10T10:00:00.000Z");
const REQUESTS: PickerPageRequest[] = [
{ direction: "older", pivot, term: "", cursor: null },
{ direction: "older", pivot, term: "", cursor: "200" },
{ direction: "older", pivot: null, term: "", cursor: null },
{ direction: "newer", pivot, term: "", cursor: null },
{ direction: "newer", pivot, term: "", cursor: "100" },
{ direction: "search", pivot: null, term: "ferritin", cursor: null },
{ direction: "search", pivot: null, term: "ferritin", cursor: "100" },
];
function params(url: string): Record<string, string> {
return Object.fromEntries(new URL(url, "http://local").searchParams);
}
describe.each([
["labs", labPickerUrl, listLabResultsSchema],
["episodes", episodePickerUrl, illnessEpisodeListQuerySchema],
["encounters", encounterPickerUrl, encounterListQuerySchema],
])("%s picker", (_name, build, schema) => {
it.each(REQUESTS)("$direction (cursor $cursor) parses", (request) => {
const result = schema.safeParse(params(build(request)));
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
});
});
describe("documents picker", () => {
it.each(REQUESTS)("$direction parses", (request) => {
// The document cursor is a row id, not an offset.
const url = documentPickerUrl({
...request,
cursor: request.cursor ? "doc-id" : null,
});
const result = documentListQuerySchema.safeParse(params(url));
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
});
it("splits the day-granular window at the pivot's day", () => {
const older = params(
documentPickerUrl({ direction: "older", pivot, term: "", cursor: null }),
);
const newer = params(
documentPickerUrl({ direction: "newer", pivot, term: "", cursor: null }),
);
expect(older.to).toBe("2019-05-17");
expect(newer.from).toBe("2019-05-18");
expect(older.order).toBe("desc");
expect(newer.order).toBe("asc");
});
});
describe("the windows around a record's date", () => {
it("splits a week after the date, at the end of that day", () => {
expect(pivot).toBe("2019-05-17T23:59:59.999Z");
expect(pickerPivot(null)).toBeNull();
expect(pickerPivot("not a date")).toBeNull();
});
it("older reads up to the split newest first, newer after it oldest first", () => {
const older = params(
labPickerUrl({ direction: "older", pivot, term: "", cursor: null }),
);
const newer = params(
labPickerUrl({ direction: "newer", pivot, term: "", cursor: null }),
);
expect(older).toMatchObject({ to: pivot, sortDir: "desc", offset: "0" });
expect(newer).toMatchObject({
from: "2019-05-18T00:00:00.000Z",
sortDir: "asc",
});
});
it("a search ignores the window and sends the term", () => {
const search = params(
labPickerUrl({
direction: "search",
pivot: null,
term: "ferritin",
cursor: null,
}),
);
expect(search.q).toBe("ferritin");
expect(search.from).toBeUndefined();
expect(search.to).toBeUndefined();
});
it("an offset page that came back full has a next page, a short one has none", () => {
expect(nextOffset(null, PICKER_PAGE_SIZE)).toBe(String(PICKER_PAGE_SIZE));
expect(nextOffset("100", PICKER_PAGE_SIZE)).toBe("200");
expect(nextOffset("100", 3)).toBeNull();
});
});
@@ -91,37 +91,36 @@ describe("documentOptionsWithSuggestions", () => {
});
});
describe("fetchWholeVault", () => {
it("asks only for pages the list route accepts, and walks every cursor", async () => {
describe("fetchVaultPage", () => {
it("asks for one page the list route accepts and hands back its 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;
return {
documents: [doc("a", "2026-05-01")],
nextCursor: "a",
} 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");
const { fetchVaultPage } = await import("../vault-document-options");
const page = await fetchVaultPage({
direction: "older",
pivot: "2026-05-17T23:59:59.999Z",
term: "",
cursor: "z",
});
expect(page.items.map((d) => d.id)).toEqual(["a"]);
expect(page.next).toBe("a");
const params = Object.fromEntries(
new URL(urls[0]!, "http://local").searchParams,
);
expect(documentListQuerySchema.safeParse(params).success).toBe(true);
expect(params).toMatchObject({ cursor: "z", to: "2026-05-17" });
} finally {
spy.mockRestore();
}
+203 -17
View File
@@ -30,10 +30,18 @@
* sheet where its button cannot be pressed while the sheet is open. The chip
* carries the option's date, so two doses of one vaccine read as two
* different records.
*
* **A long list pages on the server.** A caller over a list that can outgrow
* one page passes `server`: the search box then asks the list route instead
* of filtering what is loaded, and "Load newer" / "Load older" page through
* the windows around the record's date (see `useLinkPickerPages`). A record
* that is linked always shows as a chip, whether or not the page that holds
* it is loaded: the caller passes what it knows about the existing links as
* `knownOptions`, and every option picked in this session is remembered.
*/
import { useMemo, useState, type ReactNode } from "react";
import Link from "next/link";
import { Check, Plus, Search, X } from "lucide-react";
import { Check, Loader2, Plus, Search, X } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
@@ -82,6 +90,74 @@ export function filterOptions(
return options.filter((option) => haystack(option).includes(needle));
}
/**
* Server-side search and paging for a list that can outgrow one page. The
* caller owns the term (it is part of the list route's query) and the pages.
*/
export interface EntityLinkServerPaging {
term: string;
onTermChange: (term: string) => void;
/** The typed term has not been answered yet. */
searchPending: boolean;
hasOlder: boolean;
hasNewer: boolean;
loadingOlder: boolean;
loadingNewer: boolean;
onOlder: () => void;
onNewer: () => void;
/**
* The list route has no text search: the term filters the loaded options
* here instead of being sent.
*/
filterLocally?: boolean;
}
/**
* The paging props from a `useLinkPickerPages` result, so every caller
* wires the same fields the same way.
*/
export function serverPagingFrom(pages: {
term: string;
setTerm: (term: string) => void;
searchPending: boolean;
hasOlder: boolean;
hasNewer: boolean;
loadingOlder: boolean;
loadingNewer: boolean;
loadOlder: () => void;
loadNewer: () => void;
filterLocally: boolean;
}): EntityLinkServerPaging {
return {
term: pages.term,
onTermChange: pages.setTerm,
searchPending: pages.searchPending,
hasOlder: pages.hasOlder,
hasNewer: pages.hasNewer,
loadingOlder: pages.loadingOlder,
loadingNewer: pages.loadingNewer,
onOlder: pages.loadOlder,
onNewer: pages.loadNewer,
filterLocally: pages.filterLocally,
};
}
/**
* The options a chip can be resolved from: what is loaded, what the caller
* knows about the existing links, and what was picked in this session. The
* loaded option wins, then the known one, then the picked one.
*/
export function resolvableOptions(
options: readonly EntityLinkOption[],
knownOptions: readonly EntityLinkOption[],
picked: ReadonlyMap<string, EntityLinkOption>,
): Map<string, EntityLinkOption> {
const map = new Map<string, EntityLinkOption>(picked);
for (const option of knownOptions) map.set(option.id, option);
for (const option of options) map.set(option.id, option);
return map;
}
export interface EntityLinkGroup {
/** Group key, or null for the flat (ungrouped) bucket. */
key: string | null;
@@ -165,6 +241,8 @@ export function EntityLinkPicker({
errorLabel,
onRetry,
extraAction,
server,
knownOptions,
}: {
icon: LucideIcon;
title: string;
@@ -191,27 +269,68 @@ export function EntityLinkPicker({
* the way forward. The element renders nothing when it has nothing to offer.
*/
extraAction?: ReactNode;
/** Server-side search and paging; omit for a list that fits one read. */
server?: EntityLinkServerPaging;
/**
* What the caller knows about records already linked (label and date from
* the record's own read), so their chips show even when no loaded page
* holds them.
*/
knownOptions?: EntityLinkOption[];
}) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
const [term, setTerm] = useState("");
const [localTerm, setLocalTerm] = useState("");
const term = server ? server.term : localTerm;
const setTerm = server ? server.onTermChange : setLocalTerm;
const optionById = useMemo(() => {
const map = new Map<string, EntityLinkOption>();
for (const option of options) map.set(option.id, option);
return map;
}, [options]);
const groups = useMemo(
() => groupOptions(filterOptions(options, term)),
[options, term],
// Every option picked in this session, so its chip survives a search or a
// page change that takes it out of the loaded list. Written from the event
// handlers, never during render.
const [picked, setPicked] = useState<ReadonlyMap<string, EntityLinkOption>>(
() => new Map(),
);
const remember = (more: readonly EntityLinkOption[]) => {
if (more.length === 0) return;
setPicked((prev) => {
const next = new Map(prev);
for (const option of more) next.set(option.id, option);
return next;
});
};
const optionById = useMemo(
() => resolvableOptions(options, knownOptions ?? [], picked),
[options, knownOptions, picked],
);
// A server-paged list is already the answer to the term; filtering it
// again here would drop a match on a field the option does not render.
const groups = useMemo(() => {
const visible =
server && !server.filterLocally ? options : filterOptions(options, term);
// Linked records the loaded pages do not hold stay reachable in the
// sheet, on top, so they can be unticked where they are listed.
const loaded = new Set(options.map((option) => option.id));
const linkedElsewhere = term.trim()
? []
: selected
.filter((id) => !loaded.has(id))
.map((id) => optionById.get(id))
.filter((option): option is EntityLinkOption => option != null)
.map((option) => ({
...option,
group: { key: "__linked__", label: t("links.picker.linked") },
}));
return groupOptions([...linkedElsewhere, ...visible]);
}, [options, term, server, selected, optionById, t]);
// The link the last X removed, offered back until the next change.
const [lastRemoved, setLastRemoved] = useState<EntityLinkOption | null>(null);
const change = (ids: string[]) => {
const change = (ids: string[], touched: readonly EntityLinkOption[] = []) => {
setLastRemoved(null);
remember(touched);
onChange(ids);
};
@@ -245,7 +364,9 @@ export function EntityLinkPicker({
onRetry={onRetry}
slot={`${slot}-error`}
/>
) : options.length === 0 ? (
) : options.length === 0 &&
selected.length === 0 &&
!(server && (server.hasOlder || server.hasNewer)) ? (
<div className="space-y-2">
<p className="text-muted-foreground text-xs">{emptyLabel}</p>
{extraAction}
@@ -315,7 +436,9 @@ export function EntityLinkPicker({
<button
type="button"
data-slot={`${slot}-undo-button`}
onClick={() => change(restoreLink(selected, lastRemoved.id))}
onClick={() =>
change(restoreLink(selected, lastRemoved.id), [lastRemoved])
}
className="text-foreground focus-visible:ring-ring/50 min-h-8 rounded-sm font-medium underline underline-offset-2 focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("common.undo")}
@@ -363,12 +486,31 @@ export function EntityLinkPicker({
/>
</div>
{groups.length === 0 ? (
{server?.searchPending && groups.length === 0 ? (
<div
className="flex justify-center py-6"
data-slot={`${slot}-searching`}
>
<Loader2
className="text-muted-foreground size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
</div>
) : groups.length === 0 &&
!(server && (server.hasOlder || server.hasNewer)) ? (
<p className="text-muted-foreground px-1 py-6 text-center text-sm">
{t("links.picker.searchNoMatch")}
</p>
) : (
<div className="max-h-[50vh] space-y-4 overflow-y-auto overscroll-contain">
{server?.hasNewer ? (
<PageButton
slot={`${slot}-load-newer`}
loading={server.loadingNewer}
onClick={server.onNewer}
label={t("links.picker.loadNewer")}
/>
) : null}
{groups.map((group) => {
const groupIds = group.options.map((option) => option.id);
return (
@@ -384,7 +526,9 @@ export function EntityLinkPicker({
<button
type="button"
data-slot={`${slot}-group-select`}
onClick={() => change(toggleAll(selected, groupIds))}
onClick={() =>
change(toggleAll(selected, groupIds), group.options)
}
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
>
{t("links.picker.selectAll")}
@@ -401,7 +545,7 @@ export function EntityLinkPicker({
aria-pressed={on}
data-slot={`${slot}-option`}
onClick={() =>
change(toggleOne(selected, option.id))
change(toggleOne(selected, option.id), [option])
}
className={cn(
"border-border hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-2 rounded-md border px-3 text-left focus-visible:ring-[3px] focus-visible:outline-none",
@@ -438,6 +582,14 @@ export function EntityLinkPicker({
</div>
);
})}
{server?.hasOlder ? (
<PageButton
slot={`${slot}-load-older`}
loading={server.loadingOlder}
onClick={server.onOlder}
label={t("links.picker.loadOlder")}
/>
) : null}
</div>
)}
@@ -451,3 +603,37 @@ export function EntityLinkPicker({
</div>
);
}
/** "Load newer" / "Load older": a full-width row inside the scrolling list. */
function PageButton({
slot,
loading,
onClick,
label,
}: {
slot: string;
loading: boolean;
onClick: () => void;
label: string;
}) {
return (
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-11 w-full"
data-slot={slot}
disabled={loading}
aria-busy={loading}
onClick={onClick}
>
{loading ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : null}
{label}
</Button>
);
}
+96
View File
@@ -0,0 +1,96 @@
/**
* The list-route requests behind each link picker, one builder per family.
*
* Pure, so a test can parse every URL a picker can send against the route's
* own query schema: a page size or a parameter the route refuses answers 422,
* and a picker that reads a 422 as an empty list hides the record it exists to
* offer. That is exactly how a vault once read as empty.
*
* Every builder speaks one {@link PickerPageRequest}: `older` is the window up
* to the split newest first, `newer` the window after it oldest first,
* `search` the term across the whole history. The cursor is the family's own:
* an offset for the routes that page by offset, the last row's id for the
* document route's keyset.
*/
import {
PICKER_PAGE_SIZE,
afterPivot,
type PickerPageRequest,
} from "./use-link-picker-pages";
function withParams(path: string, params: URLSearchParams): string {
return `${path}?${params.toString()}`;
}
/** `GET /api/labs` — offset paging, `from` / `to` on the draw instant. */
export function labPickerUrl(request: PickerPageRequest): string {
const params = new URLSearchParams({
limit: String(PICKER_PAGE_SIZE),
offset: request.cursor ?? "0",
sortDir: request.direction === "newer" ? "asc" : "desc",
});
if (request.direction === "search") params.set("q", request.term);
else if (request.pivot !== null) {
if (request.direction === "older") params.set("to", request.pivot);
else params.set("from", afterPivot(request.pivot));
}
return withParams("/api/labs", params);
}
/** `GET /api/illness/episodes` — offset paging, the window on the onset. */
export function episodePickerUrl(request: PickerPageRequest): string {
const params = new URLSearchParams({
includeResolved: "true",
limit: String(PICKER_PAGE_SIZE),
offset: request.cursor ?? "0",
sortDir: request.direction === "newer" ? "asc" : "desc",
});
if (request.direction === "search") params.set("q", request.term);
else if (request.pivot !== null) {
if (request.direction === "older") params.set("to", request.pivot);
else params.set("from", afterPivot(request.pivot));
}
return withParams("/api/illness/episodes", params);
}
/**
* `GET /api/documents/inbound` — keyset paging on the filing date. The route's
* window is day-granular and inclusive, so the newer window starts the day
* after the split's day.
*/
export function documentPickerUrl(request: PickerPageRequest): string {
const params = new URLSearchParams({
sort: "documentDate",
order: request.direction === "newer" ? "asc" : "desc",
limit: String(PICKER_PAGE_SIZE),
});
if (request.cursor) params.set("cursor", request.cursor);
if (request.direction === "search") params.set("q", request.term);
else if (request.pivot !== null) {
if (request.direction === "older") {
params.set("to", request.pivot.slice(0, 10));
} else {
params.set("from", afterPivot(request.pivot).slice(0, 10));
}
}
return withParams("/api/documents/inbound", params);
}
/**
* `GET /api/encounters` — offset paging inside a window on the visit instant.
* The route answers in two arms (upcoming soonest first, past newest first),
* each paged by the same offset; the picker merges and orders them itself.
* The visit route has no text search, so `search` reads the whole history
* and the picker filters what it has loaded.
*/
export function encounterPickerUrl(request: PickerPageRequest): string {
const params = new URLSearchParams({
limit: String(PICKER_PAGE_SIZE),
offset: request.cursor ?? "0",
});
if (request.direction !== "search" && request.pivot !== null) {
if (request.direction === "older") params.set("to", request.pivot);
else params.set("from", afterPivot(request.pivot));
}
return withParams("/api/encounters", params);
}
@@ -0,0 +1,235 @@
"use client";
/**
* The data behind a link picker over a list that can be long: the records
* around one date, paged older and newer from there, or a search across the
* whole history.
*
* The pickers used to fetch one capped page, newest first (200 lab results,
* 100 conditions, ten pages of documents, the last 100 visits), and offered
* only that. On a long record everything older than the cap could not be
* linked at all: a visit from 2019 could not reach its own 2019 lab panel.
*
* Now a picker opens on the records around the date of the thing being linked
* (the visit's, the dose's, the document's). The list is split a few days
* after that date (the same window the "belongs to a visit?" suggestion uses,
* so a result drawn two days after the visit is on the first page) into two
* server-side windows:
*
* - `older`: up to the split, newest first, so the record's own days lead
* the list, paged further back on request;
* - `newer`: after the split, oldest first, fetched only when asked for and
* paged further forward from there;
*
* and a typed term switches to `search`, which asks the list route itself
* (`q`) so a match is found wherever it sits in the history. With no date
* yet the picker opens on the newest records and pages back.
*
* Every page comes from the family's own list route through `fetchPage`, so
* paging, the filter and the ownership check stay server-side.
*/
import { useMemo, useState } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { ENCOUNTER_SUGGEST_WINDOW_DAYS } from "@/lib/encounters/suggest-window";
export type PickerDirection = "older" | "newer" | "search";
/** One page of a picker list, and the cursor for the next one (or null). */
export interface PickerPage<T> {
items: T[];
next: string | null;
}
export interface PickerPageRequest {
direction: PickerDirection;
/** The split instant (ISO), or null when the record has no date yet. */
pivot: string | null;
/** The trimmed search term; empty outside `search`. */
term: string;
/** The family's own cursor (an offset or a row id), null for page one. */
cursor: string | null;
}
/** Records per page, the same for every family. */
export const PICKER_PAGE_SIZE = 100;
/** How long typing settles before the search asks the server. */
const SEARCH_DEBOUNCE_MS = 250;
/** Drop repeats by id, keeping the first occurrence's position. */
export function uniqueById<T extends { id: string }>(items: readonly T[]): T[] {
const seen = new Set<string>();
const out: T[] = [];
for (const item of items) {
if (seen.has(item.id)) continue;
seen.add(item.id);
out.push(item);
}
return out;
}
/**
* The visible list, newest first: the newer pages (fetched oldest first, so
* reversed) above the older ones.
*/
export function mergeWindowPages<T extends { id: string }>(
newerPages: ReadonlyArray<PickerPage<T>>,
olderPages: ReadonlyArray<PickerPage<T>>,
): T[] {
const newer = newerPages.flatMap((page) => page.items).reverse();
const older = olderPages.flatMap((page) => page.items);
return uniqueById([...newer, ...older]);
}
export function useLinkPickerPages<T extends { id: string }>({
enabled,
pivot,
queryKey,
fetchPage,
serverSearch = true,
}: {
enabled: boolean;
pivot: string | null;
/** The family's factory key for one direction, pivot and term. */
queryKey: (
direction: PickerDirection,
pivot: string | null,
term: string,
) => readonly unknown[];
fetchPage: (request: PickerPageRequest) => Promise<PickerPage<T>>;
/**
* Whether the list route can search. A route without a text filter (the
* visit list: its free text is ciphertext at rest) leaves the term to the
* picker, which filters what is loaded.
*/
serverSearch?: boolean;
}) {
const [rawTerm, setTerm] = useState("");
const debounced = useDebouncedValue(rawTerm.trim(), SEARCH_DEBOUNCE_MS);
const term = serverSearch ? debounced : "";
const searching = term.length > 0;
const older = useInfiniteQuery({
queryKey: queryKey("older", pivot, ""),
enabled: enabled && !searching,
initialPageParam: null as string | null,
queryFn: ({ pageParam }) =>
fetchPage({ direction: "older", pivot, term: "", cursor: pageParam }),
getNextPageParam: (last) => last.next ?? undefined,
});
// The newer window is read only once asked for, and only for the split it
// was asked for: a changed date starts over on the older window.
const [newerFor, setNewerFor] = useState<string | null>(null);
const newerRequested = pivot !== null && newerFor === pivot;
const newer = useInfiniteQuery({
queryKey: queryKey("newer", pivot, ""),
// No date, no "after": the older window already starts at the newest.
enabled: enabled && !searching && newerRequested,
initialPageParam: null as string | null,
queryFn: ({ pageParam }) =>
fetchPage({ direction: "newer", pivot, term: "", cursor: pageParam }),
getNextPageParam: (last) => last.next ?? undefined,
});
const search = useInfiniteQuery({
queryKey: queryKey("search", null, term),
enabled: enabled && searching,
initialPageParam: null as string | null,
queryFn: ({ pageParam }) =>
fetchPage({ direction: "search", pivot: null, term, cursor: pageParam }),
getNextPageParam: (last) => last.next ?? undefined,
});
const items = useMemo(() => {
if (searching) {
return uniqueById((search.data?.pages ?? []).flatMap((p) => p.items));
}
return mergeWindowPages(newer.data?.pages ?? [], older.data?.pages ?? []);
}, [searching, search.data, newer.data, older.data]);
const activeError = searching
? search.isError
: older.isError || (newerRequested && newer.isError);
return {
items,
/** The first window is still loading; the inline block shows a skeleton. */
pending: older.isPending && !searching,
error: activeError,
retry: () => {
if (searching) void search.refetch();
else {
void older.refetch();
if (newerRequested) void newer.refetch();
}
},
term: rawTerm,
setTerm,
/** A search is in flight (typed, or its first page not back yet). */
searchPending:
serverSearch &&
(rawTerm.trim() !== term || (searching && search.isPending)),
/** The picker filters the loaded options by the term itself. */
filterLocally: !serverSearch,
hasOlder: searching ? search.hasNextPage : older.hasNextPage,
// Unknown until asked: offered once for every dated record, then only
// while the server reports another page.
hasNewer:
!searching &&
pivot !== null &&
(!newerRequested || newer.isFetching || newer.hasNextPage),
loadingOlder: searching
? search.isFetchingNextPage
: older.isFetchingNextPage,
loadingNewer: !searching && newerRequested && newer.isFetching,
loadOlder: () => {
if (searching) void search.fetchNextPage();
else void older.fetchNextPage();
},
loadNewer: () => {
if (searching || pivot === null) return;
if (!newerRequested) setNewerFor(pivot);
else void newer.fetchNextPage();
},
};
}
export type LinkPickerPages<T extends { id: string }> = ReturnType<
typeof useLinkPickerPages<T>
>;
/**
* Where a record's date splits the list: the end of the UTC day
* {@link ENCOUNTER_SUGGEST_WINDOW_DAYS} days after it, so everything the
* suggestion window would offer is on the first page. Day-granular so typing
* the time of a visit does not refetch. Null when there is no date (or it does
* not parse).
*/
export function pickerPivot(anchor: string | null): string | null {
if (!anchor) return null;
const parsed = new Date(anchor);
if (Number.isNaN(parsed.getTime())) return null;
const split = new Date(
parsed.getTime() + ENCOUNTER_SUGGEST_WINDOW_DAYS * 86_400_000,
);
split.setUTCHours(23, 59, 59, 999);
return split.toISOString();
}
/** The instant just after the split, where the newer window starts. */
export function afterPivot(pivot: string): string {
return new Date(new Date(pivot).getTime() + 1).toISOString();
}
/** Offset cursors, for the list routes that page by `offset`. */
export function nextOffset(
cursor: string | null,
received: number,
): string | null {
if (received < PICKER_PAGE_SIZE) return null;
return String(Number(cursor ?? "0") + received);
}
+58 -51
View File
@@ -7,38 +7,41 @@
* 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.
* person with a full vault. The next version walked the whole vault, ten
* pages deep, and anything past the thousandth document was out of reach.
*
* 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.
* Now the picker opens on the documents filed around the record's date and
* pages older and newer from there, and the search box asks the list route
* itself, so a document is reachable wherever it sits in a vault of any size
* (`useLinkPickerPages`). 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.
*
* A document whose filing date was cleared falls outside every date window;
* the search still finds it by title, file name or content.
*/
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import type { EntityLinkOption } from "@/components/links/entity-link-picker";
import {
serverPagingFrom,
type EntityLinkOption,
} from "@/components/links/entity-link-picker";
import { documentPickerUrl } from "@/components/links/picker-requests";
import {
pickerPivot,
useLinkPickerPages,
type PickerPage,
type PickerPageRequest,
} from "@/components/links/use-link-picker-pages";
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";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { daysBetweenDateKeys } from "@/lib/tz/format";
/**
* 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;
/** The fields the option list reads off a document. */
export type VaultDocument = Pick<
InboundDocumentDto,
@@ -50,25 +53,12 @@ interface ListPage {
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;
/** One picker page of the vault. */
export async function fetchVaultPage(
request: PickerPageRequest,
): Promise<PickerPage<VaultDocument>> {
const result = await apiGet<ListPage>(documentPickerUrl(request));
return { items: result.documents, next: result.nextCursor };
}
/**
@@ -135,8 +125,9 @@ export function documentOptionsWithSuggestions(
}
/**
* 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.
* The picker's data: the vault around the record's date as options, the
* suggestion group on top when the record has a date, the paging the sheet
* offers, and the query state an error row needs.
*/
export function useVaultDocumentOptions({
enabled,
@@ -148,26 +139,42 @@ export function useVaultDocumentOptions({
}) {
const { t } = useTranslations();
const format = useFormatters();
const query = useQuery({
queryKey: queryKeys.inboundDocumentVaultPicker(),
const pages = useLinkPickerPages<VaultDocument>({
enabled,
queryFn: fetchWholeVault,
pivot: pickerPivot(anchor),
queryKey: queryKeys.inboundDocumentVaultPicker,
fetchPage: fetchVaultPage,
});
const options = useMemo(
() =>
documentOptionsWithSuggestions(query.data ?? [], anchor, {
documentOptionsWithSuggestions(pages.items, 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],
[pages.items, anchor, t, format],
);
return {
options,
pending: query.isPending,
error: query.isError,
retry: () => void query.refetch(),
pending: pages.pending,
error: pages.error,
retry: pages.retry,
server: serverPagingFrom(pages),
};
}
/** A linked document as the record's own read names it, for its chip. */
export function linkedDocumentOption(
link: { id: string; label: string | null; date: string | null },
date: (iso: string) => string,
): EntityLinkOption | null {
if (link.label === null) return null;
return {
id: link.id,
label: link.label,
dateLabel: link.date ? date(link.date) : null,
href: `/documents?doc=${encodeURIComponent(link.id)}`,
};
}
@@ -5,10 +5,11 @@
* transcribed from. The shared {@link EntityLinkPicker} — an inline summary
* (removable chips + an add button) over a searchable, month-grouped sheet.
*
* The whole vault is offered, not only the pages dated near the dose: one
* The whole vault is reachable, not only the pages dated near the dose: one
* childhood record commonly covers a dozen doses given years apart, and it
* belongs on every one of them. The pages dated within the upload
* suggestion's window of this dose sit on top as suggestions.
* belongs on every one of them. The picker opens on the pages around the
* dose's date, the ones within the upload suggestion's window on top as
* suggestions, and pages older and newer or searches the vault from there.
*
* **The gate blanks the block, it does not post-filter it.** When the
* `inboundDocuments` module is off, this renders nothing — no heading, no empty
@@ -23,11 +24,15 @@
* than showing an empty set, which would both hide the links and offer to
* save their removal.
*/
import { useMemo } from "react";
import { FolderOpen } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { EntityLinkPicker } from "@/components/links/entity-link-picker";
import { useVaultDocumentOptions } from "@/components/links/vault-document-options";
import {
linkedDocumentOption,
useVaultDocumentOptions,
} from "@/components/links/vault-document-options";
import { ImportFromSourceButton } from "@/components/documents/sources/import-from-source-button";
import { mergeIds } from "@/components/documents/sources/merge-ids";
@@ -35,6 +40,7 @@ export function VaccinationDocumentPicker({
enabled,
anchor,
documentIds,
linkedDocuments,
seedPending = false,
seedError = false,
onRetrySeed,
@@ -45,6 +51,15 @@ export function VaccinationDocumentPicker({
/** The dose's own date (ISO), for the suggestions on top. */
anchor: string | null;
documentIds: string[];
/**
* The pages the dose is filed against, as its own read names them, so their
* chips show whatever part of the vault is loaded.
*/
linkedDocuments?: ReadonlyArray<{
id: string;
label: string | null;
date: string | null;
}>;
/** The dose's own links are still loading. */
seedPending?: boolean;
/** Reading the dose's own links failed. */
@@ -53,7 +68,15 @@ export function VaccinationDocumentPicker({
onChange: (documentIds: string[]) => void;
}) {
const { t } = useTranslations();
const format = useFormatters();
const vault = useVaultDocumentOptions({ enabled, anchor });
const known = useMemo(
() =>
(linkedDocuments ?? [])
.map((link) => linkedDocumentOption(link, (iso) => format.date(iso)))
.filter((option) => option !== null),
[linkedDocuments, format],
);
if (!enabled) return null;
@@ -73,6 +96,8 @@ export function VaccinationDocumentPicker({
selected={documentIds}
onChange={onChange}
options={vault.options}
server={vault.server}
knownOptions={known}
searchPlaceholder={t("links.picker.searchPlaceholder")}
emptyLabel={t("vaccinations.form.linkNothingToOffer")}
extraAction={
@@ -191,6 +191,12 @@ export function VaccinationForm({
*/
savedDocuments?: {
ids: string[] | null;
/** The same links with the label and date the detail read carries. */
links?: ReadonlyArray<{
id: string;
label: string | null;
date: string | null;
}>;
pending: boolean;
error: boolean;
retry: () => void;
@@ -376,6 +382,7 @@ export function VaccinationForm({
enabled={user?.modules?.inboundDocuments === true}
anchor={anchor}
documentIds={draft.documentIds ?? savedDocuments?.ids ?? []}
linkedDocuments={savedDocuments?.links}
seedPending={
draft.documentIds === null && (savedDocuments?.pending ?? false)
}
@@ -141,6 +141,7 @@ export function VaccinationSheet({
ids:
detail.data?.documents?.map((document) => document.id) ??
null,
links: detail.data?.documents,
pending: detail.isPending,
error: detail.isError,
retry: () => void detail.refetch(),
@@ -0,0 +1,114 @@
/**
* An extra dose next to a slot that already carries one.
*
* Two ends: the write path keeps a different external dose record off a slot
* that is already taken (`isAnotherDose`), and the read ledger lets the dose
* stored on the slot claim it before an extra one stored on its own instant,
* whatever order the rows were read in.
*/
import { describe, expect, it } from "vitest";
import { buildSlotBands, type SlotWindowInput } from "../attribution";
import { reconstructDoseHistory, type HistoryIntake } from "../dose-history";
import { SAME_DOSE_TOLERANCE_MS, isAnotherDose } from "../slot-upsert";
const MIN = 60_000;
const t0 = new Date("2026-06-09T20:00:00Z");
const plus = (minutes: number) => new Date(t0.getTime() + minutes * MIN);
describe("isAnotherDose", () => {
it("a slot with nothing taken is never held by another dose", () => {
expect(
isAnotherDose(
{ takenAt: null, externalId: null },
{ takenAt: plus(600), externalId: "hk-2" },
),
).toBe(false);
});
it("the same external record is the same dose", () => {
expect(
isAnotherDose(
{ takenAt: t0, externalId: "hk-1" },
{ takenAt: plus(600), externalId: "hk-1" },
),
).toBe(false);
});
it("two external records a day apart are two doses", () => {
expect(
isAnotherDose(
{ takenAt: t0, externalId: "hk-1" },
{ takenAt: plus(24 * 60 + 22), externalId: "hk-2" },
),
).toBe(true);
});
it("two external records within a minute are one dose entered twice", () => {
expect(
isAnotherDose(
{ takenAt: t0, externalId: "hk-1" },
{ takenAt: new Date(t0.getTime() + 30_000), externalId: "hk-2" },
),
).toBe(false);
});
it("a take with no external record merges within the tolerance only", () => {
const held = { takenAt: t0, externalId: null };
expect(
isAnotherDose(held, {
takenAt: new Date(t0.getTime() + SAME_DOSE_TOLERANCE_MS),
externalId: "hk-2",
}),
).toBe(false);
expect(isAnotherDose(held, { takenAt: plus(30), externalId: "hk-2" })).toBe(
true,
);
});
});
describe("reconstructDoseHistory: the slot's own dose claims the slot", () => {
const slot: SlotWindowInput = {
at: t0,
timeOfDay: "22:00",
onTimeStart: plus(-60),
onTimeEnd: plus(60),
lateGraceMs: 180 * MIN,
};
const bands = buildSlotBands([slot]);
const onSlot: HistoryIntake = {
id: "on-slot",
scheduledFor: t0,
takenAt: plus(5),
skipped: false,
};
const extra: HistoryIntake = {
id: "extra",
scheduledFor: plus(30),
takenAt: plus(30),
skipped: false,
};
it.each([
["slot row first", [onSlot, extra]],
["extra row first (newest scheduledFor first)", [extra, onSlot]],
])("%s", (_label, intakes) => {
const rows = reconstructDoseHistory(bands, intakes, plus(600), null);
const slotRow = rows.find((row) => row.kind === "slot");
const adHoc = rows.filter((row) => row.kind === "ad_hoc");
expect(slotRow?.intake?.id).toBe("on-slot");
expect(adHoc.map((row) => row.intake?.id)).toEqual(["extra"]);
});
it("among takes not stored on the slot, the nearer one claims it", () => {
const far: HistoryIntake = { ...extra, id: "far" };
const near: HistoryIntake = {
id: "near",
scheduledFor: plus(10),
takenAt: plus(10),
skipped: false,
};
const rows = reconstructDoseHistory(bands, [far, near], plus(600), null);
expect(rows.find((row) => row.kind === "slot")?.intake?.id).toBe("near");
});
});
+26 -1
View File
@@ -273,8 +273,33 @@ export function reconstructDoseHistory(
}
}
for (const i of taken) {
// The best fit claims a slot first, whatever order the caller read the rows
// in. A take the write path stored ON the slot it attributes to goes before
// one stored on its own instant (an extra dose recorded unscheduled), then
// on-time before late, then the nearer take. Processing in the caller's
// order (newest `scheduledFor` first on the history route) let an extra dose
// logged half an hour after the slot claim it and pushed the slot's own
// dose out as the off-schedule one.
const ranked = taken.map((i) => {
const matched = attributeIntakeToSlot(i.takenAt as Date, bands);
if (!matched) return { i, matched, key: [2, 0, 0] as const };
const storedOnSlot =
Math.abs(i.scheduledFor.getTime() - matched.band.at.getTime()) <=
ANCHOR_EPSILON_MS;
return {
i,
matched,
key: [
storedOnSlot ? 0 : 1,
matched.status === "on_time" ? 0 : 1,
Math.abs((i.takenAt as Date).getTime() - matched.band.at.getTime()),
] as const,
};
});
ranked.sort(
(a, b) => a.key[0] - b.key[0] || a.key[1] - b.key[1] || a.key[2] - b.key[2],
);
for (const { i, matched } of ranked) {
if (matched && !claim.has(matched.band)) {
claim.set(matched.band, {
intake: i,
@@ -746,6 +746,79 @@ export async function findPinConflict(input: {
});
}
/**
* How far apart two recorded takes of one slot may sit and still be the same
* dose logged twice (a tap in HealthLog and an entry in another app a few
* minutes later). Further apart, they are two doses.
*/
export const SAME_DOSE_TOLERANCE_MS = 15 * 60_000;
/**
* Is `held` (the take a slot already carries) a different dose from the
* incoming external dose record?
*
* Band attribution maps a take to the slot whose band contains it, and a
* weekly schedule's band reaches a day either side of the slot. Without this
* check an extra dose logged the day after a Tuesday dose landed on the
* Tuesday slot and overwrote it last-write-wins: the Tuesday time was
* replaced and the extra dose existed nowhere.
*
* - Nothing taken on the slot yet (pending, skipped): not another dose, the
* incoming take is the slot's dose.
* - The same external record: the same dose.
* - Two different external records: different doses, unless they sit within
* a minute of each other, which is a double entry rather than two doses.
* - Otherwise (the held take has no external record): the same dose within
* {@link SAME_DOSE_TOLERANCE_MS}, a different one beyond it.
*/
export function isAnotherDose(
held: { takenAt: Date | null; externalId: string | null },
incoming: { takenAt: Date; externalId: string | null },
): boolean {
if (held.takenAt === null) return false;
if (held.externalId !== null && held.externalId === incoming.externalId) {
return false;
}
const gap = Math.abs(held.takenAt.getTime() - incoming.takenAt.getTime());
if (held.externalId !== null && incoming.externalId !== null) {
return gap > 60_000;
}
return gap > SAME_DOSE_TOLERANCE_MS;
}
/**
* Does the slot at `canonicalSlot` already carry a recorded take that is a
* different dose from the incoming one (see {@link isAnotherDose})? The
* caller records the incoming take as its own unscheduled dose instead of
* converging onto the slot.
*/
export async function slotHeldByAnotherDose(input: {
userId: string;
medicationId: string;
canonicalSlot: Date;
takenAt: Date;
externalId: string | null;
client?: Pick<PrismaClient, "medicationIntakeEvent">;
}): Promise<boolean> {
const client = input.client ?? defaultPrisma;
const rows = await client.medicationIntakeEvent.findMany({
where: {
userId: input.userId,
medicationId: input.medicationId,
scheduledFor: input.canonicalSlot,
deletedAt: null,
takenAt: { not: null },
},
select: { takenAt: true, externalId: true },
});
return rows.some((row) =>
isAnotherDose(row, {
takenAt: input.takenAt,
externalId: input.externalId,
}),
);
}
export interface ResolveForcedSlotInput {
userId: string;
medicationId: string;
@@ -1,11 +1,11 @@
/**
* One key, one fetch. The visit form's link pickers read labs and illness
* episodes with their own page size (200 results, 100 episodes), the Labs
* list and the illness hook with theirs (500, the route's default 50). Under
* a shared key whichever read landed first served the other, so the picker
* could offer 50 episodes or the Labs list could render a 200-row page as
* the whole list. The picker reads carry their own keys, still under the
* domain roots so a lab or episode write evicts them.
* episodes in their own windows and pages, the Labs list and the illness hook
* with theirs. Under a shared key whichever read landed first served the
* other, so the picker could offer 50 episodes or the Labs list could render
* a picker page as the whole list. The picker reads carry their own keys,
* still under the domain roots so a lab or episode write evicts them, and
* each direction, split and term is its own key.
*/
import { describe, expect, it } from "vitest";
@@ -22,20 +22,36 @@ const LIST_PARAMS = {
describe("the link pickers' reads have keys of their own", () => {
it("labs: differs from the Labs list and sits under the lab root", () => {
const key = queryKeys.labResultsPicker(200);
const key = queryKeys.labResultsPicker("older", null, "");
expect(key).not.toEqual(queryKeys.labResultsList(LIST_PARAMS));
expect(key.slice(0, 1)).toEqual(queryKeys.labResults());
});
it("episodes: differs from the illness list and sits under the illness root", () => {
const key = queryKeys.illnessEpisodesPicker(100);
const key = queryKeys.illnessEpisodesPicker("older", null, "");
expect(key).not.toEqual(queryKeys.illnessEpisodes(true));
expect(key.slice(0, 1)).toEqual(queryKeys.illness());
});
it("a different page size is a different key", () => {
expect(queryKeys.labResultsPicker(200)).not.toEqual(
queryKeys.labResultsPicker(500),
it("a different direction, split or term is a different key", () => {
const base = queryKeys.labResultsPicker("older", "2019-05-17", "");
expect(base).not.toEqual(
queryKeys.labResultsPicker("newer", "2019-05-17", ""),
);
expect(base).not.toEqual(
queryKeys.labResultsPicker("older", "2020-01-01", ""),
);
expect(queryKeys.labResultsPicker("search", null, "a")).not.toEqual(
queryKeys.labResultsPicker("search", null, "b"),
);
});
it("documents and visits: under their roots", () => {
expect(
queryKeys.inboundDocumentVaultPicker("older", null, "").slice(0, 1),
).toEqual(["documents"]);
expect(queryKeys.encounterPicker("older", null, "").slice(0, 1)).toEqual(
queryKeys.encounters(),
);
});
});
+17 -5
View File
@@ -61,12 +61,24 @@ export const documentKeys = {
inboundDocumentPicker: (q: string) =>
["documents", "inbound", "picker", q] as const,
/**
* The whole vault as a link-picker option list (every page, walked at the
* list route's ceiling), shared by the visit and vaccination forms. Own
* segment because the shape is a flat document array, not a page.
* The vault as a link-picker option list, shared by the visit and
* vaccination forms: one paged direction from the record's date or a
* search. Own segment because the page shape is the picker's, not the
* library's.
*/
inboundDocumentVaultPicker: () =>
["documents", "inbound", "vault-picker"] as const,
inboundDocumentVaultPicker: (
direction: string,
pivot: string | null,
term: string,
) =>
[
"documents",
"inbound",
"vault-picker",
direction,
pivot ?? "",
term,
] as const,
/**
* Storage usage + effective limits (`GET /api/documents/inbound/usage`).
* Invalidated through the `["documents"]` prefix after every upload /
+7
View File
@@ -45,6 +45,13 @@ export const encounterKeys = {
status ?? null,
episodeId ?? null,
] as const,
/**
* The document sheet's visit picker: one paged direction from the
* document's date. Its own key because its page shape (offset steps inside
* a window) is not the list's.
*/
encounterPicker: (direction: string, pivot: string | null, term: string) =>
["encounters", "picker", direction, pivot ?? "", term] as const,
encounter: (id: string) => ["encounters", "detail", id] as const,
/**
* The procedure history for one filter. Under the `["encounters"]` root, so
+8 -4
View File
@@ -15,10 +15,14 @@ export const illnessKeys = {
illness: () => ["illness"] as const,
illnessEpisodes: (includeResolved: boolean) =>
["illness", "episodes", includeResolved] as const,
// The visit form's episode picker, with its own page size (the illness
// pages read the route's default); a shared key would serve one to the other.
illnessEpisodesPicker: (limit: number) =>
["illness", "episodes-picker", limit] as const,
// The visit form's episode picker: one paged direction from the visit's
// date or a search, with its own page shape; a shared key would serve one
// read to the other.
illnessEpisodesPicker: (
direction: string,
pivot: string | null,
term: string,
) => ["illness", "episodes-picker", direction, pivot ?? "", term] as const,
illnessEpisode: (id: string) => ["illness", "episode", id] as const,
illnessDayLog: (episodeId: string, date: string) =>
["illness", "day-log", episodeId, date] as const,
+6 -5
View File
@@ -36,11 +36,12 @@ export const labKeys = {
labAnalyteLink: (analyte: string) =>
["lab-results", "analyte-link", analyte] as const,
// The visit form's lab picker: newest results, its own page size. Its own
// key because the Labs list reads a different page size, and a shared key
// would serve one read's page to the other.
labResultsPicker: (limit: number) =>
["lab-results", "picker", limit] as const,
// The visit form's lab picker: one paged direction from the visit's date
// (`older` / `newer`) or a search across the whole history. Its own key
// because the Labs list reads other pages, and a shared key would serve
// one read's page to the other.
labResultsPicker: (direction: string, pivot: string | null, term: string) =>
["lab-results", "picker", direction, pivot ?? "", term] as const,
// v1.25 — paginated (offset) reading feed for the biomarker detail page.
// Distinct from `labResultsList` (single-page key) so the infinite-query
+5
View File
@@ -164,6 +164,11 @@ export const encounterListQuerySchema = z
*/
episodeId: id.optional(),
limit: z.coerce.number().int().min(1).max(200).optional(),
/**
* Rows to skip in each arm, for a picker that pages a long history in
* `limit`-sized steps inside a date window.
*/
offset: z.coerce.number().int().min(0).max(100_000).optional(),
})
.strict()
.refine((q) => !(q.from && q.to) || q.from <= q.to, {
+11
View File
@@ -136,6 +136,17 @@ export type IllnessEpisodeResolve = z.infer<typeof illnessEpisodeResolveSchema>;
export const illnessEpisodeListQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).optional(),
includeResolved: z.union([z.literal("true"), z.literal("false")]).optional(),
/**
* Paging and a date window over the onset, for a picker that opens on the
* episodes around a visit and pages older or newer from there.
*/
offset: z.coerce.number().int().min(0).max(100_000).optional(),
from: z.iso.datetime({ offset: true }).optional(),
to: z.iso.datetime({ offset: true }).optional(),
/** Onset order; newest first by default. */
sortDir: z.enum(["asc", "desc"]).optional(),
/** Case-insensitive substring over the episode's label. */
q: z.string().trim().min(1).max(120).optional(),
});
/* ── day-log capture (one row per day of an episode) ─────────────── */
+5
View File
@@ -284,6 +284,11 @@ export const listLabResultsSchema = z.object({
biomarkerId: z.string().trim().min(1).max(64).optional(),
analyte: z.string().trim().min(1).max(120).optional(),
panel: z.string().trim().min(1).max(120).optional(),
/**
* Case-insensitive substring over the analyte and the panel name, so a
* picker can find one result in a long history without paging to it.
*/
q: z.string().trim().min(1).max(120).optional(),
from: z
.string()
.datetime()
@@ -42,7 +42,9 @@ async function seedConnection(userId: string): Promise<void> {
googleUserId: `google-${userId}`,
accessToken: "encrypted-access",
refreshToken: "encrypted-refresh",
tokenExpiresAt: new Date("2027-01-01T00:00:00.000Z"),
// Valid for a year from whenever the suite runs: a fixed date would
// expire and send the sync down the refresh path.
tokenExpiresAt: new Date(Date.now() + 365 * 86_400_000),
},
});
}
@@ -0,0 +1,356 @@
/**
* An extra (off-schedule) dose recorded in Apple Health must arrive as its own
* unscheduled dose.
*
* The phone posts every HealthKit dose event to the bulk route with the
* event's UUID as `externalId`, `scheduledFor` set to the event's scheduled
* date or, for an extra dose that has none, its own time. A taken write binds
* to a slot by window band, and a weekly schedule's band reaches a day either
* side of its slot. An extra dose on the Wednesday after a Tuesday dose
* therefore landed on the Tuesday slot and overwrote it: the Tuesday time was
* replaced by the Wednesday one and the extra dose existed nowhere.
*
* Three shapes, each through the shipped `POST` and `GET` exports: an extra
* dose far from any slot, one half an hour after a slot already taken, and one
* on a day the schedule has no slot. Each asserts the stored rows AND the dose
* history the app reads back, where the extra dose must show as off-schedule
* and the slot keep its own dose.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { localHmAsUtc } from "@/lib/tz/local-day";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const TZ = "Europe/Berlin";
const DAY_MS = 24 * 60 * 60 * 1000;
const MIN = 60_000;
let counter = 0;
async function signedInUser(): Promise<string> {
const prisma = getPrismaClient();
const suffix = `extra-dose-${counter++}`;
const user = await prisma.user.create({
data: {
username: suffix,
email: `${suffix}@example.test`,
role: "USER",
timezone: TZ,
},
});
const session = await prisma.session.create({
data: { userId: user.id, expiresAt: new Date(Date.now() + 3_600_000) },
});
cookieJar.set("healthlog_session", session.id);
return user.id;
}
async function seedMedication(
userId: string,
schedule: { timesOfDay: string[]; daysOfWeek: string | null },
): Promise<string> {
const med = await getPrismaClient().medication.create({
data: {
userId,
name: "Magnesium",
dose: "400mg",
active: true,
// Dose events from Apple Health only target a mirrored medication.
externalSource: "APPLE_HEALTH",
externalId: `hk-medication-${userId}`,
startsOn: new Date(Date.now() - 60 * DAY_MS),
// The ledger expects slots only from the medication's creation on.
createdAt: new Date(Date.now() - 60 * DAY_MS),
schedules: {
create: {
windowStart: schedule.timesOfDay[0],
windowEnd: schedule.timesOfDay[0],
timesOfDay: schedule.timesOfDay,
daysOfWeek: schedule.daysOfWeek,
scheduleType: "SCHEDULED",
},
},
},
});
return med.id;
}
/** A local day at least two weeks back with the given weekday (0 = Sunday). */
function pastLocalDay(weekday: number): Date {
const day = new Date(Date.now() - 21 * DAY_MS);
day.setUTCHours(12, 0, 0, 0);
while (day.getUTCDay() !== weekday) {
day.setTime(day.getTime() - DAY_MS);
}
return day;
}
interface BulkEntry {
medicationId: string;
scheduledFor: string;
takenAt: string;
externalId: string;
}
/** The phone's shape: one HealthKit dose event per entry. */
function appleDose(
medicationId: string,
takenAt: Date,
externalId: string,
scheduledFor: Date = takenAt,
): BulkEntry {
return {
medicationId,
scheduledFor: scheduledFor.toISOString(),
takenAt: takenAt.toISOString(),
externalId,
};
}
async function postBulk(entries: BulkEntry[]) {
const { POST } = await import("@/app/api/medications/intake/bulk/route");
const response = await POST(
new NextRequest("http://localhost/api/medications/intake/bulk", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
entries: entries.map((entry) => ({
...entry,
source: "APPLE_HEALTH",
})),
}),
}),
);
expect(response.status).toBe(200);
const body = (await response.json()) as {
data: { entries: Array<{ status: string; id?: string }> };
};
return body.data.entries;
}
type Handler = (
request: NextRequest,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
context: { params: Promise<any> },
) => Promise<Response>;
interface LedgerRow {
kind: "slot" | "ad_hoc";
at: string;
status: string;
intake: { id: string | null; takenAt: string | null } | null;
}
async function readLedger(
medicationId: string,
from: Date,
to: Date,
): Promise<LedgerRow[]> {
const { GET } = await import("@/app/api/medications/[id]/dose-history/route");
const search = new URLSearchParams({
from: from.toISOString(),
to: to.toISOString(),
});
const response = await (GET as Handler)(
new NextRequest(
`http://localhost/api/medications/${medicationId}/dose-history?${search}`,
{ method: "GET" },
),
{ params: Promise.resolve({ id: medicationId }) },
);
expect(response.status).toBe(200);
const body = (await response.json()) as { data: { rows: LedgerRow[] } };
return body.data.rows;
}
async function rowsOf(medicationId: string) {
return getPrismaClient().medicationIntakeEvent.findMany({
where: { medicationId, deletedAt: null },
orderBy: { takenAt: "asc" },
});
}
function takenAtOf(row: LedgerRow | undefined): number | null {
const taken = row?.intake?.takenAt;
return taken ? new Date(taken).getTime() : null;
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("an extra dose from Apple Health is stored as an unscheduled dose", () => {
it("(a) far from any slot", async () => {
const userId = await signedInUser();
const medicationId = await seedMedication(userId, {
timesOfDay: ["08:00"],
daysOfWeek: null,
});
const day = pastLocalDay(3);
const slot = localHmAsUtc(day, TZ, 8, 0);
const extraAt = localHmAsUtc(day, TZ, 15, 0);
const results = await postBulk([
appleDose(medicationId, new Date(slot.getTime() + 5 * MIN), "hk-a-1"),
appleDose(medicationId, extraAt, "hk-a-2"),
]);
expect(results.map((r) => r.status)).toEqual(["inserted", "inserted"]);
const rows = await rowsOf(medicationId);
expect(rows).toHaveLength(2);
const extra = rows.find((row) => row.externalId === "hk-a-2");
expect(extra?.takenAt?.getTime()).toBe(extraAt.getTime());
expect(extra?.scheduledFor.getTime()).toBe(extraAt.getTime());
const ledger = await readLedger(
medicationId,
new Date(slot.getTime() - 6 * 3_600_000),
new Date(slot.getTime() + 18 * 3_600_000),
);
const adHoc = ledger.filter((row) => row.kind === "ad_hoc");
expect(adHoc).toHaveLength(1);
expect(takenAtOf(adHoc[0])).toBe(extraAt.getTime());
const slotRow = ledger.find((row) => row.kind === "slot");
expect(slotRow?.status).toBe("taken_on_time");
});
it("(b) 30 minutes after a slot already taken", async () => {
const userId = await signedInUser();
const medicationId = await seedMedication(userId, {
timesOfDay: ["22:00"],
daysOfWeek: null,
});
const day = pastLocalDay(2);
const slot = localHmAsUtc(day, TZ, 22, 0);
const ownAt = new Date(slot.getTime() + 2 * MIN);
const extraAt = new Date(slot.getTime() + 30 * MIN);
// The slot's own dose first, then the extra one in a later sync.
expect(
(await postBulk([appleDose(medicationId, ownAt, "hk-b-1", slot)])).map(
(r) => r.status,
),
).toEqual(["inserted"]);
expect(
(await postBulk([appleDose(medicationId, extraAt, "hk-b-2")])).map(
(r) => r.status,
),
).toEqual(["inserted"]);
const rows = await rowsOf(medicationId);
expect(rows).toHaveLength(2);
const own = rows.find((row) => row.externalId === "hk-b-1");
const extra = rows.find((row) => row.externalId === "hk-b-2");
// The slot keeps its own dose, time unchanged.
expect(own?.scheduledFor.getTime()).toBe(slot.getTime());
expect(own?.takenAt?.getTime()).toBe(ownAt.getTime());
// The extra dose sits on its own instant.
expect(extra?.scheduledFor.getTime()).toBe(extraAt.getTime());
expect(extra?.takenAt?.getTime()).toBe(extraAt.getTime());
const ledger = await readLedger(
medicationId,
new Date(slot.getTime() - 6 * 3_600_000),
new Date(slot.getTime() + 6 * 3_600_000),
);
const slotRow = ledger.find((row) => row.kind === "slot");
expect(slotRow?.status).toBe("taken_on_time");
expect(takenAtOf(slotRow)).toBe(ownAt.getTime());
const adHoc = ledger.filter((row) => row.kind === "ad_hoc");
expect(adHoc).toHaveLength(1);
expect(takenAtOf(adHoc[0])).toBe(extraAt.getTime());
});
it("(b2) a re-sync of the same dose event stays one dose", async () => {
const userId = await signedInUser();
const medicationId = await seedMedication(userId, {
timesOfDay: ["22:00"],
daysOfWeek: null,
});
const slot = localHmAsUtc(pastLocalDay(2), TZ, 22, 0);
const ownAt = new Date(slot.getTime() + 2 * MIN);
await postBulk([appleDose(medicationId, ownAt, "hk-b2-1", slot)]);
const again = await postBulk([
appleDose(medicationId, ownAt, "hk-b2-1", slot),
]);
expect(again.map((r) => r.status)).toEqual(["duplicate"]);
expect(await rowsOf(medicationId)).toHaveLength(1);
});
it("(c) on a day without a slot, next to the weekly slot the day before", async () => {
const userId = await signedInUser();
// Tuesdays and Fridays at 22:00.
const medicationId = await seedMedication(userId, {
timesOfDay: ["22:00"],
daysOfWeek: "2,5",
});
const tuesday = pastLocalDay(2);
const wednesday = new Date(tuesday.getTime() + DAY_MS);
const tuesdaySlot = localHmAsUtc(tuesday, TZ, 22, 0);
const tuesdayDose = localHmAsUtc(tuesday, TZ, 20, 23);
const wednesdayDose = localHmAsUtc(wednesday, TZ, 20, 45);
await postBulk([appleDose(medicationId, tuesdayDose, "hk-c-tue")]);
const results = await postBulk([
appleDose(medicationId, wednesdayDose, "hk-c-wed"),
]);
expect(results.map((r) => r.status)).toEqual(["inserted"]);
const rows = await rowsOf(medicationId);
expect(rows).toHaveLength(2);
const tue = rows.find((row) => row.externalId === "hk-c-tue");
const wed = rows.find((row) => row.externalId === "hk-c-wed");
// Tuesday keeps its own time.
expect(tue?.scheduledFor.getTime()).toBe(tuesdaySlot.getTime());
expect(tue?.takenAt?.getTime()).toBe(tuesdayDose.getTime());
// Wednesday is its own unscheduled dose.
expect(wed?.scheduledFor.getTime()).toBe(wednesdayDose.getTime());
expect(wed?.takenAt?.getTime()).toBe(wednesdayDose.getTime());
const ledger = await readLedger(
medicationId,
new Date(tuesday.getTime() - 2 * DAY_MS),
new Date(tuesday.getTime() + 2 * DAY_MS),
);
const tuesdayRow = ledger.find(
(row) =>
row.kind === "slot" &&
new Date(row.at).getTime() === tuesdaySlot.getTime(),
);
expect(takenAtOf(tuesdayRow)).toBe(tuesdayDose.getTime());
const adHoc = ledger.filter((row) => row.kind === "ad_hoc");
expect(adHoc.map(takenAtOf)).toEqual([wednesdayDose.getTime()]);
});
});
@@ -15,7 +15,7 @@
* tooltip.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup";
@@ -46,7 +46,14 @@ vi.mock("@/lib/db-compat", () => ({
}));
/** Fixed clock: a ladder test that slides with the calendar is a date bomb. */
const NOW = new Date("2026-08-08T09:00:00.000Z");
const FIXED_NOW = new Date("2026-08-08T09:00:00.000Z");
/**
* The instant the seed and the job are dated against. The ladder cases keep
* the fixed clock; the route cases move it to the real one, because the route
* reads the forecast against the wall clock and a forecast seeded at a fixed
* date goes stale under it as the calendar moves on.
*/
let NOW = FIXED_NOW;
function dayKey(daysAgo: number): string {
return new Date(NOW.getTime() - daysAgo * DAY_MS).toISOString().slice(0, 10);
@@ -271,6 +278,13 @@ describe("the minimum-n ladder, end to end", () => {
});
describe("GET /api/mood/prognosis", () => {
beforeEach(() => {
NOW = new Date();
});
afterEach(() => {
NOW = FIXED_NOW;
});
/**
* A signed-in browser session.
*
@@ -795,7 +795,12 @@ manageContract("POST /api/measurement-reminders/[id]/snooze", {
POST as Handler,
"POST",
`/api/measurement-reminders/${row.id}/snooze`,
{ until: "2027-01-15" },
// A snooze must end in the future; a fixed date stops being one.
{
until: new Date(Date.now() + 90 * 86_400_000)
.toISOString()
.slice(0, 10),
},
{ id: row.id },
);
},
@@ -34,6 +34,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
/**
* The night the fixtures sleep, ten days back from whenever the suite runs.
* The analytics route summarises a window that ends now; a fixed date slides
* out of it as the calendar moves on.
*/
const SLEEP_DAY = new Date(Date.now() - 10 * 86_400_000)
.toISOString()
.slice(0, 10);
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
@@ -130,7 +139,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", ()
value: 420,
unit: "minutes",
source: "WITHINGS",
measuredAt: new Date("2026-05-12T07:00:00.000Z"),
measuredAt: new Date(`${SLEEP_DAY}T07:00:00.000Z`),
},
});
@@ -162,7 +171,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", ()
value: 480,
unit: "minutes",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-12T06:00:00.000Z"),
measuredAt: new Date(`${SLEEP_DAY}T06:00:00.000Z`),
externalId: "ah-sleep-1",
},
});
@@ -174,7 +183,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", ()
value: 410,
unit: "minutes",
source: "WITHINGS",
measuredAt: new Date("2026-05-12T07:00:00.000Z"),
measuredAt: new Date(`${SLEEP_DAY}T07:00:00.000Z`),
},
});
@@ -200,7 +209,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", ()
value: 380,
unit: "minutes",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-12T06:00:00.000Z"),
measuredAt: new Date(`${SLEEP_DAY}T06:00:00.000Z`),
externalId: "ah-sleep-watch",
deviceType: "watch",
},
@@ -212,7 +221,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", ()
value: 480,
unit: "minutes",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-12T06:30:00.000Z"),
measuredAt: new Date(`${SLEEP_DAY}T06:30:00.000Z`),
externalId: "ah-sleep-phone",
deviceType: "phone",
},
@@ -240,7 +249,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", ()
value: 480,
unit: "minutes",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-12T06:00:00.000Z"),
measuredAt: new Date(`${SLEEP_DAY}T06:00:00.000Z`),
externalId: "ah-sleep-watch",
deviceType: "watch",
},
@@ -252,7 +261,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", ()
value: 380,
unit: "minutes",
source: "APPLE_HEALTH",
measuredAt: new Date("2026-05-12T06:30:00.000Z"),
measuredAt: new Date(`${SLEEP_DAY}T06:30:00.000Z`),
externalId: "ah-sleep-phone",
deviceType: "phone",
},
+8 -1
View File
@@ -420,8 +420,15 @@ describe("per-user timezone — Pacific/Auckland end-to-end", () => {
// doesn't trigger a tz divergence on its own. The point is to
// confirm the route returns a non-zero streak and goes through
// the user-tz path without crashing.
// Three consecutive days ending a week back: the summary reads its
// streaks from a window that ends now, so fixed dates would slide out.
const firstDay = Date.UTC(
new Date().getUTCFullYear(),
new Date().getUTCMonth(),
new Date().getUTCDate() - 9,
);
for (let i = 0; i < 3; i++) {
const dt = new Date(`2026-05-${12 + i}T11:30:00.000Z`);
const dt = new Date(firstDay + i * 86_400_000 + 11.5 * 3_600_000);
await prisma.measurement.create({
data: {
userId: me.id,
@@ -0,0 +1,311 @@
/**
* A visit can link a record from anywhere in a long history.
*
* The visit form's lab picker loaded the newest 200 results and offered only
* those, the condition picker the newest 100 conditions. On a record with a
* long history everything older could not be linked: a 2019 visit could not
* reach its own 2019 lab panel. The pickers now open on a window around the
* visit's date and page and search through the list routes.
*
* Everything here goes through the shipped route exports with the requests
* the pickers build (`picker-requests.ts`), against a record of 1,200 lab
* results spread over seven years, so the window, the search and the paging
* are proven against real SQL, and the link is saved and read back.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { NextRequest } from "next/server";
import {
GET as listEncounters,
POST as createEncounter,
} from "@/app/api/encounters/route";
import { GET as listEpisodes } from "@/app/api/illness/episodes/route";
import { GET as listLabs } from "@/app/api/labs/route";
import {
encounterPickerUrl,
episodePickerUrl,
labPickerUrl,
} from "@/components/links/picker-requests";
import {
nextOffset,
pickerPivot,
type PickerPageRequest,
} from "@/components/links/use-link-picker-pages";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => cookieJar.set(name, value),
delete: (name: string) => cookieJar.delete(name),
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const OWNER_ID = "long-history-owner";
const DAY_MS = 24 * 60 * 60 * 1000;
/** Historical on purpose: the visit and its panel sit years back. */
const VISIT_AT = "2019-04-03T09:30:00.000Z";
const FERRITIN_AT = new Date("2019-04-03T07:00:00.000Z");
const RESULT_COUNT = 1_200;
async function seedOwner() {
const prisma = getPrismaClient();
await prisma.user.create({
data: {
id: OWNER_ID,
username: "long-history",
email: "long-history@example.test",
timezone: "Europe/Berlin",
modulePreferencesJson: { labs: true, illness: true },
},
});
const session = await prisma.session.create({
data: {
userId: OWNER_ID,
expiresAt: new Date(Date.now() + 7 * DAY_MS),
mfaVerifiedAt: new Date(),
},
});
cookieJar.set("healthlog_session", session.id);
}
/**
* 1,200 glucose results, one every two days back from today, plus the one
* ferritin result drawn on the morning of the 2019 visit.
*/
async function seedLabs(): Promise<string> {
const prisma = getPrismaClient();
const now = Date.now();
await prisma.labResult.createMany({
data: Array.from({ length: RESULT_COUNT }, (_, i) => ({
userId: OWNER_ID,
panel: "Metabolic",
analyte: "Glucose",
value: 90 + (i % 10),
unit: "mg/dL",
takenAt: new Date(now - (i + 1) * 2 * DAY_MS),
})),
});
const ferritin = await prisma.labResult.create({
data: {
userId: OWNER_ID,
panel: "Iron studies",
analyte: "Ferritin",
value: 54,
unit: "ng/mL",
takenAt: FERRITIN_AT,
},
});
return ferritin.id;
}
async function get<T>(
handler: (request: NextRequest) => Promise<Response>,
url: string,
): Promise<T> {
const response = await handler(new NextRequest(`http://localhost${url}`));
expect(response.status, url).toBe(200);
return ((await response.json()) as { data: T }).data;
}
interface LabPage {
results: Array<{ id: string; analyte: string; takenAt: string }>;
meta: { total: number };
}
const labPage = (request: PickerPageRequest) =>
get<LabPage>(listLabs as never, labPickerUrl(request));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
await seedOwner();
});
describe("the visit form's lab picker on a 1,200-result record", () => {
it("the old newest-200 page never reached the 2019 result", async () => {
const ferritinId = await seedLabs();
const page = await get<LabPage>(
listLabs as never,
"/api/labs?limit=200&offset=0&sortDir=desc",
);
expect(page.meta.total).toBe(RESULT_COUNT + 1);
expect(page.results.some((row) => row.id === ferritinId)).toBe(false);
});
it("opens on the visit's own date, with the 2019 result on the first page", async () => {
const ferritinId = await seedLabs();
const pivot = pickerPivot(VISIT_AT);
const first = await labPage({
direction: "older",
pivot,
term: "",
cursor: null,
});
expect(first.results.some((row) => row.id === ferritinId)).toBe(true);
// Nothing after the window's split is on it.
for (const row of first.results) {
expect(new Date(row.takenAt).getTime()).toBeLessThanOrEqual(
new Date(pivot!).getTime(),
);
}
// "Load newer" pages forward from the split, oldest first, without
// repeating a row.
const seen = new Set(first.results.map((row) => row.id));
let cursor: string | null = null;
let newerRows = 0;
let previous = new Date(pivot!).getTime();
for (let guard = 0; guard < 20; guard++) {
const page = await labPage({
direction: "newer",
pivot,
term: "",
cursor,
});
for (const row of page.results) {
expect(seen.has(row.id)).toBe(false);
seen.add(row.id);
const at = new Date(row.takenAt).getTime();
expect(at).toBeGreaterThanOrEqual(previous);
previous = at;
}
newerRows += page.results.length;
cursor = nextOffset(cursor, page.results.length);
if (cursor === null) break;
}
// Older window plus every newer page cover the whole record once.
expect(seen.size).toBe(RESULT_COUNT + 1);
expect(newerRows).toBeGreaterThan(0);
});
it("a search finds the 2019 result among 1,200 others, by analyte and by panel", async () => {
const ferritinId = await seedLabs();
for (const term of ["ferritin", "iron stud"]) {
const page = await labPage({
direction: "search",
pivot: null,
term,
cursor: null,
});
expect(page.results.map((row) => row.id)).toEqual([ferritinId]);
}
});
it("the found result links to the visit and reads back on it", async () => {
const ferritinId = await seedLabs();
const created = await createEncounter(
new Request("http://localhost/api/encounters", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
occurredAt: VISIT_AT,
status: "DONE",
kind: "ROUTINE",
labResultIds: [ferritinId],
}),
}) as never,
);
expect(created.status).toBe(201);
const list = await get<{
past: Array<{
id: string;
links?: { labResults: Array<{ id: string; label: string | null }> };
}>;
}>(
listEncounters as never,
encounterPickerUrl({
direction: "older",
pivot: pickerPivot(VISIT_AT),
term: "",
cursor: null,
}),
);
expect(list.past).toHaveLength(1);
expect(list.past[0]!.links?.labResults).toEqual([
expect.objectContaining({
id: ferritinId,
label: "Iron studies · Ferritin",
}),
]);
});
});
describe("the condition picker's window and search", () => {
it("pages the episodes around a date and finds one by name", async () => {
const prisma = getPrismaClient();
const now = Date.now();
await prisma.illnessEpisode.createMany({
data: Array.from({ length: 150 }, (_, i) => ({
userId: OWNER_ID,
label: `Cold ${i}`,
type: "INFECTION" as const,
onsetAt: new Date(now - (i + 1) * 10 * DAY_MS),
})),
});
const old = await prisma.illnessEpisode.create({
data: {
userId: OWNER_ID,
label: "Tendinitis",
type: "INJURY",
onsetAt: new Date("2019-03-20T00:00:00.000Z"),
},
});
const pivot = pickerPivot(VISIT_AT);
const around = await get<Array<{ id: string; onsetAt: string }>>(
listEpisodes as never,
episodePickerUrl({ direction: "older", pivot, term: "", cursor: null }),
);
expect(around[0]!.id).toBe(old.id);
const found = await get<Array<{ id: string }>>(
listEpisodes as never,
episodePickerUrl({
direction: "search",
pivot: null,
term: "tendin",
cursor: null,
}),
);
expect(found.map((row) => row.id)).toEqual([old.id]);
// Offset paging walks the whole newer side without a repeat.
const ids = new Set<string>();
let cursor: string | null = null;
for (let guard = 0; guard < 5; guard++) {
const page: Array<{ id: string }> = await get(
listEpisodes as never,
episodePickerUrl({ direction: "newer", pivot, term: "", cursor }),
);
for (const row of page) {
expect(ids.has(row.id)).toBe(false);
ids.add(row.id);
}
cursor = nextOffset(cursor, page.length);
if (cursor === null) break;
}
expect(ids.size).toBe(150);
});
});