agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
344 lines • 14.1 kB
JavaScript
/**
* time/zone — IANA zone names and offset arithmetic, through `Intl`.
*
* Pattern: the platform's tz database behind two small questions — "what is
* the offset of this zone at this instant?" and "which instant is
* this wall time in this zone?" — with Temporal's four DST words for
* the second. No dependency: `Intl.DateTimeFormat` carries the
* database the runtime ships.
* Role: L0 leaf (the time layer's one owner). Imports `instant.ts` only.
* Emits: N/A.
*
* ## A zone is an IANA name, never an abbreviation
*
* `Intl` is more forgiving than this layer: it accepts `PST` (and reads it as
* `America/Los_Angeles`, whose October offset is -07:00, not PST's -08:00),
* `EST` (as `America/Panama`), and a bare offset such as `+05:30`. Each of
* those turns a person's words into a zone without saying so, so
* {@link isZoneName} refuses them: a name is accepted when it has an
* `Area/Location` shape (`America/Los_Angeles`, `Etc/GMT+5`) or is `UTC` /
* `GMT`, AND `Intl` knows it. An abbreviation reaches a zone only through a
* recorded map (a later step), never here.
*
* ## Cost — one `formatToParts` per (zone, instant), memoised
*
* Asking `Intl` is the whole cost of this module (a `formatToParts` is
* microseconds; everything else is arithmetic), and a resolution asks the
* same instants again: every candidate a DST probe validates is then spelled
* with its offset. So the formatter is built once per zone and the reading of
* each (zone, instant) is kept — the tz database cannot change inside a
* process — in a bounded memo cleared when full, like the formatter cache.
* `readWall` also skips the one probe it can prove redundant: when the offset
* a day before, at, and a day after the wall time agree, no change falls in
* that window, so the one candidate needs no check. What this buys is pinned
* as a COUNT by the performance case of test/time/resolve.test.ts, not a
* duration.
*
* ## DST — the four words
*
* | Wall time | `compatible` | `earlier` | `later` | `reject` |
* |-----------|--------------|-----------|---------|----------|
* | exists once | it | it | it | it |
* | exists twice (fall back, `01:30`) | the first | the first | the second | `undefined` |
* | never exists (spring forward, `02:30`) | shifted forward by the gap (`03:30`) | shifted back (`01:30`) | shifted forward | `undefined` |
*
* @example
* ```ts
* isZoneName('America/Los_Angeles'); // true
* isZoneName('PST'); // false — an abbreviation
* offsetAt('America/Los_Angeles', Date.parse('2026-10-09T15:00:00Z')); // -420
* readWall({ year: 2026, month: 11, day: 1, hour: 1, minute: 30 }, 'America/Los_Angeles');
* // { kind: 'overlap', earlier: …08:30Z, later: …09:30Z }
* ```
*/
import { daysInMonth, utcWallMs } from './instant.js';
/** `Area/Location` (one or more `/`), or the two single-word names that are not abbreviations of anything. */
const IANA_SHAPE = /^(?:[A-Za-z][A-Za-z0-9_+-]*(?:\/[A-Za-z0-9_+-]+)+|UTC|GMT)$/;
const MAX_NAME = 64;
const MAX_CACHED = 256;
const formatters = new Map();
/** The cached formatter for `zone`, or `undefined` when `Intl` does not know it. Bounded: the cache is cleared when full. */
function formatterFor(zone) {
const hit = formatters.get(zone);
if (hit !== undefined)
return hit;
let formatter;
try {
formatter = new Intl.DateTimeFormat('en-US', {
timeZone: zone,
hourCycle: 'h23',
era: 'short',
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
});
}
catch {
return undefined;
}
if (formatters.size >= MAX_CACHED)
formatters.clear();
formatters.set(zone, formatter);
return formatter;
}
/** Whether `value` is an IANA zone name this runtime's tz database knows — see the module header for what is refused. */
export function isZoneName(value) {
if (typeof value !== 'string' || value.length > MAX_NAME || !IANA_SHAPE.test(value))
return false;
return formatterFor(value) !== undefined;
}
/** The runtime's canonical spelling of a zone (`america/los_angeles` → `America/Los_Angeles`), or `undefined` when it is not a zone name. */
export function canonicalZone(value) {
if (!isZoneName(value))
return undefined;
return formatterFor(value).resolvedOptions().timeZone;
}
/** The wall time an instant shows in `zone` (milliseconds kept). Frozen: the reading is shared through the memo. */
export function wallAt(zone, ms) {
return shownWall(zoneFormatter(zone, 'wallAt'), zone, ms);
}
const MAX_READINGS = 4096;
/** zone → instant → the wall time `Intl` shows there; `readingCount` bounds the whole memo. */
const readings = new Map();
let readingCount = 0;
/** The memoised reading of `ms` in `zone` (the module header's cost note); `formatter` is `zone`'s, already checked. */
function shownWall(formatter, zone, ms) {
const hit = readings.get(zone)?.get(ms);
if (hit !== undefined)
return hit;
const wall = Object.freeze(formatWall(formatter, ms));
if (readingCount >= MAX_READINGS) {
readings.clear();
readingCount = 0;
}
let byInstant = readings.get(zone);
if (byInstant === undefined) {
byInstant = new Map();
readings.set(zone, byInstant);
}
byInstant.set(ms, wall);
readingCount += 1;
return wall;
}
/** One `Intl` reading — the only call in this module that costs. */
function formatWall(formatter, ms) {
const parts = {};
for (const part of formatter.formatToParts(new Date(ms)))
parts[part.type] = part.value;
const shownYear = Number(parts.year);
return {
// `era: 'short'` spells year 0 as '1 BC': astronomical year = 1 - shown.
year: parts.era === 'BC' || parts.era === 'B' ? 1 - shownYear : shownYear,
month: Number(parts.month),
day: Number(parts.day),
hour: Number(parts.hour),
minute: Number(parts.minute),
second: Number(parts.second),
millisecond: ((ms % 1000) + 1000) % 1000,
};
}
function zoneFormatter(zone, caller) {
if (!isZoneName(zone)) {
throw new TypeError(`${caller}: ${JSON.stringify(zone)} is not an IANA zone name.`);
}
return formatterFor(zone);
}
function wallMs(wall) {
return utcWallMs(wall.year, wall.month, wall.day, wall.hour, wall.minute, wall.second ?? 0, wall.millisecond ?? 0);
}
/** The offset of `zone` at the instant `ms`, in MILLISECONDS east of UTC. */
function offsetMsAt(zone, ms) {
return wallMs(wallAt(zone, ms)) - ms;
}
/**
* The offset of `zone` at the instant `ms`, in minutes east of UTC
* (`America/Los_Angeles` in October: -420). A historical local-mean-time
* offset can be a fraction of a minute.
*/
export function offsetAt(zone, ms) {
return offsetMsAt(zone, ms) / 60_000;
}
/** Whether a wall time's fields name a real calendar time (any zone aside). */
export function isWallTime(value) {
if (typeof value !== 'object' || value === null)
return false;
const w = value;
const int = (v, lo, hi) => typeof v === 'number' && Number.isInteger(v) && v >= lo && v <= hi;
if (!int(w.year, 0, 9999) || !int(w.month, 1, 12))
return false;
const year = w.year;
const month = w.month;
return (int(w.day, 1, daysInMonth(year, month)) &&
int(w.hour, 0, 23) &&
int(w.minute, 0, 59) &&
(w.second === undefined || int(w.second, 0, 59)) &&
(w.millisecond === undefined || int(w.millisecond, 0, 999)));
}
/**
* What `wall` names in `zone`: one instant, two (a fall-back overlap:
* `earlier`, `later`), or none (a spring-forward gap: `earlier` is the wall
* time read with the offset after the change, `later` with the offset before
* it — Temporal's two readings). Throws a `TypeError` on a wall time that is
* not a calendar time or a zone that is not a zone name.
*/
export function readWall(wall, zone) {
if (!isWallTime(wall)) {
throw new TypeError(`readWall: ${JSON.stringify(wall)} is not a wall time.`);
}
zoneFormatter(zone, 'readWall');
const w = wallMs(wall);
// Every real zone changes offset at most once within a day either side.
const before = offsetMsAt(zone, w - 86_400_000);
const after = offsetMsAt(zone, w + 86_400_000);
const at = offsetMsAt(zone, w);
// One offset across the two days: no change in the window, so `w - at` (at
// most 14 h from `w`) shows `wall` — the check below would ask `Intl` for nothing.
if (before === at && at === after)
return { kind: 'unique', ms: w - at };
const offsets = [...new Set([before, at, after])];
const valid = offsets.map((o) => w - o).filter((t) => offsetMsAt(zone, t) === w - t);
const instants = [...new Set(valid)].sort((a, b) => a - b);
if (instants.length === 1)
return { kind: 'unique', ms: instants[0] };
if (instants.length >= 2) {
return {
kind: 'overlap',
earlier: instants[0],
later: instants[instants.length - 1],
};
}
return { kind: 'gap', earlier: w - after, later: w - before };
}
/**
* The instant `wall` names in `zone` under `disambiguation` (the module
* table), or `undefined` under `'reject'` when the wall time is doubled or
* skipped.
*/
export function wallToInstant(wall, zone, disambiguation) {
const reading = readWall(wall, zone);
if (reading.kind === 'unique')
return reading.ms;
if (disambiguation === 'reject')
return undefined;
if (reading.kind === 'overlap')
return disambiguation === 'later' ? reading.later : reading.earlier;
return disambiguation === 'earlier' ? reading.earlier : reading.later;
}
/**
* The tz database version this runtime's `Intl` carries — Node names it
* (`process.versions.tz`, e.g. `'2025b'`); any other runtime answers
* `'unknown'`. Recorded on every `time-reading` row: zone data changes
* between releases, so a reading is re-derivable only with it.
*/
export function tzdataVersion() {
const versions = globalThis.process
?.versions;
const tz = versions?.tz;
return typeof tz === 'string' && tz.length > 0 && tz.length <= 32 ? tz : 'unknown';
}
// ─── Zones named in words (data from the tz database, never a word list) ─
/** The areas a place's zone is looked up under (`Etc` and the legacy `US/…` links are not places). */
const PLACE_AREAS = [
'Africa',
'America',
'Antarctica',
'Asia',
'Atlantic',
'Australia',
'Europe',
'Indian',
'Pacific',
];
const PLACE_KEY = /^[A-Za-z][A-Za-z'.-]*(?:_[A-Za-z][A-Za-z'.-]*){0,3}$/;
const placeMemo = new Map();
let placeIndex;
/** Last segment (lower case) → the runtime's zones that end in it, from `Intl.supportedValuesOf`. */
function placeIndexOf() {
if (placeIndex !== undefined)
return placeIndex;
const index = new Map();
const supported = Intl.supportedValuesOf;
let names = [];
try {
names = typeof supported === 'function' ? supported('timeZone') : [];
}
catch {
names = [];
}
for (const name of names) {
const area = name.slice(0, name.indexOf('/'));
if (!PLACE_AREAS.includes(area))
continue;
const last = name.slice(name.lastIndexOf('/') + 1).toLowerCase();
index.set(last, [...(index.get(last) ?? []), name]);
}
placeIndex = index;
return index;
}
/**
* The ONE zone a place name names in this runtime's tz database, or
* `undefined` when it names none or more than one. `London` → `Europe/London`,
* `New York` → `America/New_York`; `India`, `Pacific`, `Eastern` → `undefined`
* (no zone ends in them — a region is not a place). Looked up two ways, both
* the tz database's own data: the zones whose last segment is the place, and
* `Area/Place` under each geographic area (which admits a renamed city's link,
* `Kyiv`). Every hit is compared by its canonical name, so a link and its
* target are one zone. Memoised; the runtime's database does not change.
*
* @example
* ```ts
* zoneOfPlace('London'); // 'Europe/London'
* zoneOfPlace('New York'); // 'America/New_York'
* zoneOfPlace('India'); // undefined — asked, never guessed
* ```
*/
export function zoneOfPlace(place) {
const key = place.trim().replace(/\s+/g, '_');
if (key.length > 32 || !PLACE_KEY.test(key))
return undefined;
const memo = placeMemo.get(key.toLowerCase());
if (memo !== undefined)
return memo ?? undefined;
const found = new Set();
for (const name of placeIndexOf().get(key.toLowerCase()) ?? []) {
found.add(canonicalZone(name) ?? name);
}
for (const area of PLACE_AREAS) {
const name = canonicalZone(`${area}/${key}`);
if (name !== undefined)
found.add(name);
}
const zone = found.size === 1 ? [...found][0] : undefined;
if (placeMemo.size >= MAX_CACHED)
placeMemo.clear();
placeMemo.set(key.toLowerCase(), zone ?? null);
return zone;
}
/**
* The IANA zone that is a fixed offset, for a whole-hour offset the tz
* database names (`Etc/GMT+8` is −08:00 — POSIX signs, inverted), else
* `undefined`. How a window read at a literal offset, or typed with one no
* other zone shows, names its zone without claiming a place's.
*
* @example
* ```ts
* fixedOffsetZone(-480); // 'Etc/GMT+8'
* fixedOffsetZone(0); // 'UTC'
* fixedOffsetZone(330); // undefined — no Etc zone for +05:30
* ```
*/
export function fixedOffsetZone(minutes) {
if (minutes === 0)
return 'UTC';
if (!Number.isInteger(minutes) || minutes % 60 !== 0)
return undefined;
const hours = minutes / 60;
if (hours < -12 || hours > 14)
return undefined;
const name = `Etc/GMT${hours < 0 ? '+' : '-'}${Math.abs(hours)}`;
return isZoneName(name) ? name : undefined;
}
//# sourceMappingURL=zone.js.map