/** * The bridge between a `datetime-local` input and the unix seconds the URL and * the API speak. * * Local wall-clock text is lossy in a way unix seconds are not. Twice a year a * fall-back fold gives two instants the same text, and a spring-forward gap * gives an hour of text no instant at all. So the text is never the authority: * a bound the operator did not touch is carried through as the number it * already was, and a bound they did edit is accepted only when it survives a * round trip unchanged. */ /** Zero-padded to the width the `datetime-local` grammar requires. */ function pad(value: number, width: number): string { return String(value).padStart(width, "0"); } /** * Unix seconds → the local wall-clock text a `datetime-local` input holds, * always with seconds, because the inputs run at `step={1}`. */ export function unixToDatetimeLocal(unix: number): string { const date = new Date(unix * 1000); const day = `${pad(date.getFullYear(), 4)}-${pad(date.getMonth() + 1, 2)}-${pad(date.getDate(), 2)}`; const time = `${pad(date.getHours(), 2)}:${pad(date.getMinutes(), 2)}:${pad(date.getSeconds(), 2)}`; return `${day}T${time}`; } const DATETIME_LOCAL = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,3})?)?$/; /** * The text with its seconds spelled out, or undefined when it is not a * `datetime-local` value at all. A browser omits `:00` seconds even at * `step={1}`, so the canonical form is what a round trip compares against. * * The grammar allows a fractional part after the seconds and some engines emit * one; a bound is a whole second here and on the wire, so it is parsed and then * dropped rather than treated as text we do not recognise. */ function canonicalize(value: string): string | undefined { const match = DATETIME_LOCAL.exec(value); if (match === null) return undefined; return `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6] ?? "00"}`; } /** Local wall-clock text → unix seconds, or undefined when it names no instant. */ export function datetimeLocalToUnix(value: string): number | undefined { const canonical = canonicalize(value); if (canonical === undefined) return undefined; const ms = new Date(canonical).getTime(); return Number.isFinite(ms) ? Math.floor(ms / 1000) : undefined; } /** * One bound of the filter form: what the input shows, what the applied search * carried, and whether the operator has touched it since. */ export interface DatetimeField { text: string; /** The applied value this field was seeded from, reused while `dirty` is false. */ original: number | undefined; dirty: boolean; } export type DatetimeResolution = { ok: true; value: number | undefined } | { ok: false; reason: "unparseable" | "nonexistent" }; export function datetimeField(original: number | undefined): DatetimeField { return { text: original === undefined ? "" : unixToDatetimeLocal(original), original, dirty: false }; } export function editDatetimeField(field: DatetimeField, text: string): DatetimeField { return { ...field, text, dirty: true }; } /** * The unix value this bound applies. * * An untouched field resolves to the number it was seeded with, never to a * re-parse of its own text: the text of a fall-back instant names two of them, * and re-parsing would silently move a bound the operator never edited. * * An edited field is parsed, then formatted back. A wall-clock time inside the * spring-forward gap exists on no clock, and `Date` quietly slides it forward * an hour; the round trip catches that and the caller reports it instead of * filtering on an hour nobody asked for. */ export function resolveDatetimeField(field: DatetimeField): DatetimeResolution { if (!field.dirty) return { ok: true, value: field.original }; if (field.text.trim() === "") return { ok: true, value: undefined }; const unix = datetimeLocalToUnix(field.text); if (unix === undefined) return { ok: false, reason: "unparseable" }; if (unixToDatetimeLocal(unix) !== canonicalize(field.text)) return { ok: false, reason: "nonexistent" }; return { ok: true, value: unix }; }