/** * Quiet hours — when a person may be reached. * * Quiet hours defer delivery of EVERY severity, criticals included. This is a * home lab, not a pager rotation: very little here is worth waking someone for, * and an alerting system that wakes you for things you would not have acted on * until morning trains you to ignore it. * * Escalation steps continue to ADVANCE during a quiet window — only delivery * defers — so nothing is skipped; it arrives when the window opens. And a * deferred alert that resolves before the window ends is delivered as neither a * page nor an all-clear, because delivery re-evaluates current state rather * than replaying history. * * `escalation_policies.bypass_quiet_hours` is the escape hatch for the few * conditions that genuinely cannot wait. Reserved, unused in MVP. * * See openspec/changes/add-alerting/design.md D13. */ export interface QuietHoursWindow { /** Local "HH:MM", inclusive. Null (either field) means always reachable. */ start: string | null; end: string | null; /** IANA timezone the window is expressed in, e.g. "America/Los_Angeles". */ timezone: string; } const HHMM = /^([01]\d|2[0-3]):([0-5]\d)$/; /** Parse "HH:MM" to minutes since local midnight, or null if malformed. */ export function parseClockTime(value: string): number | null { const match = HHMM.exec(value); if (!match) return null; return Number.parseInt(match[1], 10) * 60 + Number.parseInt(match[2], 10); } /** * Minutes since local midnight for `instant` in `timezone`. * * Uses Intl rather than manual offset arithmetic so DST is handled by the * platform's tz database. A window is expressed in wall-clock terms — "22:00 * to 07:00" means those local hours on whichever side of a DST boundary the * instant falls. */ export function localMinutesOfDay(instant: Date, timezone: string): number { const parts = new Intl.DateTimeFormat('en-US', { timeZone: timezone, hour: '2-digit', minute: '2-digit', hour12: false, }).formatToParts(instant); const hour = Number.parseInt(parts.find((p) => p.type === 'hour')?.value ?? '0', 10); const minute = Number.parseInt(parts.find((p) => p.type === 'minute')?.value ?? '0', 10); // Intl renders midnight as 24 in some locales/options combinations. return (hour % 24) * 60 + minute; } /** * Whether `instant` falls inside the window. * * Windows normally wrap midnight (22:00–07:00), so a naive `start <= t <= end` * comparison would be wrong for the common case rather than an edge case. */ export function isWithinQuietHours(window: QuietHoursWindow, instant: Date): boolean { if (!window.start || !window.end) return false; const start = parseClockTime(window.start); const end = parseClockTime(window.end); if (start === null || end === null) return false; // Degenerate: start === end is treated as "no window", not "always quiet". // The alternative silences someone permanently through a typo. if (start === end) return false; const now = localMinutesOfDay(instant, window.timezone); return start < end ? now >= start && now < end : // Wraps midnight: inside if after the start OR before the end. now >= start || now < end; } /** * The instant a deferred notification becomes deliverable — the next time the * window's end is reached, at or after `instant`. * * Returns null when the instant is not inside a window (nothing to defer). */ export function quietHoursEndAfter(window: QuietHoursWindow, instant: Date): Date | null { if (!isWithinQuietHours(window, instant)) return null; const end = parseClockTime(window.end ?? ''); if (end === null) return null; const nowLocal = localMinutesOfDay(instant, window.timezone); // Minutes until the window's end, wrapping to tomorrow when already past it. const minutesUntilEnd = end > nowLocal ? end - nowLocal : 24 * 60 - nowLocal + end; return new Date(instant.getTime() + minutesUntilEnd * 60_000); }