From e16c9fb71793bb811059b6666dbf3e6811c6462e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:11:49 +0200 Subject: [PATCH] feat(links): search a year or a month in the link pickers by its date window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A picker term that names a period ("2019", "03.2019", "2019-03", "März 2019", "2019년 3월") is sent as the list route's from/to window instead of q, newest first, on every family including the visit list, which has no text search. Month names come from Intl for the UI locale, folded for case, accents and umlaut spellings. Instant routes get the period's bounds in the person's timezone; the document route gets the period's calendar days. The sheet names the period it shows. --- messages/de.json | 5 +- messages/en.json | 5 +- messages/es.json | 5 +- messages/fr.json | 5 +- messages/it.json | 5 +- messages/ko.json | 5 +- messages/pl.json | 5 +- src/components/links/entity-link-picker.tsx | 24 +- src/components/links/period-query.ts | 207 ++++++++++++++++++ src/components/links/picker-requests.ts | 80 ++++++- src/components/links/use-link-picker-pages.ts | 73 +++++- 11 files changed, 394 insertions(+), 25 deletions(-) create mode 100644 src/components/links/period-query.ts 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.