mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
feat(links): search a year or a month in the link pickers by its date window
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.
This commit is contained in:
1 parent
f75a2fe343
commit
e16c9fb717
11 files changed
+394
-25
No files matched your search
+4
-1
@@ -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": {
|
||||
|
||||
+4
-1
@@ -12227,7 +12227,10 @@
|
||||
"removed": "Unlinked {name}",
|
||||
"linked": "Linked",
|
||||
"loadNewer": "Load newer",
|
||||
"loadOlder": "Load older"
|
||||
"loadOlder": "Load older",
|
||||
"period": {
|
||||
"showing": "Showing {period}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bodySites": {
|
||||
|
||||
+4
-1
@@ -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": {
|
||||
|
||||
+4
-1
@@ -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": {
|
||||
|
||||
+4
-1
@@ -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": {
|
||||
|
||||
+4
-1
@@ -12227,7 +12227,10 @@
|
||||
"removed": "{name} 연결 해제됨",
|
||||
"linked": "연결됨",
|
||||
"loadNewer": "최근 항목 더 보기",
|
||||
"loadOlder": "이전 항목 더 보기"
|
||||
"loadOlder": "이전 항목 더 보기",
|
||||
"period": {
|
||||
"showing": "{period} 표시 중"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bodySites": {
|
||||
|
||||
+4
-1
@@ -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": {
|
||||
|
||||
@@ -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({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{server?.period ? (
|
||||
<p
|
||||
className="text-muted-foreground px-1 text-xs"
|
||||
data-slot={`${slot}-period`}
|
||||
>
|
||||
{t("links.picker.period.showing", {
|
||||
period: formatPeriod(server.period, locale),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{server?.searchPending && groups.length === 0 ? (
|
||||
<div
|
||||
className="flex justify-center py-6"
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* A picker search term that names a calendar period: a year, or a month of a
|
||||
* year.
|
||||
*
|
||||
* A person looking for "the lab panel from March 2019" types the date, not an
|
||||
* analyte. The list routes match `q` against names and titles only, so "2019"
|
||||
* or "03.2019" found nothing, or only whatever happened to carry those digits
|
||||
* in its name. A term that parses here is sent as the route's own `from` /
|
||||
* `to` window instead, so the answer is every record in that period, however
|
||||
* far back it sits.
|
||||
*
|
||||
* Accepted, after folding case, accents and the umlaut spellings "ae", "oe",
|
||||
* "ue" (so "März", "Maerz" and "mär" agree):
|
||||
*
|
||||
* - a year: "2019" (Korean "2019년");
|
||||
* - a numeric month and year, either order, separated by ".", "/", "-" or a
|
||||
* space: "03.2019", "3.2019", "03/2019", "2019-03", "2019/03", "2019.03",
|
||||
* Korean "2019년 3월" / "2019. 3.";
|
||||
* - a month name and a year, either order, in the person's UI locale: the
|
||||
* full name, the short name, the genitive form where the language has one
|
||||
* (Polish "marca"), or any prefix of at least three letters that names one
|
||||
* month only ("sept", "paź"; "jui" is both juin and juillet, so it is not
|
||||
* a period). Spanish "de" may join the two ("marzo de 2019").
|
||||
*
|
||||
* The month names come from `Intl.DateTimeFormat` for the locale rather than a
|
||||
* table kept by hand, so they read the way the app's own dates are rendered.
|
||||
*
|
||||
* A year before {@link MIN_PERIOD_YEAR} or more than one year after the
|
||||
* current one is not a period ("1850" stays a text search), and neither is a
|
||||
* month outside 1 to 12 ("13.2019") or a term with anything else in it
|
||||
* ("2019abc", "LDL 2019").
|
||||
*
|
||||
* Pure: no provider, no clock unless the caller omits `now`.
|
||||
*/
|
||||
import { foldForMatch } from "@/lib/i18n/fold-for-match";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
|
||||
/** The earliest year a period search accepts. */
|
||||
export const MIN_PERIOD_YEAR = 1900;
|
||||
|
||||
export interface PeriodQuery {
|
||||
year: number;
|
||||
/** 1 to 12, or null for the whole year. */
|
||||
month: number | null;
|
||||
/** First calendar day of the period, `YYYY-MM-DD`. */
|
||||
fromDay: string;
|
||||
/** Last calendar day of the period, `YYYY-MM-DD` (inclusive). */
|
||||
toDay: string;
|
||||
}
|
||||
|
||||
/** Words that may stand between a month name and its year. */
|
||||
const CONNECTORS: Record<string, readonly string[]> = {
|
||||
es: ["de", "del"],
|
||||
it: ["del"],
|
||||
};
|
||||
|
||||
const nameCache = new Map<string, ReadonlyArray<[string, number]>>();
|
||||
|
||||
/** 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}`;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<T extends { id: string }>({
|
||||
*/
|
||||
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<T extends { id: string }>({
|
||||
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<T extends { id: string }>({
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user