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/.
96 lines
3.6 KiB
TypeScript
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;
|
|
}
|