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
96
packages/scheduling/src/materialize.ts
Normal file
96
packages/scheduling/src/materialize.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue