All files / lib/rrm ist.ts

100% Statements 16/16
100% Branches 11/11
100% Functions 3/3
100% Lines 15/15

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