/** * Pure peak/off-peak window logic. No pi imports — runs anywhere, tested * against the CC0 conformance vectors for DeepSeek's billing rule. * * Two load-bearing details (both from vendor rule wording, see * https://github.com/xyzs996/deepseek-peak-hours): * 1. The weekday is read on the billing calendar (the instant shifted by * calendarOffsetMinutes), not on UTC — the two calendars disagree over * 16:00-24:00 UTC and no live-schedule test can tell them apart. * 2. A `weekendOffPeakFrom` gate is evaluated on the instant being * classified, never on "now", so historical instants bill the way they * actually were billed. */ import type { PeakSchedule } from "./schedules.ts"; const MINUTE_MS = 60_000; /** "HH:mm" → minutes since midnight. */ export function parseHm(value: string): number { const [h, m] = value.split(":"); return Number(h) * 60 + Number(m); } /** The instant as seen on a wall clock fixed at the given UTC offset. */ function shifted(instant: Date, offsetMinutes: number): Date { return new Date(instant.getTime() + offsetMinutes * MINUTE_MS); } /** ISO weekday (1=Mon..7=Sun) of a UTC-based Date. */ function isoWeekday(d: Date): number { const wd = d.getUTCDay(); return wd === 0 ? 7 : wd; } function secondsOfDay(d: Date): number { return d.getUTCHours() * 3600 + d.getUTCMinutes() * 60 + d.getUTCSeconds(); } /** Is `instant` inside a peak window of `schedule`? */ export function isPeakAt(schedule: PeakSchedule, instant: Date): boolean { const local = shifted(instant, schedule.calendarOffsetMinutes); const weekday = isoWeekday(local); const secs = secondsOfDay(local); // Weekend/weekday override, gated on the instant being classified. if (schedule.weekendOffPeakFrom !== null) { const effective = new Date(schedule.weekendOffPeakFrom).getTime(); if (instant.getTime() >= effective && !schedule.peakWeekdays.includes(weekday)) { return false; } } else if (!schedule.peakWeekdays.includes(weekday)) { return false; } for (const w of schedule.windows) { const start = parseHm(w.start) * 60; const end = parseHm(w.end) * 60; if (start <= secs && secs < end) return true; } return false; } export interface PhaseChange { at: Date; /** The phase that begins at `at`. */ peak: boolean; } /** * The next instant the phase flips, scanning billing-calendar window edges * (plus local midnight) up to 10 days ahead — enough for the worst case * (Friday evening → Monday morning, ~63h). Returns null if no flip is * found within the horizon. */ export function nextChangeAt(schedule: PeakSchedule, instant: Date): PhaseChange | null { const base = isPeakAt(schedule, instant); const local = shifted(instant, schedule.calendarOffsetMinutes); const y = local.getUTCFullYear(); const m = local.getUTCMonth(); const d = local.getUTCDate(); const offsetMs = schedule.calendarOffsetMinutes * MINUTE_MS; // A future rule-change instant can flip the phase on its own. if (schedule.weekendOffPeakFrom !== null) { const effective = new Date(schedule.weekendOffPeakFrom); if (effective.getTime() > instant.getTime() && isPeakAt(schedule, effective) !== base) { return { at: effective, peak: !base }; } } const edges = new Set([0]); // local midnight: the calendar-day boundary for (const w of schedule.windows) { edges.add(parseHm(w.start)); edges.add(parseHm(w.end)); } const sorted = [...edges].sort((a, b) => a - b); for (let day = 0; day <= 10; day++) { const dayStartUtc = Date.UTC(y, m, d + day); for (const e of sorted) { const candidate = new Date(dayStartUtc + e * MINUTE_MS - offsetMs); if (candidate.getTime() <= instant.getTime()) continue; if (isPeakAt(schedule, candidate) !== base) { return { at: candidate, peak: !base }; } } } return null; }