/** * Mechanical operator availability in an IANA timezone (#273). * * This module owns the clock decision. Callers supply `now`; neither a model nor * process-local memory decides whether an interruption is allowed. UTC-minute * scanning for the next transition is intentional: evaluating the same local * predicate across real instants handles skipped and repeated DST wall-clock * minutes without inventing an offset conversion of our own. */ import { WEEKDAYS, type InterruptCategory, type ReportingPolicy, type Weekday, type WeeklyAvailability } from "./types.ts"; const MINUTE_MS = 60_000; const TRANSITION_HORIZON_MS = 15 * 24 * 60 * MINUTE_MS; const formatters = new Map(); interface CachedTransition { from: number; until: number; open: boolean; nextTransitionAt?: number; } const transitions = new WeakMap(); function formatter(timezone: string): Intl.DateTimeFormat { const existing = formatters.get(timezone); if (existing !== undefined) return existing; const created = new Intl.DateTimeFormat("en-GB", { timeZone: timezone, weekday: "short", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23", }); formatters.set(timezone, created); return created; } interface LocalMinute { day: Weekday; date: string; clock: string; } function localMinute(at: number, timezone: string): LocalMinute { const parts = formatter(timezone).formatToParts(new Date(at)); const get = (type: Intl.DateTimeFormatPartTypes): string => parts.find((part) => part.type === type)?.value ?? ""; return { day: get("weekday").slice(0, 3).toLowerCase() as Weekday, date: `${get("year")}-${get("month")}-${get("day")}`, clock: `${get("hour")}:${get("minute")}`, }; } function previousDay(day: Weekday): Weekday { const index = WEEKDAYS.indexOf(day); return WEEKDAYS[(index + WEEKDAYS.length - 1) % WEEKDAYS.length]!; } /** Whether `at` falls inside the configured local weekly window. */ export function availabilityOpen(window: WeeklyAvailability | undefined, at: number): boolean { if (window === undefined) return true; const local = localMinute(at, window.timezone); if (window.start < window.end) { return window.days.includes(local.day) && local.clock >= window.start && local.clock < window.end; } // Overnight: a selected day opens at `start` and remains open on the next // local day until `end`. return ( (window.days.includes(local.day) && local.clock >= window.start) || (window.days.includes(previousDay(local.day)) && local.clock < window.end) ); } export type InterruptDisposition = "interrupt" | "digest" | "availability"; export function availabilityDisposition( window: WeeklyAvailability | undefined, category: InterruptCategory, at: number, ): Exclude { if (window === undefined || availabilityOpen(window, at) || window.bypass.includes(category)) { return "interrupt"; } return "availability"; } /** A question-kind category. Every ask surface — `message --category * decision-needed`, a `QUESTION:`-marked message, and `conductor_ask`'s * default — resolves to this one category, so treating it as * availability-deferred covers the whole question pathway at once (#596). */ export const QUESTION_KIND: InterruptCategory = "decision-needed"; /** * Decide one tier-2 category. `digest` means the category policy itself defers * it; `availability` means it was otherwise interruptible and may be released * when the configured window next opens. * * A question is availability-deferred, never digest-deferred (#596): the * category policy decides what may interrupt the operator's phone, but a * question's deferral is literally "the operator is not available" — it waits * for the window and the working-hours catch-up can release it at the next * opening. With no window configured the next opening is now, so a question * delivers rather than being silently bound to the daily digest. The * `interruptOn` list never applies to a question's deferral. */ export function interruptDisposition( policy: ReportingPolicy | undefined, category: InterruptCategory, at: number, ): InterruptDisposition { if ( policy !== undefined && category !== QUESTION_KIND && !policy.interruptOn.includes(category) ) return "digest"; return availabilityDisposition(policy?.availability, category, at); } export interface AvailabilityState { mode: "always" | "working" | "quiet"; timezone?: string; nextTransitionAt?: number; bypass: InterruptCategory[]; } /** Current mode plus the first real instant at which that mode changes. */ export function availabilityState(policy: ReportingPolicy | undefined, now: number): AvailabilityState { const window = policy?.availability; if (window === undefined) return { mode: "always", bypass: [] }; const from = Math.floor(now / MINUTE_MS) * MINUTE_MS; let cached = transitions.get(window); if (cached === undefined || from < cached.from || from >= cached.until) { const open = availabilityOpen(window, now); const end = now + TRANSITION_HORIZON_MS; let cursor = from + MINUTE_MS; while (cursor <= end && availabilityOpen(window, cursor) === open) cursor += MINUTE_MS; cached = { from, until: cursor, open, ...(cursor > end ? {} : { nextTransitionAt: cursor }), }; transitions.set(window, cached); } const { open, nextTransitionAt } = cached; return { mode: open ? "working" : "quiet", timezone: window.timezone, ...(nextTransitionAt === undefined ? {} : { nextTransitionAt }), bypass: [...window.bypass], }; } /** Stable operator-facing local timestamp for status and tick prompts. */ export function formatZonedMinute(at: number, timezone: string): string { const local = localMinute(at, timezone); return `${local.date} ${local.clock} ${timezone} (${new Date(at).toISOString()})`; } /** Compact local timestamp for a one-line future, e.g. "2026-08-24 09:00 UTC": * the same instant as {@link formatZonedMinute} without the ISO tail, so a * held-notice line read on a phone says plainly when the window opens. */ export function formatNextWindowOpening(at: number, timezone: string): string { const local = localMinute(at, timezone); return `${local.date} ${local.clock} ${timezone}`; } /** One prompt sentence; the runtime gate, not this prose, owns the decision. */ export function availabilityPrompt(policy: ReportingPolicy | undefined, now: number): string { const state = availabilityState(policy, now); if (state.mode === "always") { return "Operator availability (mechanical): 24-hour interrupts; no working-hours window is configured."; } const until = state.nextTransitionAt === undefined || state.timezone === undefined ? "the next configured transition" : formatZonedMinute(state.nextTransitionAt, state.timezone); const bypass = state.bypass.length === 0 ? "none" : state.bypass.join(", "); return state.mode === "working" ? `Operator availability (mechanical): working until ${until}; outside-hours bypass: ${bypass}.` : `Operator availability (mechanical): quiet until ${until}; only these categories bypass quiet hours: ${bypass}.`; }