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

96 lines
3.6 KiB
TypeScript

import { compareWall, wallAsEpochMs } from "./nominal.js";
import { offsetMs, wallOfInstant } from "./timezone.js";
import type { WallClock } from "./timezone.js";
export { materializeOccurrence } from "./materialize-api.js";
const SIX_HOURS = 6 * 3600_000;
/**
* Resolve one nominal local wall time in a zone to its UTC fire instant
* (epoch ms).
*
* Normal: the unique instant reading that wall time. Fold (ambiguous): fires
* once, at the earlier instant. Gap (nonexistent): the first valid instant
* after the gap — the earliest instant whose local reading is strictly later
* than the target.
*
* The candidate instant is found by fixed-point iteration (guess = local-as-UTC
* minus the offset at the guess), which converges for normal and fold times.
* Neighbouring offsets are probed to catch the second reading of a fold; the
* earliest matching instant wins. A non-matching iteration means the wall time
* does not exist (gap), resolved by a bracketed binary search for the exact
* first valid instant after the transition — correct for 1-hour gaps and
* Pacific/Apia's whole-day skip alike.
*/
export function resolveWallToInstant(wall: WallClock, zone: string): number {
if (zone === "UTC") return wallAsEpochMs(wall);
const asUtc = wallAsEpochMs(wall);
// Fixed-point iteration from the naive reading.
let guess = asUtc;
let resolved: number | null = null;
for (let i = 0; i < 6; i++) {
guess = asUtc - offsetMs(guess, zone);
if (compareWall(wallOfInstant(guess, zone), wall) === 0) {
resolved = guess;
break;
}
}
// Collect every instant reading this wall time: the resolved one plus
// candidates derived from the offsets in effect on either side of it.
const matches: number[] = [];
const consider = (instant: number) => {
if (compareWall(wallOfInstant(instant, zone), wall) === 0) matches.push(instant);
};
if (resolved !== null) {
consider(resolved);
consider(asUtc - offsetMs(resolved - SIX_HOURS, zone));
consider(asUtc - offsetMs(resolved + SIX_HOURS, zone));
}
if (matches.length > 0) {
// Fold: exactly one firing, at the earliest (earlier-offset) instant.
return Math.min(...matches);
}
// Gap: the wall time does not exist. First valid instant after the gap.
return firstInstantAfterGap(asUtc, wall, zone);
}
/**
* Earliest instant whose wall clock in `zone` is strictly after `wall`.
* A coarse 15-minute scan brackets the transition, then a binary search
* narrows to the exact instant (works for 1-hour and whole-day gaps).
*/
function firstInstantAfterGap(asUtc: number, wall: WallClock, zone: string): number {
const stepMs = 15 * 60_000;
// Establish the search invariant wall(lo) <= wall first: the local-as-UTC
// reading can already lie past the gap when the transition is large (e.g. a
// whole-day skip), so step back until the reading is at or before the target.
let lo = asUtc;
for (let i = 0; i < 8 * 96 && compareWall(wallOfInstant(lo, zone), wall) > 0; i++) {
lo -= stepMs;
}
for (let i = 0; i < 16 * 96; i++) {
const next = lo + stepMs;
if (compareWall(wallOfInstant(next, zone), wall) > 0) {
return binarySearchFirstAfter(lo, next, wall, zone);
}
lo = next;
}
throw new Error(`no instant found after gap at ${JSON.stringify(wall)} in ${zone}`);
}
/** Invariant: wall(lo) <= wall < wall(hi); returns the smallest such hi. */
function binarySearchFirstAfter(lo: number, hi: number, wall: WallClock, zone: string): number {
while (hi - lo > 1) {
const mid = lo + Math.floor((hi - lo) / 2);
if (compareWall(wallOfInstant(mid, zone), wall) > 0) {
hi = mid;
} else {
lo = mid;
}
}
return hi;
}