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
120
packages/scheduling/src/occurrences.ts
Normal file
120
packages/scheduling/src/occurrences.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { cronMatchesDay } from "./cron.js";
|
||||
import { resolveWallToInstant } from "./materialize.js";
|
||||
import { slotNominal, sortedHours, sortedMinutes, type PreparedSchedule } from "./schedule.js";
|
||||
import { toInstantMs, wallOfInstant } from "./timezone.js";
|
||||
import type { WallClock } from "./timezone.js";
|
||||
import type { Occurrence } from "./schedule.js";
|
||||
|
||||
export interface NextOccurrencesOptions {
|
||||
/**
|
||||
* When true (default) a slot whose fire instant is exactly `from` is
|
||||
* skipped; when false it is included. The default keeps re-generation
|
||||
* idempotent: re-running generation from the last emitted instant never
|
||||
* re-emits it.
|
||||
*/
|
||||
readonly exclusive?: boolean;
|
||||
}
|
||||
|
||||
|
||||
/** Nominal calendar day (local midnight reading) containing `epochMs`. */
|
||||
function startNominalDay(epochMs: number, zone: string): WallClock {
|
||||
const wall = wallOfInstant(epochMs, zone);
|
||||
return { year: wall.year, month: wall.month, day: wall.day, hour: 0, minute: 0, second: 0 };
|
||||
}
|
||||
|
||||
/** Advance a nominal calendar day by one, purely calendar arithmetic. */
|
||||
function nextNominalDay(day: WallClock): WallClock {
|
||||
const next = new Date(Date.UTC(day.year, day.month - 1, day.day + 1));
|
||||
return {
|
||||
year: next.getUTCFullYear(),
|
||||
month: next.getUTCMonth() + 1,
|
||||
day: next.getUTCDate(),
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The next `count` occurrences at or after `from`, evaluated in the schedule
|
||||
* zone and materialized to UTC instants.
|
||||
*
|
||||
* Each local day's matching slots are emitted in ascending local order; a
|
||||
* slot's materialization (gap shift-forward, fold earlier-instant) may collide
|
||||
* with another slot's instant — such collisions are deduplicated so each
|
||||
* materialized instant appears exactly once (occurrence key stays unique).
|
||||
* Generation is capped at `maxYears` of local days to bound work for
|
||||
* sparse schedules.
|
||||
*/
|
||||
export function nextOccurrencesPrepared(
|
||||
prepared: PreparedSchedule,
|
||||
from: Date | number | string,
|
||||
count: number,
|
||||
options?: NextOccurrencesOptions,
|
||||
maxYears = 8,
|
||||
): Occurrence[] {
|
||||
if (!Number.isInteger(count) || count <= 0) {
|
||||
throw new RangeError(`count must be a positive integer, got ${count}`);
|
||||
}
|
||||
const zone = prepared.timezone;
|
||||
const cron = prepared.cron;
|
||||
const fromMs = toInstantMs(from);
|
||||
const exclusive = options?.exclusive ?? true;
|
||||
const hours = sortedHours(cron);
|
||||
const minutes = sortedMinutes(cron);
|
||||
const out: Occurrence[] = [];
|
||||
const seen = new Set<number>();
|
||||
// Index of the emitted occurrence per fire instant, so a later nominal slot
|
||||
// collapsing onto an already-emitted instant can take over the label.
|
||||
const indexAt = new Map<number, number>();
|
||||
const dayLimit = Math.ceil(maxYears * 366);
|
||||
// Iterate NOMINAL calendar days (not materialized midnights) so a skipped
|
||||
// local day (Pacific/Apia 2011-12-30) still contributes its slots: every
|
||||
// slot of that day materializes to the first valid instant after the gap and
|
||||
// collapses onto a single occurrence there.
|
||||
let day = startNominalDay(fromMs, zone);
|
||||
// Once `count` occurrences exist, generation may continue only far enough to
|
||||
// finish the collapse set of the LAST emitted instant — later slots of that
|
||||
// same instant must still be able to take over its nominal label — and then
|
||||
// stops. Occurrences are emitted in ascending instant order, so the first
|
||||
// slot materializing strictly after the boundary ends generation.
|
||||
let boundary: number | null = null;
|
||||
for (let dayIndex = 0; dayIndex < dayLimit; dayIndex++) {
|
||||
if (cronMatchesDay(cron, day.year, day.month, day.day)) {
|
||||
for (const hour of hours) {
|
||||
for (const minute of minutes) {
|
||||
const slotWall: WallClock = {
|
||||
year: day.year, month: day.month, day: day.day,
|
||||
hour, minute, second: 0,
|
||||
};
|
||||
const fireAt = resolveWallToInstant(slotWall, zone);
|
||||
if (exclusive ? fireAt <= fromMs : fireAt < fromMs) continue;
|
||||
if (boundary !== null && fireAt > boundary) return out;
|
||||
if (seen.has(fireAt)) {
|
||||
// Collapse: several nominal slots materialized onto one instant
|
||||
// (post-gap). Exactly one occurrence survives per instant, carrying
|
||||
// the LATEST collapsed nominal slot — the conservative value for
|
||||
// the last_fired_nominal_local never-refire gate.
|
||||
const index = indexAt.get(fireAt);
|
||||
if (index !== undefined) {
|
||||
out[index] = {
|
||||
nominalLocal: slotNominal(day.year, day.month, day.day, hour, minute),
|
||||
fireAtUtc: fireAt,
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
seen.add(fireAt);
|
||||
indexAt.set(fireAt, out.length);
|
||||
out.push({
|
||||
nominalLocal: slotNominal(day.year, day.month, day.day, hour, minute),
|
||||
fireAtUtc: fireAt,
|
||||
});
|
||||
if (out.length >= count) boundary = fireAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
day = nextNominalDay(day);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue