T-HUB/packages/scheduling/src/nominal.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

65 lines
2.6 KiB
TypeScript

import { InvalidNominalLocalError } from "./errors.js";
import type { NominalLocal, WallClock } from "./timezone.js";
/** Format a wall clock as a nominal local date-time string, `YYYY-MM-DDTHH:mm`. */
export function formatNominal(wall: WallClock): NominalLocal {
const y = String(wall.year).padStart(4, "0");
const mo = String(wall.month).padStart(2, "0");
const d = String(wall.day).padStart(2, "0");
const h = String(wall.hour).padStart(2, "0");
const mi = String(wall.minute).padStart(2, "0");
return `${y}-${mo}-${d}T${h}:${mi}`;
}
const NOMINAL_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/;
/**
* Parse a nominal local date-time string back into a wall clock. Rejects
* malformed strings and impossible calendar values with
* `InvalidNominalLocalError`. Note the string may denote a wall time that does
* not exist in a zone (gap) or exists twice (fold); existence is a
* materialization concern, not a parse concern.
*/
export function parseNominal(nominal: NominalLocal): WallClock {
const match = NOMINAL_RE.exec(nominal);
if (!match) {
throw new InvalidNominalLocalError(`malformed nominal local date-time: "${nominal}"`);
}
const wall: WallClock = {
year: Number(match[1]),
month: Number(match[2]),
day: Number(match[3]),
hour: Number(match[4]),
minute: Number(match[5]),
second: match[6] === undefined ? 0 : Number(match[6]),
};
if (wall.month < 1 || wall.month > 12) {
throw new InvalidNominalLocalError(`month out of range in "${nominal}"`);
}
if (wall.day < 1 || wall.day > 31) {
throw new InvalidNominalLocalError(`day out of range in "${nominal}"`);
}
if (wall.hour > 23 || wall.minute > 59 || wall.second > 59) {
throw new InvalidNominalLocalError(`time of day out of range in "${nominal}"`);
}
return wall;
}
/** Total ordering over wall clocks; safe because fields are validated. */
export function compareWall(a: WallClock, b: WallClock): number {
if (a.year !== b.year) return a.year < b.year ? -1 : 1;
if (a.month !== b.month) return a.month < b.month ? -1 : 1;
if (a.day !== b.day) return a.day < b.day ? -1 : 1;
if (a.hour !== b.hour) return a.hour < b.hour ? -1 : 1;
if (a.minute !== b.minute) return a.minute < b.minute ? -1 : 1;
return a.second < b.second ? -1 : a.second > b.second ? 1 : 0;
}
export function wallEquals(a: WallClock, b: WallClock): boolean {
return compareWall(a, b) === 0;
}
/** Milliseconds between two wall readings when interpreted as UTC. */
export function wallAsEpochMs(wall: WallClock): number {
return Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute, wall.second);
}