/* Pure Offer Capacity Wave calculations for structured offer availability policy. Do not fetch CRM, browser, or private operational data. RFC-0322: add structured offer capacity wave helper. */ export interface OfferCapacityPolicy { timezone: string; startsAt: string; cadence: "monthly" | "fixed-days"; cadenceDays?: number; slotRange: { min: number; max: number }; maxSlotsPerWave: number; } export interface OfferCapacityReservation { waveId: string; slots: number; source: "bordbuch" | "manual"; asOf: string; } export interface OfferCapacityState { waveId: string; waveIndex: number; startsAt: string; endsAt: string; daysRemaining: number; progress: number; slotRange: { min: number; max: number }; maxSlots: number; reservedSlots?: number; openSlots?: number; availabilityStatus: "known" | "unknown" | "full"; } const DAY_MS = 24 * 60 * 60 * 1000; function parseDateOnly(value: string): Date { const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (!match) throw new Error(`Invalid date-only value: ${value}`); const [, year, month, day] = match; return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day))); } function dateOnly(date: Date, timezone: string): string { const parts = new Intl.DateTimeFormat("en-CA", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit", }).formatToParts(date); const get = (type: string) => parts.find((part) => part.type === type)?.value ?? ""; return `${get("year")}-${get("month")}-${get("day")}`; } function daysInMonth(year: number, monthIndex: number): number { return new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate(); } function addMonthsClamped(start: Date, months: number): Date { const year = start.getUTCFullYear(); const month = start.getUTCMonth() + months; const targetYear = year + Math.floor(month / 12); const targetMonth = ((month % 12) + 12) % 12; const day = Math.min(start.getUTCDate(), daysInMonth(targetYear, targetMonth)); return new Date(Date.UTC(targetYear, targetMonth, day)); } function addDays(date: Date, days: number): Date { return new Date(date.getTime() + days * DAY_MS); } function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } function monthDistance(from: Date, to: Date): number { return ( (to.getUTCFullYear() - from.getUTCFullYear()) * 12 + (to.getUTCMonth() - from.getUTCMonth()) ); } export function calculateOfferCapacityState( policy: OfferCapacityPolicy, reservations: readonly OfferCapacityReservation[] = [], now: Date = new Date(), ): OfferCapacityState { const start = parseDateOnly(policy.startsAt); const today = parseDateOnly(dateOnly(now, policy.timezone)); let waveIndex = 0; let waveStart = start; let waveEnd: Date; if (policy.cadence === "monthly") { waveIndex = Math.max(0, monthDistance(start, today)); waveStart = addMonthsClamped(start, waveIndex); if (waveStart > today && waveIndex > 0) { waveIndex -= 1; waveStart = addMonthsClamped(start, waveIndex); } waveEnd = addMonthsClamped(start, waveIndex + 1); } else { const cadenceDays = policy.cadenceDays ?? 0; if (cadenceDays <= 0) throw new Error("fixed-days cadence requires cadenceDays > 0"); waveIndex = Math.max( 0, Math.floor((today.getTime() - start.getTime()) / (cadenceDays * DAY_MS)), ); waveStart = addDays(start, waveIndex * cadenceDays); waveEnd = addDays(waveStart, cadenceDays); } const waveId = policy.cadence === "monthly" ? `${waveStart.getUTCFullYear()}-${String(waveStart.getUTCMonth() + 1).padStart(2, "0")}` : `${policy.startsAt}+${waveIndex}`; const duration = Math.max(1, waveEnd.getTime() - waveStart.getTime()); const elapsed = clamp(today.getTime() - waveStart.getTime(), 0, duration); const activeReservations = reservations.filter((reservation) => reservation.waveId === waveId); const reservedSlots = activeReservations.length > 0 ? activeReservations.reduce((sum, reservation) => sum + reservation.slots, 0) : undefined; const openSlots = reservedSlots === undefined ? undefined : clamp(policy.maxSlotsPerWave - reservedSlots, 0, policy.maxSlotsPerWave); return { waveId, waveIndex, startsAt: dateOnly(waveStart, "UTC"), endsAt: dateOnly(waveEnd, "UTC"), daysRemaining: Math.max(0, Math.ceil((waveEnd.getTime() - today.getTime()) / DAY_MS)), progress: Math.round((elapsed / duration) * 100), slotRange: policy.slotRange, maxSlots: policy.maxSlotsPerWave, ...(reservedSlots === undefined ? {} : { reservedSlots }), ...(openSlots === undefined ? {} : { openSlots }), availabilityStatus: openSlots === undefined ? "unknown" : openSlots <= 0 ? "full" : "known", }; }