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/.
120 lines
4.9 KiB
TypeScript
120 lines
4.9 KiB
TypeScript
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;
|
|
}
|