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
96
packages/scheduling/src/schedule.ts
Normal file
96
packages/scheduling/src/schedule.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue