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

96 lines
3.4 KiB
TypeScript

import { cronMatchesDay, type ParsedCron } from "./cron.js";
import { formatNominal } from "./nominal.js";
import { resolveWallToInstant } from "./materialize.js";
import { canonicalTimezone, wallOfInstant } from "./timezone.js";
import { parseCron } from "./cron.js";
import type { NominalLocal } from "./timezone.js";
/** A schedule's time specification: five-field cron in an explicit IANA zone. */
export interface ScheduleSpec {
/** Five-field cron expression, e.g. `*\/15 * * * *`. */
readonly cron: string;
/** Explicit IANA zone id — never server-local. `UTC` is allowed. */
readonly timezone: string;
}
/** A materialized occurrence: nominal local slot plus its UTC fire instant. */
export interface Occurrence {
/** Local date-time of this slot in the schedule zone, e.g. `2024-03-10T02:30`. */
nominalLocal: NominalLocal;
/** Materialized UTC fire instant in epoch milliseconds. */
fireAtUtc: number;
}
/** A schedule validated once, reusable across calls. */
export interface PreparedSchedule {
readonly cron: ParsedCron;
readonly timezone: string;
readonly spec: ScheduleSpec;
}
/**
* Validate a spec (cron + zone) once. Throws `InvalidCronError`,
* `ImpossibleScheduleError` or `InvalidTimezoneError` on bad input.
*/
export function prepareSchedule(spec: ScheduleSpec): PreparedSchedule {
return {
cron: parseCron(spec.cron),
timezone: canonicalTimezone(spec.timezone),
spec: { cron: spec.cron, timezone: spec.timezone },
};
}
/** Hour values sorted ascending for deterministic day-slot iteration. */
export function sortedHours(cron: ParsedCron): number[] {
return [...cron.hour.values].sort((a, b) => a - b);
}
/** Builds the nominal-local string for one slot on a matching local day. */
export function slotNominal(year: number, month: number, day: number, hour: number, minute: number): NominalLocal {
const y = String(year).padStart(4, "0");
const mo = String(month).padStart(2, "0");
const d = String(day).padStart(2, "0");
const h = String(hour).padStart(2, "0");
const mi = String(minute).padStart(2, "0");
return `${y}-${mo}-${d}T${h}:${mi}`;
}
/**
* Iterate matching local days at or after a starting local day. Returns each
* matching day's wall clock at local midnight (materialized to an instant so
* the caller can step by absolute time across DST).
*/
export function* matchingLocalDays(
cron: ParsedCron,
startDayEpoch: number,
zone: string,
maxDays: number,
): Generator<{ wall: ReturnType<typeof wallOfInstant>; midnightInstant: number }> {
let cursor = startDayEpoch;
for (let i = 0; i < maxDays; i++) {
const wall = wallOfInstant(cursor, zone);
if (cronMatchesDay(cron, wall.year, wall.month, wall.day)) {
yield { wall, midnightInstant: cursor };
}
cursor = stepOneLocalDay(cursor, zone);
}
}
/** Advance to the next local midnight, robust across 23/24/25-hour days. */
export function stepOneLocalDay(dayEpoch: number, zone: string): number {
const nextApprox = dayEpoch + 24 * 3600_000;
const nextWall = wallOfInstant(nextApprox, zone);
return resolveWallToInstant(
{ year: nextWall.year, month: nextWall.month, day: nextWall.day, hour: 0, minute: 0, second: 0 },
zone,
);
}
export { formatNominal };
/** Minute values sorted ascending for deterministic day-slot iteration. */
export function sortedMinutes(cron: ParsedCron): number[] {
return [...cron.minute.values].sort((a, b) => a - b);
}