mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge branch 'v1424/fixes' into release/v1.42.4
This commit is contained in:
commit
de40db7ad0
49 files changed
+2579
-269
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -12174,7 +12174,10 @@
|
||||
"suggested": "이 날짜 전후",
|
||||
"loadError": "목록을 불러오지 못했습니다.",
|
||||
"removeNamed": "{name} 제거",
|
||||
"removed": "{name} 연결 해제됨"
|
||||
"removed": "{name} 연결 해제됨",
|
||||
"linked": "연결됨",
|
||||
"loadNewer": "최근 항목 더 보기",
|
||||
"loadOlder": "이전 항목 더 보기"
|
||||
}
|
||||
},
|
||||
"bodySites": {
|
||||
|
||||
+4
-1
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 /
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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) ─────────────── */
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user