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/.
65 lines
2.6 KiB
TypeScript
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);
|
|
}
|