diff --git a/src/components/documents/__tests__/document-record-links.test.tsx b/src/components/documents/__tests__/document-record-links.test.tsx
index 209e10413..5d980d908 100644
--- a/src/components/documents/__tests__/document-record-links.test.tsx
+++ b/src/components/documents/__tests__/document-record-links.test.tsx
@@ -18,6 +18,7 @@ vi.mock("@/components/links/use-link-picker-pages", async (importOriginal) => ({
setTerm: vi.fn(),
searchPending: false,
filterLocally: true,
+ period: null,
hasOlder: false,
hasNewer: false,
loadingOlder: false,
diff --git a/src/components/links/__tests__/entity-link-picker-paging.test.tsx b/src/components/links/__tests__/entity-link-picker-paging.test.tsx
index 8c7e67de3..21342b4d9 100644
--- a/src/components/links/__tests__/entity-link-picker-paging.test.tsx
+++ b/src/components/links/__tests__/entity-link-picker-paging.test.tsx
@@ -125,6 +125,41 @@ describe("paged link picker", () => {
expect(html).toContain('data-slot="labs-add"');
});
+ it("names the period a date term searches, and does not filter its answer by the digits", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-slot="labs-period"');
+ expect(html).toContain("Showing March 2019");
+ expect(html).toContain('data-slot="labs-option"');
+ });
+
+ it("shows no period line for a plain term", () => {
+ const html = render(
+ ,
+ );
+ expect(html).not.toContain('data-slot="labs-period"');
+ });
+
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(
diff --git a/src/components/links/__tests__/period-query.test.ts b/src/components/links/__tests__/period-query.test.ts
new file mode 100644
index 000000000..ee13a4805
--- /dev/null
+++ b/src/components/links/__tests__/period-query.test.ts
@@ -0,0 +1,155 @@
+/**
+ * A picker term that names a year or a month becomes a date window; anything
+ * else stays a text search. Every accepted form is checked in every UI
+ * locale, and the near misses that must stay text are checked too.
+ */
+import { describe, expect, it } from "vitest";
+
+import { formatPeriod, parsePeriodQuery } from "../period-query";
+
+const NOW = new Date("2026-10-15T12:00:00.000Z");
+const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
+
+function window(term: string, locale: string) {
+ const parsed = parsePeriodQuery(term, locale, NOW);
+ return parsed ? `${parsed.fromDay}..${parsed.toDay}` : null;
+}
+
+const MARCH = "2019-03-01..2019-03-31";
+const YEAR = "2019-01-01..2019-12-31";
+
+describe("parsePeriodQuery", () => {
+ describe.each(LOCALES)("%s", (locale) => {
+ it("reads a year", () => {
+ expect(window("2019", locale)).toBe(YEAR);
+ expect(window(" 2019 ", locale)).toBe(YEAR);
+ });
+
+ it.each([
+ "03.2019",
+ "3.2019",
+ "03/2019",
+ "3/2019",
+ "03-2019",
+ "2019-03",
+ "2019/03",
+ "2019.03",
+ "2019.3",
+ " 03 . 2019 ",
+ ])("reads the numeric month %j", (term) => {
+ expect(window(term, locale)).toBe(MARCH);
+ });
+
+ it.each(["LDL", "2019abc", "13.2019", "00.2019", "1850", "1899", "2028"])(
+ "leaves %j as text",
+ (term) => {
+ expect(window(term, locale)).toBeNull();
+ },
+ );
+
+ it.each(["", " ", "2019 2020", "03.2019.04", "LDL 2019", "3 2019 x"])(
+ "leaves %j as text",
+ (term) => {
+ expect(window(term, locale)).toBeNull();
+ },
+ );
+ });
+
+ it.each([
+ ["de", ["März 2019", "märz 2019", "MÄRZ 2019", "Maerz 2019", "Marz 2019"]],
+ ["de", ["mär 2019", "Mär. 2019", "2019 März", "März, 2019"]],
+ ["en", ["March 2019", "march 2019", "Mar 2019", "Mar. 2019", "2019 March"]],
+ ["es", ["marzo 2019", "Marzo de 2019", "mar 2019", "mar. 2019"]],
+ ["fr", ["mars 2019", "Mars 2019", "mar 2019", "2019 mars"]],
+ ["it", ["marzo 2019", "Marzo 2019", "mar 2019", "marzo del 2019"]],
+ ["pl", ["marzec 2019", "Marzec 2019", "marca 2019", "mar 2019"]],
+ ["ko", ["2019년 3월", "2019년3월", "2019년 03월", "3월 2019", "2019. 3."]],
+ ] as const)("reads March 2019 in %s", (locale, terms) => {
+ for (const term of terms) {
+ expect(window(term, locale), term).toBe(MARCH);
+ }
+ });
+
+ it.each([
+ ["de", "Dezember 2019", "2019-12-01..2019-12-31"],
+ ["de", "Okt 2019", "2019-10-01..2019-10-31"],
+ ["de", "Februar 2020", "2020-02-01..2020-02-29"],
+ ["en", "Sept 2019", "2019-09-01..2019-09-30"],
+ ["en", "Sep 2019", "2019-09-01..2019-09-30"],
+ ["en", "May 2019", "2019-05-01..2019-05-31"],
+ ["es", "septiembre 2019", "2019-09-01..2019-09-30"],
+ ["es", "ene 2019", "2019-01-01..2019-01-31"],
+ ["es", "dic 2019", "2019-12-01..2019-12-31"],
+ ["fr", "février 2019", "2019-02-01..2019-02-28"],
+ ["fr", "fevrier 2019", "2019-02-01..2019-02-28"],
+ ["fr", "févr. 2019", "2019-02-01..2019-02-28"],
+ ["fr", "août 2019", "2019-08-01..2019-08-31"],
+ ["fr", "déc 2019", "2019-12-01..2019-12-31"],
+ ["fr", "juil. 2019", "2019-07-01..2019-07-31"],
+ ["it", "gennaio 2019", "2019-01-01..2019-01-31"],
+ ["it", "giu 2019", "2019-06-01..2019-06-30"],
+ ["it", "set 2019", "2019-09-01..2019-09-30"],
+ ["pl", "październik 2019", "2019-10-01..2019-10-31"],
+ ["pl", "paź 2019", "2019-10-01..2019-10-31"],
+ ["pl", "października 2019", "2019-10-01..2019-10-31"],
+ ["pl", "luty 2019", "2019-02-01..2019-02-28"],
+ ["pl", "styczeń 2019", "2019-01-01..2019-01-31"],
+ ["ko", "2019년 12월", "2019-12-01..2019-12-31"],
+ ["ko", "2019년", YEAR],
+ // Austrian German calls January "Jänner", and Intl knows it.
+ ["de-AT", "Jänner 2019", "2019-01-01..2019-01-31"],
+ ["de-AT", "März 2019", MARCH],
+ ] as const)("%s %j", (locale, term, expected) => {
+ expect(window(term, locale)).toBe(expected);
+ });
+
+ it.each([
+ // "jui" is both juin and juillet; "ma" is too short to name one month.
+ ["fr", "jui 2019"],
+ ["en", "ma 2019"],
+ ["en", "Ju 2019"],
+ // A month name from another language is text in this one.
+ ["en", "März 2019"],
+ ["de", "March 2019"],
+ // A month without a year is not a period.
+ ["en", "March"],
+ ["de", "03"],
+ // Two months, or a month name with digits glued on.
+ ["en", "March May"],
+ ["en", "March2019"],
+ ] as const)("%s %j stays text", (locale, term) => {
+ expect(window(term, locale)).toBeNull();
+ });
+
+ it("accepts next year and refuses the one after", () => {
+ expect(window("2027", "en")).toBe("2027-01-01..2027-12-31");
+ expect(window("12.2027", "en")).toBe("2027-12-01..2027-12-31");
+ expect(window("2028", "en")).toBeNull();
+ expect(window("1900", "en")).toBe("1900-01-01..1900-12-31");
+ });
+
+ it("names the year and the month", () => {
+ expect(parsePeriodQuery("2019", "de", NOW)).toEqual({
+ year: 2019,
+ month: null,
+ fromDay: "2019-01-01",
+ toDay: "2019-12-31",
+ });
+ expect(parsePeriodQuery("Maerz 2019", "de", NOW)).toMatchObject({
+ year: 2019,
+ month: 3,
+ });
+ });
+});
+
+describe("formatPeriod", () => {
+ it("names a period the way the locale writes it", () => {
+ const march = parsePeriodQuery("03.2019", "de", NOW)!;
+ expect(formatPeriod(march, "de")).toBe("März 2019");
+ expect(formatPeriod(march, "en")).toBe("March 2019");
+ expect(formatPeriod(march, "ko")).toBe("2019년 3월");
+ expect(formatPeriod(parsePeriodQuery("2019", "en", NOW)!, "en")).toBe(
+ "2019",
+ );
+ });
+});
diff --git a/src/components/links/__tests__/picker-requests.test.ts b/src/components/links/__tests__/picker-requests.test.ts
index 125939e4b..377b2a46b 100644
--- a/src/components/links/__tests__/picker-requests.test.ts
+++ b/src/components/links/__tests__/picker-requests.test.ts
@@ -28,15 +28,24 @@ import {
} from "../use-link-picker-pages";
const pivot = pickerPivot("2019-05-10T10:00:00.000Z");
+const ctx = { locale: "en", timeZone: "UTC" } as const;
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" },
+ { direction: "older", pivot, term: "", cursor: null, ...ctx },
+ { direction: "older", pivot, term: "", cursor: "200", ...ctx },
+ { direction: "older", pivot: null, term: "", cursor: null, ...ctx },
+ { direction: "newer", pivot, term: "", cursor: null, ...ctx },
+ { direction: "newer", pivot, term: "", cursor: "100", ...ctx },
+ { direction: "search", pivot: null, term: "ferritin", cursor: null, ...ctx },
+ { direction: "search", pivot: null, term: "ferritin", cursor: "100", ...ctx },
+ { direction: "search", pivot: null, term: "2019", cursor: null, ...ctx },
+ {
+ direction: "search",
+ pivot: null,
+ term: "March 2019",
+ cursor: "100",
+ ...ctx,
+ },
];
function params(url: string): Record {
@@ -67,10 +76,22 @@ describe("documents picker", () => {
it("splits the day-granular window at the pivot's day", () => {
const older = params(
- documentPickerUrl({ direction: "older", pivot, term: "", cursor: null }),
+ documentPickerUrl({
+ direction: "older",
+ pivot,
+ term: "",
+ cursor: null,
+ ...ctx,
+ }),
);
const newer = params(
- documentPickerUrl({ direction: "newer", pivot, term: "", cursor: null }),
+ documentPickerUrl({
+ direction: "newer",
+ pivot,
+ term: "",
+ cursor: null,
+ ...ctx,
+ }),
);
expect(older.to).toBe("2019-05-17");
expect(newer.from).toBe("2019-05-18");
@@ -88,10 +109,22 @@ describe("the windows around a record's date", () => {
it("older reads up to the split newest first, newer after it oldest first", () => {
const older = params(
- labPickerUrl({ direction: "older", pivot, term: "", cursor: null }),
+ labPickerUrl({
+ direction: "older",
+ pivot,
+ term: "",
+ cursor: null,
+ ...ctx,
+ }),
);
const newer = params(
- labPickerUrl({ direction: "newer", pivot, term: "", cursor: null }),
+ labPickerUrl({
+ direction: "newer",
+ pivot,
+ term: "",
+ cursor: null,
+ ...ctx,
+ }),
);
expect(older).toMatchObject({ to: pivot, sortDir: "desc", offset: "0" });
expect(newer).toMatchObject({
@@ -107,6 +140,7 @@ describe("the windows around a record's date", () => {
pivot: null,
term: "ferritin",
cursor: null,
+ ...ctx,
}),
);
expect(search.q).toBe("ferritin");
@@ -120,3 +154,88 @@ describe("the windows around a record's date", () => {
expect(nextOffset("100", 3)).toBeNull();
});
});
+
+describe("a search term that names a period", () => {
+ const search = (
+ term: string,
+ locale = "en",
+ timeZone = "UTC",
+ ): PickerPageRequest => ({
+ direction: "search",
+ pivot: null,
+ term,
+ cursor: null,
+ locale,
+ timeZone,
+ });
+
+ it.each([
+ ["labs", labPickerUrl, "sortDir"],
+ ["episodes", episodePickerUrl, "sortDir"],
+ ] as const)(
+ "%s: a year is the year's instant window, newest first, with no q",
+ (_name, build, sortKey) => {
+ const sent = params(build(search("2019")));
+ expect(sent.q).toBeUndefined();
+ expect(sent.from).toBe("2019-01-01T00:00:00.000Z");
+ expect(sent.to).toBe("2019-12-31T23:59:59.999Z");
+ expect(sent[sortKey]).toBe("desc");
+ },
+ );
+
+ it("labs: a month in the person's language is that month", () => {
+ const sent = params(labPickerUrl(search("März 2019", "de")));
+ expect(sent.q).toBeUndefined();
+ expect(sent.from).toBe("2019-03-01T00:00:00.000Z");
+ expect(sent.to).toBe("2019-03-31T23:59:59.999Z");
+ });
+
+ it("the window follows the person's timezone, DST included", () => {
+ // Berlin: March 2019 starts at UTC+1 and ends at UTC+2.
+ const sent = params(
+ episodePickerUrl(search("03.2019", "de", "Europe/Berlin")),
+ );
+ expect(sent.from).toBe("2019-02-28T23:00:00.000Z");
+ expect(sent.to).toBe("2019-03-31T21:59:59.999Z");
+ });
+
+ it("documents: the day-granular window is the period's two calendar days", () => {
+ const sent = params(documentPickerUrl(search("2019-02")));
+ expect(sent.q).toBeUndefined();
+ expect(sent.from).toBe("2019-02-01");
+ expect(sent.to).toBe("2019-02-28");
+ expect(sent.order).toBe("desc");
+ });
+
+ it("encounters: the route without text search still gets the period", () => {
+ const sent = params(encounterPickerUrl(search("Mar 2019")));
+ expect(sent.q).toBeUndefined();
+ expect(sent.from).toBe("2019-03-01T00:00:00.000Z");
+ expect(sent.to).toBe("2019-03-31T23:59:59.999Z");
+ });
+
+ it.each([labPickerUrl, episodePickerUrl, documentPickerUrl])(
+ "a plain term stays a text search (%#)",
+ (build) => {
+ for (const term of ["LDL", "2019abc", "13.2019", "1850"]) {
+ const sent = params(build(search(term)));
+ expect(sent.q).toBe(term);
+ expect(sent.from).toBeUndefined();
+ expect(sent.to).toBeUndefined();
+ }
+ },
+ );
+
+ it("a month name is read in the UI locale only", () => {
+ // "März" is not an English month, so it stays text.
+ expect(params(labPickerUrl(search("März 2019", "en"))).q).toBe("März 2019");
+ });
+
+ it("the older and newer windows never read the term", () => {
+ const older = params(
+ labPickerUrl({ ...search("2019"), direction: "older", pivot }),
+ );
+ expect(older.to).toBe(pivot);
+ expect(older.from).toBeUndefined();
+ });
+});
diff --git a/src/components/links/__tests__/vault-document-options.test.ts b/src/components/links/__tests__/vault-document-options.test.ts
index 049c915d8..ef065b4a4 100644
--- a/src/components/links/__tests__/vault-document-options.test.ts
+++ b/src/components/links/__tests__/vault-document-options.test.ts
@@ -113,6 +113,8 @@ describe("fetchVaultPage", () => {
pivot: "2026-05-17T23:59:59.999Z",
term: "",
cursor: "z",
+ locale: "en",
+ timeZone: "UTC",
});
expect(page.items.map((d) => d.id)).toEqual(["a"]);
expect(page.next).toBe("a");
diff --git a/tests/integration/visit-link-picker-long-history.test.ts b/tests/integration/visit-link-picker-long-history.test.ts
index b9d286a4d..e67f629dd 100644
--- a/tests/integration/visit-link-picker-long-history.test.ts
+++ b/tests/integration/visit-link-picker-long-history.test.ts
@@ -66,6 +66,8 @@ const DAY_MS = 24 * 60 * 60 * 1000;
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;
+/** What the hook adds to every request: the UI locale and the person's zone. */
+const CTX = { locale: "en", timeZone: "Europe/Berlin" } as const;
async function seedOwner() {
const prisma = getPrismaClient();
@@ -161,6 +163,7 @@ describe("the visit form's lab picker on a 1,200-result record", () => {
pivot,
term: "",
cursor: null,
+ ...CTX,
});
expect(first.results.some((row) => row.id === ferritinId)).toBe(true);
// Nothing after the window's split is on it.
@@ -182,6 +185,7 @@ describe("the visit form's lab picker on a 1,200-result record", () => {
pivot,
term: "",
cursor,
+ ...CTX,
});
for (const row of page.results) {
expect(seen.has(row.id)).toBe(false);
@@ -207,6 +211,7 @@ describe("the visit form's lab picker on a 1,200-result record", () => {
pivot: null,
term,
cursor: null,
+ ...CTX,
});
expect(page.results.map((row) => row.id)).toEqual([ferritinId]);
}
@@ -240,6 +245,7 @@ describe("the visit form's lab picker on a 1,200-result record", () => {
pivot: pickerPivot(VISIT_AT),
term: "",
cursor: null,
+ ...CTX,
}),
);
expect(list.past).toHaveLength(1);
@@ -276,7 +282,13 @@ describe("the condition picker's window and search", () => {
const pivot = pickerPivot(VISIT_AT);
const around = await get>(
listEpisodes as never,
- episodePickerUrl({ direction: "older", pivot, term: "", cursor: null }),
+ episodePickerUrl({
+ direction: "older",
+ pivot,
+ term: "",
+ cursor: null,
+ ...CTX,
+ }),
);
expect(around[0]!.id).toBe(old.id);
@@ -287,6 +299,7 @@ describe("the condition picker's window and search", () => {
pivot: null,
term: "tendin",
cursor: null,
+ ...CTX,
}),
);
expect(found.map((row) => row.id)).toEqual([old.id]);
@@ -297,7 +310,13 @@ describe("the condition picker's window and search", () => {
for (let guard = 0; guard < 5; guard++) {
const page: Array<{ id: string }> = await get(
listEpisodes as never,
- episodePickerUrl({ direction: "newer", pivot, term: "", cursor }),
+ episodePickerUrl({
+ direction: "newer",
+ pivot,
+ term: "",
+ cursor,
+ ...CTX,
+ }),
);
for (const row of page) {
expect(ids.has(row.id)).toBe(false);
diff --git a/tests/integration/visit-link-picker-period-search.test.ts b/tests/integration/visit-link-picker-period-search.test.ts
new file mode 100644
index 000000000..8c11213fa
--- /dev/null
+++ b/tests/integration/visit-link-picker-period-search.test.ts
@@ -0,0 +1,399 @@
+/**
+ * A picker search for a year or a month returns exactly that period.
+ *
+ * Typing "2019" into a visit's link picker used to send `q=2019`, which the
+ * list routes match against names and titles, so filtering by a year found
+ * nothing. A term that names a period is now sent as the route's own date
+ * window. This drives every family's list route with the requests the
+ * pickers build (`picker-requests.ts`), against three years of records, and
+ * walks every page: the answer must be every record in the period and
+ * nothing else, including the records far beyond the first page.
+ *
+ * The expected sets are derived independently of the builders: an instant
+ * belongs to the period its day in the person's zone falls in
+ * (`userDayKey`), a document to the period its stored calendar date is in.
+ * The seeds put records just either side of each boundary in that zone.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+process.env.ENCRYPTION_KEY ??=
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+
+import { NextRequest } from "next/server";
+
+import { GET as listEncounters } from "@/app/api/encounters/route";
+import { GET as listDocuments } from "@/app/api/documents/inbound/route";
+import { GET as listEpisodes } from "@/app/api/illness/episodes/route";
+import { GET as listLabs } from "@/app/api/labs/route";
+import {
+ documentPickerUrl,
+ encounterPickerUrl,
+ episodePickerUrl,
+ labPickerUrl,
+} from "@/components/links/picker-requests";
+import {
+ PICKER_PAGE_SIZE,
+ nextOffset,
+ type PickerPageRequest,
+} from "@/components/links/use-link-picker-pages";
+import { userDayKey } from "@/lib/tz/format";
+
+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 = "period-search-owner";
+const DAY_MS = 24 * 60 * 60 * 1000;
+const TZ = "Europe/Berlin";
+
+/** Every day from 2018-01-01 to 2020-12-31, as UTC-midnight instants. */
+const DAYS = Array.from(
+ { length: 1096 },
+ (_, i) => new Date(Date.UTC(2018, 0, 1) + i * DAY_MS),
+);
+
+/**
+ * Instants just either side of the period boundaries in Berlin: 23:30 and
+ * 00:30 local around New Year 2019 (UTC+1), the start of March 2019 (UTC+1)
+ * and the end of March 2019 (UTC+2, after the DST change), and New Year 2020.
+ */
+const EDGES = [
+ "2018-12-31T22:30:00.000Z",
+ "2018-12-31T23:30:00.000Z",
+ "2019-02-28T22:30:00.000Z",
+ "2019-02-28T23:30:00.000Z",
+ "2019-03-31T21:30:00.000Z",
+ "2019-03-31T22:30:00.000Z",
+ "2019-12-31T22:30:00.000Z",
+ "2019-12-31T23:30:00.000Z",
+].map((iso) => new Date(iso));
+
+const search = (term: string, locale: string): PickerPageRequest => ({
+ direction: "search",
+ pivot: null,
+ term,
+ cursor: null,
+ locale,
+ timeZone: TZ,
+});
+
+async function seedOwner() {
+ const prisma = getPrismaClient();
+ await prisma.user.create({
+ data: {
+ id: OWNER_ID,
+ username: "period-search",
+ email: "period-search@example.test",
+ timezone: TZ,
+ modulePreferencesJson: {
+ labs: true,
+ illness: true,
+ inboundDocuments: 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);
+}
+
+async function get(
+ handler: (request: NextRequest) => Promise,
+ url: string,
+): Promise {
+ const response = await handler(new NextRequest(`http://localhost${url}`));
+ expect(response.status, url).toBe(200);
+ return ((await response.json()) as { data: T }).data;
+}
+
+/** Ids of the instants whose day in the person's zone starts with `prefix`. */
+function expectedIds(
+ rows: ReadonlyArray<{ id: string; at: Date }>,
+ prefix: string,
+): Set {
+ return new Set(
+ rows
+ .filter((row) => userDayKey(row.at, TZ).startsWith(prefix))
+ .map((r) => r.id),
+ );
+}
+
+/** Walk an offset-paged search to its end, newest first, without repeats. */
+async function walkOffsets(
+ fetchPage: (cursor: string | null) => Promise,
+ at: (row: T) => string,
+): Promise<{ ids: Set; pages: number }> {
+ const ids = new Set();
+ let cursor: string | null = null;
+ let pages = 0;
+ let previous = Number.POSITIVE_INFINITY;
+ for (let guard = 0; guard < 30; guard++) {
+ const rows = await fetchPage(cursor);
+ pages += 1;
+ for (const row of rows) {
+ expect(ids.has(row.id), row.id).toBe(false);
+ ids.add(row.id);
+ const when = new Date(at(row)).getTime();
+ expect(when).toBeLessThanOrEqual(previous);
+ previous = when;
+ }
+ cursor = nextOffset(cursor, rows.length);
+ if (cursor === null) break;
+ }
+ return { ids, pages };
+}
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ cookieJar.clear();
+ headerJar.clear();
+ await seedOwner();
+});
+
+describe("lab results: a period search walks exactly the period", () => {
+ async function seed() {
+ const prisma = getPrismaClient();
+ // A date-only reading is stored at noon UTC; the edges carry a time.
+ const instants = [
+ ...DAYS.map((day) => new Date(day.getTime() + 12 * 60 * 60 * 1000)),
+ ...EDGES,
+ ];
+ await prisma.labResult.createMany({
+ data: instants.map((takenAt, i) => ({
+ userId: OWNER_ID,
+ panel: "Metabolic",
+ analyte: `Glucose ${i}`,
+ value: 90,
+ unit: "mg/dL",
+ takenAt,
+ })),
+ });
+ const rows = await prisma.labResult.findMany({
+ where: { userId: OWNER_ID },
+ select: { id: true, takenAt: true },
+ });
+ return rows.map((row) => ({ id: row.id, at: row.takenAt }));
+ }
+
+ const walk = (request: PickerPageRequest) =>
+ walkOffsets(
+ async (cursor) => {
+ const page = await get<{
+ results: Array<{ id: string; takenAt: string }>;
+ }>(listLabs as never, labPickerUrl({ ...request, cursor }));
+ return page.results;
+ },
+ (row) => row.takenAt,
+ );
+
+ it("a year", async () => {
+ const rows = await seed();
+ const expected = expectedIds(rows, "2019-");
+ expect(expected.size).toBe(365 + 6);
+ const { ids, pages } = await walk(search("2019", "en"));
+ expect(pages).toBeGreaterThan(1);
+ expect(ids.size).toBeGreaterThan(PICKER_PAGE_SIZE);
+ expect(ids).toEqual(expected);
+ });
+
+ it.each([
+ ["de", "März 2019"],
+ ["de", "03.2019"],
+ ["en", "Mar 2019"],
+ ["pl", "marzec 2019"],
+ ["ko", "2019년 3월"],
+ ])("a month (%s %j)", async (locale, term) => {
+ const rows = await seed();
+ const expected = expectedIds(rows, "2019-03-");
+ expect(expected.size).toBe(31 + 2);
+ const { ids } = await walk(search(term, locale));
+ expect(ids).toEqual(expected);
+ });
+
+ it("a plain term is still a text search", async () => {
+ await seed();
+ const { ids } = await walk(search("Glucose 7", "en"));
+ // "Glucose 7", "Glucose 70" … "Glucose 799", "Glucose 7xx": names only.
+ expect(ids.size).toBeGreaterThan(0);
+ expect(ids.size).toBeLessThan(200);
+ });
+});
+
+describe("conditions: a period search walks exactly the period", () => {
+ it("a year and a month", async () => {
+ const prisma = getPrismaClient();
+ const onsets = [
+ ...DAYS.filter((_, i) => i % 2 === 0).map(
+ (day) => new Date(day.getTime() + 5 * 60 * 60 * 1000),
+ ),
+ ...EDGES,
+ ];
+ await prisma.illnessEpisode.createMany({
+ data: onsets.map((onsetAt, i) => ({
+ userId: OWNER_ID,
+ label: `Cold ${i}`,
+ type: "INFECTION" as const,
+ onsetAt,
+ resolvedAt: new Date(onsetAt.getTime() + 3 * DAY_MS),
+ })),
+ });
+ const rows = (
+ await prisma.illnessEpisode.findMany({
+ where: { userId: OWNER_ID },
+ select: { id: true, onsetAt: true },
+ })
+ ).map((row) => ({ id: row.id, at: row.onsetAt }));
+
+ const walk = (request: PickerPageRequest) =>
+ walkOffsets(
+ async (cursor) => {
+ const page = await get>(
+ listEpisodes as never,
+ episodePickerUrl({ ...request, cursor }),
+ );
+ return page;
+ },
+ (row) => row.onsetAt,
+ );
+
+ const year = await walk(search("2019", "fr"));
+ expect(year.ids.size).toBeGreaterThan(PICKER_PAGE_SIZE);
+ expect(year.ids).toEqual(expectedIds(rows, "2019-"));
+
+ const month = await walk(search("mars 2019", "fr"));
+ expect(month.ids).toEqual(expectedIds(rows, "2019-03-"));
+ });
+});
+
+describe("visits: the route without text search still searches a period", () => {
+ it("a year and a month", async () => {
+ const prisma = getPrismaClient();
+ const instants = [
+ ...DAYS.map((day) => new Date(day.getTime() + 9 * 60 * 60 * 1000)),
+ ...EDGES,
+ ];
+ await prisma.encounter.createMany({
+ data: instants.map((occurredAt) => ({
+ userId: OWNER_ID,
+ occurredAt,
+ status: "DONE" as const,
+ kind: "ROUTINE" as const,
+ })),
+ });
+ const rows = (
+ await prisma.encounter.findMany({
+ where: { userId: OWNER_ID },
+ select: { id: true, occurredAt: true },
+ })
+ ).map((row) => ({ id: row.id, at: row.occurredAt }));
+
+ const walk = (request: PickerPageRequest) =>
+ walkOffsets(
+ async (cursor) => {
+ const list = await get<{
+ upcoming: Array<{ id: string; occurredAt: string }>;
+ past: Array<{ id: string; occurredAt: string }>;
+ }>(
+ listEncounters as never,
+ encounterPickerUrl({ ...request, cursor }),
+ );
+ expect(list.upcoming).toEqual([]);
+ return list.past;
+ },
+ (row) => row.occurredAt,
+ );
+
+ const year = await walk(search("2019", "it"));
+ expect(year.pages).toBeGreaterThan(1);
+ expect(year.ids).toEqual(expectedIds(rows, "2019-"));
+
+ const month = await walk(search("marzo 2019", "it"));
+ expect(month.ids).toEqual(expectedIds(rows, "2019-03-"));
+ });
+});
+
+describe("documents: the day-granular window is the period's calendar days", () => {
+ it("a year and a month, across keyset pages", async () => {
+ const prisma = getPrismaClient();
+ await prisma.inboundDocument.createMany({
+ data: DAYS.map((day, i) => ({
+ userId: OWNER_ID,
+ kind: "OTHER" as const,
+ filename: `doc-${i}.pdf`,
+ mimeType: "application/pdf",
+ byteSize: 4,
+ status: "STORED" as const,
+ contentEncrypted: Buffer.from("test"),
+ contentCodec: "v1",
+ documentDate: day,
+ })),
+ });
+ const rows = await prisma.inboundDocument.findMany({
+ where: { userId: OWNER_ID },
+ select: { id: true, documentDate: true },
+ });
+ const expected = (prefix: string) =>
+ new Set(
+ rows
+ .filter((row) => row.documentDate!.toISOString().startsWith(prefix))
+ .map((row) => row.id),
+ );
+
+ async function walk(request: PickerPageRequest) {
+ const ids = new Set();
+ let cursor: string | null = null;
+ let pages = 0;
+ for (let guard = 0; guard < 30; guard++) {
+ const page: {
+ documents: Array<{ id: string }>;
+ nextCursor: string | null;
+ } = await get(
+ listDocuments as never,
+ documentPickerUrl({ ...request, cursor }),
+ );
+ pages += 1;
+ for (const doc of page.documents) {
+ expect(ids.has(doc.id)).toBe(false);
+ ids.add(doc.id);
+ }
+ cursor = page.nextCursor;
+ if (cursor === null) break;
+ }
+ return { ids, pages };
+ }
+
+ const year = await walk(search("2019", "es"));
+ expect(year.pages).toBeGreaterThan(1);
+ expect(year.ids).toEqual(expected("2019-"));
+ expect(year.ids.size).toBe(365);
+
+ const month = await walk(search("marzo de 2019", "es"));
+ expect(month.ids).toEqual(expected("2019-03-"));
+ expect(month.ids.size).toBe(31);
+ });
+});