import { addDays, differenceInCalendarDays, eachDayOfInterval, endOfMonth, endOfWeek, format, isValid, parseISO, startOfMonth, startOfWeek, } from "date-fns"; import { fromZonedTime } from "date-fns-tz"; import { formatZonedDate } from "./zonedDate"; import type { CalendarEventRecord } from "../types/calendar"; export function formatLocalDate(date: Date): string { return format(date, "yyyy-MM-dd"); } export function formatLocalTime(date: Date): string { return format(date, "HH:mm"); } export function toISOFromLocal(dateStr: string, timeStr = "00:00"): string { const [year, month, day] = dateStr.split("-").map(Number); const [hour, minute] = timeStr.split(":").map(Number); return new Date(year, month - 1, day, hour || 0, minute || 0).toISOString(); } export function toISOFromZoned( dateStr: string, timeStr: string, timeZone: string, ): string { const wall = `${dateStr}T${timeStr}:00`; const instant = fromZonedTime(wall, timeZone); if ( !isValid(instant) || formatZonedDate(instant, timeZone, "yyyy-MM-dd'T'HH:mm") !== `${dateStr}T${timeStr}` ) throw new Error("That time does not exist in the selected time zone."); return instant.toISOString(); } export function isAllDayEvent(record: CalendarEventRecord): boolean { return record.allDay === true; } function startDate(record: CalendarEventRecord): string { if (record.startDate) return record.startDate; const start = new Date(record.startsAt); return isValid(start) ? formatZonedDate(start, 'UTC', 'yyyy-MM-dd') : ''; } function endDate(record: CalendarEventRecord): string { if (record.endDate) return record.endDate; const end = new Date(record.endsAt || record.startsAt); return isValid(end) ? formatZonedDate(end, 'UTC', 'yyyy-MM-dd') : ''; } function validDateString(value: string): boolean { return /^\d{4}-\d{2}-\d{2}$/.test(value) && isValid(parseISO(value)); } export function eventOccursOnDay( record: CalendarEventRecord, day: Date, ): boolean { if (!isValid(day)) return false; const ds = formatLocalDate(day); if (isAllDayEvent(record)) { const start = startDate(record); const end = endDate(record); return ( validDateString(start) && validDateString(end) && end > start && ds >= start && ds < end ); } const start = new Date(record.startsAt); const end = record.endsAt ? new Date(record.endsAt) : new Date(start.getTime() + 3600000); if (!isValid(start) || !isValid(end) || end < start) return false; const dayStart = new Date(day.getFullYear(), day.getMonth(), day.getDate()); if (end.getTime() === start.getTime()) return start >= dayStart && start < addDays(dayStart, 1); return start < addDays(dayStart, 1) && end > dayStart; } export function getEventDaySegment( record: CalendarEventRecord, day: Date, ): { startMinute: number; endMinute: number; allDay: boolean } | null { if (!eventOccursOnDay(record, day)) return null; if (isAllDayEvent(record)) return { startMinute: 0, endMinute: 1440, allDay: true }; const start = new Date(record.startsAt); const end = record.endsAt ? new Date(record.endsAt) : new Date(start.getTime() + 3600000); const dayString = formatLocalDate(day); const startString = formatLocalDate(start); const endString = formatLocalDate(end); const wallMinutes = (date: Date) => date.getHours() * 60 + date.getMinutes(); const startMinute = startString < dayString ? 0 : wallMinutes(start); const endMinute = endString > dayString ? 1440 : wallMinutes(end); return { startMinute, endMinute: Math.max(startMinute, endMinute), allDay: false, }; } export function getEventTimeLabel( record: CalendarEventRecord, day?: Date, ): string { if (isAllDayEvent(record)) return "All Day"; const start = new Date(record.startsAt); const end = record.endsAt ? new Date(record.endsAt) : new Date(start.getTime() + 3600000); if (!isValid(start) || !isValid(end) || end <= start) return ""; if (!day) return `${format(start, "HH:mm")} – ${format(end, "HH:mm")}`; const segment = getEventDaySegment(record, day); if (!segment) return ""; const label = (minutes: number) => `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`; return `${label(segment.startMinute)} – ${label(segment.endMinute)}`; } export function formatEventTimeRange( startsAt: string, endsAt?: string, ): string { return getEventTimeLabel({ startsAt, endsAt, name: "", $type: "community.lexicon.calendar.event", createdAt: "", }); } export function isEndTimeValid( startDateStr: string, startTimeStr: string, endDateStr: string, endTimeStr: string, timeZone?: string, ): boolean { try { const start = timeZone ? toISOFromZoned(startDateStr, startTimeStr, timeZone) : toISOFromLocal(startDateStr, startTimeStr); const end = timeZone ? toISOFromZoned(endDateStr, endTimeStr, timeZone) : toISOFromLocal(endDateStr, endTimeStr); return new Date(end).getTime() >= new Date(start).getTime(); } catch { return false; } } export function rescheduleEvent( record: CalendarEventRecord, targetDay: Date, targetHour?: number, ): Partial { if (isAllDayEvent(record)) { const duration = Math.max( 1, differenceInCalendarDays( parseISO(endDate(record)), parseISO(startDate(record)), ), ); const start = formatLocalDate(targetDay); const end = formatLocalDate(addDays(targetDay, duration)); return { allDay: true, startDate: start, endDate: end, startsAt: `${start}T00:00:00.000Z`, endsAt: `${end}T00:00:00.000Z`, }; } const start = new Date(record.startsAt); const end = record.endsAt ? new Date(record.endsAt) : new Date(start.getTime() + 3600000); const target = new Date(targetDay); target.setHours( targetHour ?? start.getHours(), start.getMinutes(), start.getSeconds(), start.getMilliseconds(), ); return { startsAt: target.toISOString(), endsAt: new Date( target.getTime() + end.getTime() - start.getTime(), ).toISOString(), }; } export function getMonthGrid(currentDate: Date) { const monthStart = startOfMonth(currentDate); const monthEnd = endOfMonth(monthStart); const startDate = startOfWeek(monthStart); const endDate = endOfWeek(monthEnd); const days = eachDayOfInterval({ start: startDate, end: endDate }); return { monthStart, monthEnd, startDate, endDate, days, weeksCount: Math.ceil(days.length / 7), }; }