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