Gates / test (push) Successful in 1m40s
Gates / package (push) Successful in 3m58s
Gates / container (push) Successful in 14s
CI / gates (push) Successful in 12m51s
Gates / frontend (push) Successful in 1m18s
Gates / test-aarch64 (push) Successful in 6m57s
query log, live and lookup merge into /activity. history filters live in the url, so a pasted link or back/forward reproduces the exact view; the result column separates servfail and nxdomain from success in the list. live is follow-by-default with freeze, and a streamed row opens its in-memory provenance detail — no correlation invented for rows sqlite has not written. lookup survives as the current policy simulation under /activity/test. investigation links carry absolute bounds, and the diagnostics page now honors since/until instead of ignoring them. the old routes are gone without aliases.
96 lines
4.0 KiB
TypeScript
96 lines
4.0 KiB
TypeScript
/**
|
|
* 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 };
|
|
}
|