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/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
212
packages/scheduling/src/timezone.ts
Normal file
212
packages/scheduling/src/timezone.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
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";
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue