diff --git a/messages/de.json b/messages/de.json
index 5e17d1bf6..2abf8465c 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -12227,7 +12227,10 @@
"removed": "Verknüpfung mit {name} gelöst",
"linked": "Verknüpft",
"loadNewer": "Neuere laden",
- "loadOlder": "Ältere laden"
+ "loadOlder": "Ältere laden",
+ "period": {
+ "showing": "Zeitraum: {period}"
+ }
}
},
"bodySites": {
diff --git a/messages/en.json b/messages/en.json
index b08ee23b9..46880af6d 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -12227,7 +12227,10 @@
"removed": "Unlinked {name}",
"linked": "Linked",
"loadNewer": "Load newer",
- "loadOlder": "Load older"
+ "loadOlder": "Load older",
+ "period": {
+ "showing": "Showing {period}"
+ }
}
},
"bodySites": {
diff --git a/messages/es.json b/messages/es.json
index 9bf9001d7..112c33f1d 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -12227,7 +12227,10 @@
"removed": "Se desvinculó {name}",
"linked": "Vinculados",
"loadNewer": "Cargar más recientes",
- "loadOlder": "Cargar anteriores"
+ "loadOlder": "Cargar anteriores",
+ "period": {
+ "showing": "Mostrando {period}"
+ }
}
},
"bodySites": {
diff --git a/messages/fr.json b/messages/fr.json
index 878a0384b..6177b0427 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -12227,7 +12227,10 @@
"removed": "{name} dissocié",
"linked": "Liés",
"loadNewer": "Charger les plus récents",
- "loadOlder": "Charger les plus anciens"
+ "loadOlder": "Charger les plus anciens",
+ "period": {
+ "showing": "Période : {period}"
+ }
}
},
"bodySites": {
diff --git a/messages/it.json b/messages/it.json
index 2c3c7df29..3749f474f 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -12227,7 +12227,10 @@
"removed": "Collegamento a {name} rimosso",
"linked": "Collegati",
"loadNewer": "Carica più recenti",
- "loadOlder": "Carica meno recenti"
+ "loadOlder": "Carica meno recenti",
+ "period": {
+ "showing": "Periodo: {period}"
+ }
}
},
"bodySites": {
diff --git a/messages/ko.json b/messages/ko.json
index 1d65cae91..4d17c9756 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -12227,7 +12227,10 @@
"removed": "{name} 연결 해제됨",
"linked": "연결됨",
"loadNewer": "최근 항목 더 보기",
- "loadOlder": "이전 항목 더 보기"
+ "loadOlder": "이전 항목 더 보기",
+ "period": {
+ "showing": "{period} 표시 중"
+ }
}
},
"bodySites": {
diff --git a/messages/pl.json b/messages/pl.json
index 6093866f9..87fa0206b 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -12227,7 +12227,10 @@
"removed": "Odłączono: {name}",
"linked": "Połączone",
"loadNewer": "Wczytaj nowsze",
- "loadOlder": "Wczytaj starsze"
+ "loadOlder": "Wczytaj starsze",
+ "period": {
+ "showing": "Okres: {period}"
+ }
}
},
"bodySites": {
diff --git a/src/components/links/entity-link-picker.tsx b/src/components/links/entity-link-picker.tsx
index 919dfd9e7..00d26c098 100644
--- a/src/components/links/entity-link-picker.tsx
+++ b/src/components/links/entity-link-picker.tsx
@@ -53,6 +53,8 @@ import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
+import { formatPeriod, type PeriodQuery } from "./period-query";
+
export interface EntityLinkOption {
id: string;
label: string;
@@ -110,6 +112,8 @@ export interface EntityLinkServerPaging {
* here instead of being sent.
*/
filterLocally?: boolean;
+ /** The term names a year or a month, and the list is that period. */
+ period?: PeriodQuery | null;
}
/**
@@ -127,6 +131,7 @@ export function serverPagingFrom(pages: {
loadOlder: () => void;
loadNewer: () => void;
filterLocally: boolean;
+ period: PeriodQuery | null;
}): EntityLinkServerPaging {
return {
term: pages.term,
@@ -139,6 +144,7 @@ export function serverPagingFrom(pages: {
onOlder: pages.loadOlder,
onNewer: pages.loadNewer,
filterLocally: pages.filterLocally,
+ period: pages.period,
};
}
@@ -278,7 +284,7 @@ export function EntityLinkPicker({
*/
knownOptions?: EntityLinkOption[];
}) {
- const { t } = useTranslations();
+ const { t, locale } = useTranslations();
const [open, setOpen] = useState(false);
const [localTerm, setLocalTerm] = useState("");
const term = server ? server.term : localTerm;
@@ -308,7 +314,10 @@ export function EntityLinkPicker({
// 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);
+ // A period is answered by the route's date window, never by the digits.
+ server && (!server.filterLocally || server.period)
+ ? 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));
@@ -486,6 +495,17 @@ export function EntityLinkPicker({
/>
+ {server?.period ? (
+
+ {t("links.picker.period.showing", {
+ period: formatPeriod(server.period, locale),
+ })}
+
+ ) : null}
+
{server?.searchPending && groups.length === 0 ? (
= {
+ es: ["de", "del"],
+ it: ["del"],
+};
+
+const nameCache = new Map>();
+
+/** Spell German umlauts the way a keyboard without them does. */
+function umlautSpelling(lower: string): string {
+ return lower.replace(/ä/gu, "ae").replace(/ö/gu, "oe").replace(/ü/gu, "ue");
+}
+
+/**
+ * Every folded spelling of every month in `locale`, paired with its month
+ * number: long, short and (where it differs) genitive, each also with its
+ * umlauts spelled out.
+ */
+function monthNames(locale: string): ReadonlyArray<[string, number]> {
+ const cached = nameCache.get(locale);
+ if (cached) return cached;
+ const names: Array<[string, number]> = [];
+ const add = (raw: string, month: number) => {
+ const lower = raw.toLowerCase().replace(/\./gu, "");
+ for (const spelling of [lower, umlautSpelling(lower)]) {
+ const folded = foldForMatch(spelling);
+ if (folded && !names.some(([n, m]) => n === folded && m === month)) {
+ names.push([folded, month]);
+ }
+ }
+ };
+ let long: Intl.DateTimeFormat;
+ let short: Intl.DateTimeFormat;
+ let genitive: Intl.DateTimeFormat;
+ try {
+ long = new Intl.DateTimeFormat(locale, { month: "long", timeZone: "UTC" });
+ short = new Intl.DateTimeFormat(locale, {
+ month: "short",
+ timeZone: "UTC",
+ });
+ genitive = new Intl.DateTimeFormat(locale, {
+ month: "long",
+ day: "numeric",
+ timeZone: "UTC",
+ });
+ } catch {
+ nameCache.set(locale, names);
+ return names;
+ }
+ for (let month = 1; month <= 12; month += 1) {
+ const day = new Date(Date.UTC(2019, month - 1, 15));
+ add(long.format(day), month);
+ add(short.format(day), month);
+ const part = genitive
+ .formatToParts(day)
+ .find((p) => p.type === "month")?.value;
+ if (part) add(part, month);
+ }
+ nameCache.set(locale, names);
+ return names;
+}
+
+/** The month a folded word names in `locale`, or null. */
+function monthFromName(word: string, locale: string): number | null {
+ const names = monthNames(locale);
+ const exact = names.find(([name]) => name === word);
+ if (exact) return exact[1];
+ if (word.length < 3 || !/^\p{L}+$/u.test(word)) return null;
+ const months = new Set(
+ names.filter(([name]) => name.startsWith(word)).map(([, month]) => month),
+ );
+ return months.size === 1 ? [...months][0] : null;
+}
+
+function plausibleYear(word: string, now: Date): number | null {
+ if (!/^\d{4}$/u.test(word)) return null;
+ const year = Number(word);
+ if (year < MIN_PERIOD_YEAR || year > now.getUTCFullYear() + 1) return null;
+ return year;
+}
+
+function numericMonth(word: string): number | null {
+ if (!/^\d{1,2}$/u.test(word)) return null;
+ const month = Number(word);
+ return month >= 1 && month <= 12 ? month : null;
+}
+
+function pad(value: number): string {
+ return String(value).padStart(2, "0");
+}
+
+function period(year: number, month: number | null): PeriodQuery {
+ if (month === null) {
+ return { year, month, fromDay: `${year}-01-01`, toDay: `${year}-12-31` };
+ }
+ const fromDay = `${year}-${pad(month)}-01`;
+ const nextMonth =
+ month === 12 ? `${year + 1}-01-01` : `${year}-${pad(month + 1)}-01`;
+ return { year, month, fromDay, toDay: shiftDateKey(nextMonth, -1) };
+}
+
+/** The base language of a locale tag: "de-AT" → "de". */
+function language(locale: string): string {
+ return locale.toLowerCase().split(/[-_]/u)[0] ?? "";
+}
+
+/**
+ * The period `term` names in `locale`, or null when it is not one (and the
+ * picker keeps its text search).
+ */
+export function parsePeriodQuery(
+ term: string,
+ locale: string,
+ now: Date = new Date(),
+): PeriodQuery | null {
+ const lang = language(locale);
+ let text = term.toLowerCase();
+ // Korean writes the units after the numbers: "2019년 3월".
+ if (lang === "ko") {
+ text = text.replace(/(\d)\s*년/gu, "$1 ").replace(/(\d)\s*월/gu, "$1 ");
+ }
+ const connectors = CONNECTORS[lang] ?? [];
+ const words = foldForMatch(text.replace(/[.,]/gu, " "))
+ .split(" ")
+ .filter((word) => word.length > 0 && !connectors.includes(word));
+
+ if (words.length === 1) {
+ const year = plausibleYear(words[0], now);
+ return year === null ? null : period(year, null);
+ }
+ if (words.length !== 2) return null;
+
+ for (const [first, second] of [
+ [words[0], words[1]],
+ [words[1], words[0]],
+ ]) {
+ const year = plausibleYear(second, now);
+ if (year === null) continue;
+ const month = numericMonth(first) ?? monthFromName(first, locale);
+ if (month !== null) return period(year, month);
+ }
+ return null;
+}
+
+/** The period as the app names it in `locale`: "2019", "März 2019". */
+export function formatPeriod(value: PeriodQuery, locale: string): string {
+ if (value.month === null) return String(value.year);
+ try {
+ return new Intl.DateTimeFormat(locale, {
+ month: "long",
+ year: "numeric",
+ timeZone: "UTC",
+ }).format(new Date(Date.UTC(value.year, value.month - 1, 15)));
+ } catch {
+ return `${pad(value.month)}.${value.year}`;
+ }
+}
diff --git a/src/components/links/picker-requests.ts b/src/components/links/picker-requests.ts
index 2d4be8381..0e93009a7 100644
--- a/src/components/links/picker-requests.ts
+++ b/src/components/links/picker-requests.ts
@@ -11,7 +11,28 @@
* `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.
+ *
+ * A search term that names a year or a month ("2019", "03.2019", "März 2019",
+ * see {@link parsePeriodQuery}) is not sent as `q`: it becomes the route's
+ * own `from` / `to` window over that period, newest first, so the answer is
+ * every record dated in it rather than the records whose name contains the
+ * digits. The visit route, which has no text search at all, gets the same
+ * window.
+ *
+ * The period is a calendar period as the person reads it. The routes over an
+ * instant (lab draw, condition onset, visit) get the first instant of the
+ * period's first day and the last instant of its last day in the person's
+ * timezone, the zone every date in the picker is rendered in, so a visit at
+ * 00:30 on 1 March is in March for the person who sees it dated 1 March. A
+ * date-only lab reading is stored at noon UTC, which reads as its own date in
+ * every zone from UTC-11 to UTC+11. The document route's window is already
+ * day-granular and inclusive over a stored calendar date, so it gets the two
+ * day keys as they are.
*/
+import { startOfLocalDayKey } from "@/lib/tz/local-day";
+import { shiftDateKey } from "@/lib/tz/format";
+
+import { parsePeriodQuery, type PeriodQuery } from "./period-query";
import {
PICKER_PAGE_SIZE,
afterPivot,
@@ -22,6 +43,44 @@ function withParams(path: string, params: URLSearchParams): string {
return `${path}?${params.toString()}`;
}
+/** The period a search request names, or null outside a period search. */
+export function requestPeriod(request: PickerPageRequest): PeriodQuery | null {
+ if (request.direction !== "search") return null;
+ return parsePeriodQuery(request.term, request.locale);
+}
+
+/**
+ * The instant window of a period in `timeZone`: from the first instant of its
+ * first day to the last millisecond before the day after its last, both
+ * inclusive as the instant routes read them.
+ */
+export function periodInstants(
+ period: PeriodQuery,
+ timeZone: string,
+): { from: string; to: string } {
+ const from = startOfLocalDayKey(period.fromDay, timeZone);
+ const next = startOfLocalDayKey(shiftDateKey(period.toDay, 1), timeZone);
+ return {
+ from: from.toISOString(),
+ to: new Date(next.getTime() - 1).toISOString(),
+ };
+}
+
+/** `q`, or the period's instant window, for a search on an instant route. */
+function setInstantSearch(
+ params: URLSearchParams,
+ request: PickerPageRequest,
+): void {
+ const period = requestPeriod(request);
+ if (period) {
+ const window = periodInstants(period, request.timeZone);
+ params.set("from", window.from);
+ params.set("to", window.to);
+ } else {
+ params.set("q", request.term);
+ }
+}
+
/** `GET /api/labs` — offset paging, `from` / `to` on the draw instant. */
export function labPickerUrl(request: PickerPageRequest): string {
const params = new URLSearchParams({
@@ -29,7 +88,7 @@ export function labPickerUrl(request: PickerPageRequest): string {
offset: request.cursor ?? "0",
sortDir: request.direction === "newer" ? "asc" : "desc",
});
- if (request.direction === "search") params.set("q", request.term);
+ if (request.direction === "search") setInstantSearch(params, request);
else if (request.pivot !== null) {
if (request.direction === "older") params.set("to", request.pivot);
else params.set("from", afterPivot(request.pivot));
@@ -45,7 +104,7 @@ export function episodePickerUrl(request: PickerPageRequest): string {
offset: request.cursor ?? "0",
sortDir: request.direction === "newer" ? "asc" : "desc",
});
- if (request.direction === "search") params.set("q", request.term);
+ if (request.direction === "search") setInstantSearch(params, request);
else if (request.pivot !== null) {
if (request.direction === "older") params.set("to", request.pivot);
else params.set("from", afterPivot(request.pivot));
@@ -65,7 +124,11 @@ export function documentPickerUrl(request: PickerPageRequest): string {
limit: String(PICKER_PAGE_SIZE),
});
if (request.cursor) params.set("cursor", request.cursor);
- if (request.direction === "search") params.set("q", request.term);
+ const period = requestPeriod(request);
+ if (period) {
+ params.set("from", period.fromDay);
+ params.set("to", period.toDay);
+ } else 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));
@@ -80,15 +143,20 @@ export function documentPickerUrl(request: PickerPageRequest): string {
* `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.
+ * The visit route has no text search, so a plain term never reaches it (the
+ * picker filters what it has loaded); a period search reads that period.
*/
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) {
+ const period = requestPeriod(request);
+ if (period) {
+ const window = periodInstants(period, request.timeZone);
+ params.set("from", window.from);
+ params.set("to", window.to);
+ } else if (request.direction !== "search" && request.pivot !== null) {
if (request.direction === "older") params.set("to", request.pivot);
else params.set("from", afterPivot(request.pivot));
}
diff --git a/src/components/links/use-link-picker-pages.ts b/src/components/links/use-link-picker-pages.ts
index 4a0b13f7e..cb5807b57 100644
--- a/src/components/links/use-link-picker-pages.ts
+++ b/src/components/links/use-link-picker-pages.ts
@@ -22,8 +22,11 @@
* 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.
+ * (`q`) so a match is found wherever it sits in the history. A term that names
+ * a year or a month ("2019", "03.2019", "März 2019") searches that period
+ * instead, through the route's date window, on every family including the
+ * visit list that has no text search (`parsePeriodQuery`). 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.
@@ -33,6 +36,9 @@ import { useInfiniteQuery } from "@tanstack/react-query";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { ENCOUNTER_SUGGEST_WINDOW_DAYS } from "@/lib/encounters/suggest-window";
+import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context";
+
+import { parsePeriodQuery, type PeriodQuery } from "./period-query";
export type PickerDirection = "older" | "newer" | "search";
@@ -50,6 +56,10 @@ export interface PickerPageRequest {
term: string;
/** The family's own cursor (an offset or a row id), null for page one. */
cursor: string | null;
+ /** The UI locale, which names the months a period search understands. */
+ locale: string;
+ /** The person's timezone, where a period's days begin and end. */
+ timeZone: string;
}
/** Records per page, the same for every family. */
@@ -106,17 +116,41 @@ export function useLinkPickerPages({
*/
serverSearch?: boolean;
}) {
+ const { locale } = useTranslations();
+ const timeZone = useDisplayTimezone();
const [rawTerm, setTerm] = useState("");
const debounced = useDebouncedValue(rawTerm.trim(), SEARCH_DEBOUNCE_MS);
- const term = serverSearch ? debounced : "";
+ // A period goes to the route as a date window, which every family's route
+ // has, so it is searched on the server even where text is not.
+ const period: PeriodQuery | null = useMemo(
+ () => parsePeriodQuery(debounced, locale),
+ [debounced, locale],
+ );
+ const typedPeriod = useMemo(
+ () => parsePeriodQuery(rawTerm.trim(), locale) !== null,
+ [rawTerm, locale],
+ );
+ const term = serverSearch || period !== null ? debounced : "";
const searching = term.length > 0;
+ // "März 2019" and "03.2019" are one request; the key says which period, in
+ // which zone, rather than how it was typed.
+ const searchKey = period
+ ? `period:${period.fromDay}..${period.toDay}@${timeZone}`
+ : term;
const older = useInfiniteQuery({
queryKey: queryKey("older", pivot, ""),
enabled: enabled && !searching,
initialPageParam: null as string | null,
queryFn: ({ pageParam }) =>
- fetchPage({ direction: "older", pivot, term: "", cursor: pageParam }),
+ fetchPage({
+ direction: "older",
+ pivot,
+ term: "",
+ cursor: pageParam,
+ locale,
+ timeZone,
+ }),
getNextPageParam: (last) => last.next ?? undefined,
});
@@ -131,16 +165,30 @@ export function useLinkPickerPages({
enabled: enabled && !searching && newerRequested,
initialPageParam: null as string | null,
queryFn: ({ pageParam }) =>
- fetchPage({ direction: "newer", pivot, term: "", cursor: pageParam }),
+ fetchPage({
+ direction: "newer",
+ pivot,
+ term: "",
+ cursor: pageParam,
+ locale,
+ timeZone,
+ }),
getNextPageParam: (last) => last.next ?? undefined,
});
const search = useInfiniteQuery({
- queryKey: queryKey("search", null, term),
+ queryKey: queryKey("search", null, searchKey),
enabled: enabled && searching,
initialPageParam: null as string | null,
queryFn: ({ pageParam }) =>
- fetchPage({ direction: "search", pivot: null, term, cursor: pageParam }),
+ fetchPage({
+ direction: "search",
+ pivot: null,
+ term,
+ cursor: pageParam,
+ locale,
+ timeZone,
+ }),
getNextPageParam: (last) => last.next ?? undefined,
});
@@ -171,10 +219,15 @@ export function useLinkPickerPages({
setTerm,
/** A search is in flight (typed, or its first page not back yet). */
searchPending:
- serverSearch &&
+ (serverSearch || typedPeriod || period !== null) &&
(rawTerm.trim() !== term || (searching && search.isPending)),
- /** The picker filters the loaded options by the term itself. */
- filterLocally: !serverSearch,
+ /**
+ * The picker filters the loaded options by the term itself: a route
+ * without text search, and a term that is not a period.
+ */
+ filterLocally: !serverSearch && !typedPeriod,
+ /** The period the list is showing, when the term names one. */
+ period: searching ? period : null,
hasOlder: searching ? search.hasNextPage : older.hasNextPage,
// Unknown until asked: offered once for every dated record, then only
// while the server reports another page.