import type { IJMAPClient } from "@/lib/jmap/client-interface"; import type { CalendarEvent } from "@/lib/jmap/types"; import { addMinutes } from "date-fns"; export interface FreeBusySlot { start: Date; end: Date; status: "free" | "busy" | "tentative" | "unavailable" | "unknown"; } const SLOT_MINUTES = 30; function clampToSlotStart(d: Date): Date { const clone = new Date(d); clone.setSeconds(0, 0); const mins = clone.getMinutes(); const remainder = mins % SLOT_MINUTES; if (remainder !== 0) { clone.setMinutes(mins - remainder, 0, 0); } return clone; } function buildSlots(start: Date, end: Date): FreeBusySlot[] { const slots: FreeBusySlot[] = []; let cursor = new Date(start); while (cursor < end) { const slotEnd = addMinutes(cursor, SLOT_MINUTES); slots.push({ start: new Date(cursor), end: slotEnd > end ? new Date(end) : slotEnd, status: "unknown", }); cursor = slotEnd; } return slots; } interface EventRange { start: Date; end: Date; freeBusyStatus: CalendarEvent["freeBusyStatus"]; eventStatus: CalendarEvent["status"]; } function getEventRange(event: CalendarEvent): EventRange { return { start: new Date(event.start), end: new Date(new Date(event.start).getTime() + parseDurationMs(event.duration)), freeBusyStatus: event.freeBusyStatus, eventStatus: event.status, }; } // NOTE: this duplicates the ISO 8601 duration parsing in // components/calendar/event-card.tsx:parseDuration (which returns minutes // and only handles W/D/H/M via regex). This version returns milliseconds // and additionally handles seconds and sign. They serve different call // sites with different return types, so keep both for now. function parseDurationMs(duration: string): number { let ms = 0; let sign = 1; let s = duration; if (s.startsWith("-")) { sign = -1; s = s.slice(1); } if (s.startsWith("+")) s = s.slice(1); if (!s.startsWith("P")) return 0; s = s.slice(1); const tIdx = s.indexOf("T"); const datePart = tIdx >= 0 ? s.slice(0, tIdx) : s; const timePart = tIdx >= 0 ? s.slice(tIdx + 1) : ""; let num = ""; for (const ch of datePart) { if (ch >= "0" && ch <= "9") { num += ch; } else { const v = parseInt(num, 10) || 0; if (ch === "W") ms += v * 7 * 24 * 60 * 60 * 1000; else if (ch === "D") ms += v * 24 * 60 * 60 * 1000; num = ""; } } for (const ch of timePart) { if (ch >= "0" && ch <= "9") { num += ch; } else { const v = parseInt(num, 10) || 0; if (ch === "H") ms += v * 60 * 60 * 1000; else if (ch === "M") ms += v * 60 * 1000; else if (ch === "S") ms += v * 1000; num = ""; } } return ms * sign; } function eventsOverlap(eventStart: Date, eventEnd: Date, slotStart: Date, slotEnd: Date): boolean { return eventStart < slotEnd && eventEnd > slotStart; } function slotStatusFromEvent( event: CalendarEvent, participantStatus: string | null ): FreeBusySlot["status"] { if (event.status === "cancelled") return "free"; if (participantStatus === "declined") return "free"; if (participantStatus === "tentative") return "tentative"; if (event.freeBusyStatus === "free") return "free"; if (event.freeBusyStatus === "busy") return "busy"; if (participantStatus === "accepted") return "busy"; if (participantStatus === "needs-action") return "tentative"; return "busy"; } export async function fetchFreeBusy( client: IJMAPClient, participants: { email: string }[], start: Date, end: Date, accountId?: string ): Promise> { const result = new Map(); const slots = buildSlots(clampToSlotStart(start), end); for (const p of participants) { const key = p.email.toLowerCase(); const participantSlots: FreeBusySlot[] = slots.map((s) => ({ start: new Date(s.start), end: new Date(s.end), status: "unknown" as const, })); result.set(key, participantSlots); } try { const events = await client.queryAllCalendarEvents( { after: start.toISOString(), before: end.toISOString() }, [{ property: "start", isAscending: true }], undefined, accountId ); for (const event of events) { if (event.status === "cancelled") continue; if (!event.participants) continue; const range = getEventRange(event); for (const key of result.keys()) { const participant = Object.values(event.participants).find( (p) => p.email.toLowerCase() === key ); if (!participant) continue; const status = slotStatusFromEvent(event, participant.participationStatus); const participantSlots = result.get(key)!; for (const slot of participantSlots) { if (eventsOverlap(range.start, range.end, slot.start, slot.end)) { if (status === "busy" || slot.status === "unknown") { slot.status = status; } else if (status === "tentative" && slot.status === "free") { slot.status = "tentative"; } } } } } } catch { // Return unknown statuses for all slots on fetch failure } return result; } export function isWorkingHour(hour: number, workStart = 8, workEnd = 18): boolean { return hour >= workStart && hour < workEnd; }