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

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;
}