T-HUB/packages/scheduling/src/timezone.ts
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

212 lines
6.6 KiB
TypeScript

import { InvalidInstantError, InvalidTimezoneError } from "./errors.js";
/**
* Wall-clock reading of an instant in some zone. Fields are the raw numeric
* calendar values (month 1-12, day 1-31, hour 0-23).
*/
export interface WallClock {
readonly year: number;
readonly month: number;
readonly day: number;
readonly hour: number;
readonly minute: number;
readonly second: number;
}
/**
* Nominal local date-time, e.g. `2024-03-10T02:30`. Lexicographic order of
* these strings is chronological order, which is what the
* `last_fired_nominal_local` refire gate relies on.
*/
export type NominalLocal = string;
const WALL_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = {
hourCycle: "h23",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
};
const wallFormatters = new Map<string, Intl.DateTimeFormat>();
function wallFormatter(zone: string): Intl.DateTimeFormat {
const cached = wallFormatters.get(zone);
if (cached) return cached;
const formatter = new Intl.DateTimeFormat("en-US", {
...WALL_FORMAT_OPTIONS,
timeZone: zone,
});
wallFormatters.set(zone, formatter);
return formatter;
}
const IDIOMS_REJECTED = new Set(["local", "system", "server", "server-local", "machine"]);
/**
* Validate a timezone and return its canonical IANA id.
*
* Accepts real IANA ids (including `Etc/GMT+5` style fixed zones and `UTC`),
* canonicalizing whatever Intl resolves (e.g. `US/Eastern` -> `America/New_York`).
* Rejects unknown ids, offset spellings like `UTC+3`/`+05:30`, empty input and
* the strings `local`/`system` — the schedule zone is never allowed to fall
* back to the process zone.
*/
export function canonicalTimezone(timezone: string): string {
if (typeof timezone !== "string") {
throw new InvalidTimezoneError(`timezone must be a string, got ${typeof timezone}`);
}
const trimmed = timezone.trim();
if (trimmed === "") {
throw new InvalidTimezoneError("timezone must be an explicit IANA zone id, not an empty string");
}
if (IDIOMS_REJECTED.has(trimmed.toLowerCase())) {
throw new InvalidTimezoneError(
`timezone "${timezone}" is not an IANA zone id; a schedule must carry an explicit zone (never server-local)`,
);
}
if (/^[+-]?\d{1,2}(:?\d{2})?$/.test(trimmed) || /^UTC[+-]\d/.test(trimmed)) {
throw new InvalidTimezoneError(
`timezone "${timezone}" is a UTC offset, not an IANA zone id; store an IANA zone (UTC is allowed)`,
);
}
let resolved: string;
try {
resolved = new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).resolvedOptions().timeZone;
} catch {
throw new InvalidTimezoneError(`unknown IANA time zone: "${timezone}"`);
}
if (!resolved) {
throw new InvalidTimezoneError(`unknown IANA time zone: "${timezone}"`);
}
return resolved;
}
/** True when `canonicalTimezone` would accept the value. */
export function isValidTimezone(timezone: string): boolean {
try {
canonicalTimezone(timezone);
return true;
} catch {
return false;
}
}
/** Validate and canonicalize, throwing `InvalidTimezoneError` on rejection. */
export function assertValidTimezone(timezone: string): string {
return canonicalTimezone(timezone);
}
/** Local wall clock struck by an instant, in the given zone. */
export function wallOfInstant(epochMs: number, zone: string): WallClock {
if (zone === "UTC") {
return decodeUtc(epochMs);
}
const parts = wallFormatter(zone).formatToParts(new Date(epochMs));
let year = 0;
let month = 0;
let day = 0;
let hour = 0;
let minute = 0;
let second = 0;
for (const part of parts) {
switch (part.type) {
case "year":
year = Number(part.value);
break;
case "month":
month = Number(part.value);
break;
case "day":
day = Number(part.value);
break;
case "hour":
hour = Number(part.value);
break;
case "minute":
minute = Number(part.value);
break;
case "second":
second = Number(part.value);
break;
default:
break;
}
}
return { year, month, day, hour, minute, second };
}
/** The UTC instant a wall-clock reading would denote if the offset were zero. */
export function epochOfWall(wall: WallClock): number {
return Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute, wall.second);
}
function decodeUtc(epochMs: number): WallClock {
const d = new Date(epochMs);
return {
year: d.getUTCFullYear(),
month: d.getUTCMonth() + 1,
day: d.getUTCDate(),
hour: d.getUTCHours(),
minute: d.getUTCMinutes(),
second: d.getUTCSeconds(),
};
}
/**
* Zone offset (ms east of UTC) in effect at an instant. Minute-precision
* arithmetic against `Intl`; UTC short-circuits without touching a formatter.
*/
export function offsetMs(epochMs: number, zone: string): number {
if (zone === "UTC") return 0;
return epochOfWall(wallOfInstant(epochMs, zone)) - epochMs;
}
const OFFSET_ISO_TAIL = /(?:Z|[+-]\d{2}:\d{2})$/;
/**
* Coerce an instant input to epoch milliseconds.
*
* `Date` and finite numbers pass through. Strings must be ISO-8601; a string
* that carries a time component must also carry an explicit UTC offset (`Z` or
* `±hh:mm`) — otherwise `Date.parse` would silently apply the server's local
* zone, which this library exists to prevent.
*/
export function toInstantMs(value: Date | number | string): number {
if (value instanceof Date) {
const ms = value.getTime();
if (Number.isNaN(ms)) throw new InvalidInstantError("invalid Date (NaN)");
return ms;
}
if (typeof value === "number") {
if (!Number.isFinite(value)) throw new InvalidInstantError(`invalid instant number: ${value}`);
return value;
}
if (typeof value === "string") {
const trimmed = value.trim();
const hasTime = /[T ]\d/.test(trimmed);
if (hasTime && !OFFSET_ISO_TAIL.test(trimmed)) {
throw new InvalidInstantError(
`instant "${value}" has a time but no UTC offset; supply ...Z or ...±hh:mm (never server-local)`,
);
}
const ms = Date.parse(trimmed);
if (Number.isNaN(ms)) throw new InvalidInstantError(`unparseable instant: "${value}"`);
return ms;
}
throw new InvalidInstantError(`unsupported instant input: ${String(value)}`);
}
type ProcessVersionsLike = { tz?: string };
/**
* tzdata version backing this process (`process.versions.tz`, e.g. `2026a`),
* or `"unknown"` outside Node. Recorded per schedule so tzdata-driven
* re-materialization can detect drift.
*/
export function currentTzdataVersion(): string {
const proc = (globalThis as { process?: { versions?: ProcessVersionsLike } }).process;
return proc?.versions?.tz ?? "unknown";
}