Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 | 6x 6x 2288x 2288x 2283x 10x 13x 3x 10x 10x 10x 13x 4x 10x 10x | /**
* IST (India Standard Time) calendar math for the RRM ladder scheduler and
* gateway (docs/operations/rrm-ladder-design.md §5–§6).
*
* India has ONE timezone, fixed at UTC+05:30, with no daylight saving — so a
* constant offset is correct forever, unlike almost anywhere else `Intl`
* would be used for this. That is exactly why this module does NOT use
* `Intl.DateTimeFormat` or the Worker's local clock: `Intl` needs a tz
* database and a runtime that ships one, and the whole point of a fixed
* offset is to not depend on either. Every function here is pure — the
* caller always passes `ms` (epoch milliseconds) — so tests never touch
* `Date.now()` and every case is exactly reproducible.
*/
export type { IstParts } from "./time";
/** UTC+05:30, in milliseconds. Never changes — India has no DST. */
// ─────────────────────────────────────────────────────────────────────────
// `IST_OFFSET_MS`, `IstParts` and `istParts` are RE-EXPORTED from
// `lib/rrm/time.ts` rather than defined twice. Two implementations of the
// same clock arithmetic is exactly the split that produces a send at the
// wrong hour: one module says the window is closed, the other says it is
// open. This file adds only the two send-window helpers the ladder engine
// needs on top of that shared clock.
// ─────────────────────────────────────────────────────────────────────────
export { IST_OFFSET_MS, istParts } from "./time";
import { IST_OFFSET_MS, istParts } from "./time";
const HOUR_MS = 3_600_000;
const DAY_MS = 86_400_000;
export function isWithinSendWindow(
ms: number,
startHour: number,
endHour: number,
holidays: readonly string[],
): boolean {
const { hour, dateISO } = istParts(ms);
if (holidays.includes(dateISO)) return false;
return hour >= startHour && hour < endHour;
}
/** UTC epoch ms for `dateISO`'s 00:00:00.000 IST — the inverse of `istParts`'s `dateISO`. */
function istMidnightUtcMs(dateISO: string): number {
return Date.parse(`${dateISO}T00:00:00.000Z`) - IST_OFFSET_MS;
}
/**
* The next instant at/after `ms` that falls inside the send window on a
* non-holiday IST date, plus the caller's jitter.
*
* If `ms` already qualifies, that instant IS the answer (`ms + jitterMs`) —
* this is a pure "find the next qualifying moment" function, not "find the
* start of the *next* window period." In practice every caller (the
* scheduler's due-step reschedule, per the design doc §6/§7) only calls this
* when `isWithinSendWindow(ms, ...)` is already false, but the function stays
* correct either way rather than assuming its own caller's usage pattern.
*
* Otherwise: walk forward to the next non-holiday day's `startHour:00` IST —
* today's, if `ms` is still before today's start hour and today is not a
* holiday; tomorrow's (skipping any further holidays) otherwise. A holiday
* closes the whole day, so it is checked before the hour comparison, exactly
* as in `isWithinSendWindow`.
*/
export function nextWindowStart(
ms: number,
startHour: number,
endHour: number,
holidays: readonly string[],
jitterMs = 0,
): number {
if (isWithinSendWindow(ms, startHour, endHour, holidays)) {
return ms + jitterMs;
}
const { hour, dateISO } = istParts(ms);
const todayMidnight = istMidnightUtcMs(dateISO);
let candidateMidnight =
hour < startHour && !holidays.includes(dateISO)
? todayMidnight
: todayMidnight + DAY_MS;
// Skip forward over any further holidays. `istParts` on a midnight-exact
// instant always yields hour 0 and the matching calendar date, so this
// reads back exactly the date `candidateMidnight` was built for.
while (holidays.includes(istParts(candidateMidnight).dateISO)) {
candidateMidnight += DAY_MS;
}
const candidateStart = candidateMidnight + startHour * HOUR_MS;
return candidateStart + jitterMs;
}
|