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.