import { computed, type Ref } from 'vue' import type { ScheduleView, ScheduleEvent, ScheduleBlockedSlot, ScheduleEventStatus, } from '../types/components' import { formatTime } from './useTimeUtils' type ReadableRef = Readonly> export const SCHEDULE_SLOT_HEIGHT = 40 export interface UseScheduleCalendarLayoutOptions { currentView: ReadableRef currentDate: ReadableRef now: ReadableRef events: ReadableRef blockedSlots: ReadableRef dayStartHour: ReadableRef dayEndHour: ReadableRef slotDuration: ReadableRef weekStartsOn: ReadableRef<0 | 1> locale: ReadableRef showNowIndicator: ReadableRef slotHeight?: ReadableRef } /** Builds ScheduleCalendar date ranges, event layout styles, and display labels. */ export function useScheduleCalendarLayout(options: UseScheduleCalendarLayoutOptions) { const slotHeight = computed(() => options.slotHeight?.value ?? SCHEDULE_SLOT_HEIGHT) const totalSlots = computed(() => { return ((options.dayEndHour.value - options.dayStartHour.value) * 60) / options.slotDuration.value }) const timeLabels = computed(() => buildTimeLabels( options.dayStartHour.value, options.slotDuration.value, totalSlots.value, )) const weekDays = computed(() => buildWeekDays(options.currentDate.value, options.weekStartsOn.value)) const visibleDays = computed(() => { if (options.currentView.value === 'day') return [options.currentDate.value] if (options.currentView.value === 'week') return weekDays.value return [] }) const headerTitle = computed(() => buildHeaderTitle( options.currentView.value, options.currentDate.value, weekDays.value, options.locale.value, )) const monthDays = computed(() => buildMonthDays(options.currentDate.value, options.weekStartsOn.value)) const monthWeekdayLabels = computed(() => buildMonthWeekdayLabels( options.locale.value, options.weekStartsOn.value, )) function isToday(date: Date): boolean { return isSameDay(date, options.now.value) } function getEventStyle(event: ScheduleEvent, dayDate: Date): Record | null { return getTimedItemStyle( event.start, event.end, dayDate, options.dayStartHour.value, options.dayEndHour.value, options.slotDuration.value, slotHeight.value, true, slotHeight.value / 2, ) } function getBlockedStyle(slot: ScheduleBlockedSlot): Record { return getTimedItemStyle( slot.start, slot.end, new Date(slot.start), options.dayStartHour.value, options.dayEndHour.value, options.slotDuration.value, slotHeight.value, false, 0, ) ?? { top: '0px', height: '0px' } } function getNowIndicatorStyle(): Record | null { if (!options.showNowIndicator.value) return null const nowMin = dateToMinutes(options.now.value) const dayStartMin = options.dayStartHour.value * 60 const dayEndMin = options.dayEndHour.value * 60 if (nowMin < dayStartMin || nowMin > dayEndMin) return null const pixelsPerMin = slotHeight.value / options.slotDuration.value return { top: `${(nowMin - dayStartMin) * pixelsPerMin}px` } } function getEventsForDay(day: Date): ScheduleEvent[] { return eventsForDate(options.events.value, day) } function getEventsForDate(date: Date): ScheduleEvent[] { return eventsForDate(options.events.value, date) } function getBlockedForDay(day: Date): ScheduleBlockedSlot[] { return options.blockedSlots.value.filter((slot) => isSameDay(new Date(slot.start), day)) } return { totalSlots, timeLabels, weekDays, visibleDays, headerTitle, monthDays, monthWeekdayLabels, isToday, isSameDay, getEventStyle, getEventsForDay, getEventsForDate, getBlockedForDay, getBlockedStyle, getNowIndicatorStyle, getStatusClass, getMonthStatusClass, formatEventTime, } } export function parseScheduleModelDate(value?: Date | string): Date { if (!value) return new Date() if (value instanceof Date) return value const d = new Date(value) return isNaN(d.getTime()) ? new Date() : d } export function getWeekStart(date: Date, weekStartsOn: 0 | 1): Date { const d = new Date(date) const day = d.getDay() const diff = (day - weekStartsOn + 7) % 7 d.setDate(d.getDate() - diff) return d } export function isSameDay(a: Date, b: Date): boolean { return a.toDateString() === b.toDateString() } function buildTimeLabels(dayStartHour: number, slotDuration: number, totalSlots: number): string[] { const labels: string[] = [] for (let i = 0; i <= totalSlots; i++) { const totalMinutes = dayStartHour * 60 + i * slotDuration const hour = Math.floor(totalMinutes / 60) const minute = totalMinutes % 60 labels.push(formatTime(hour, minute, '24h')) } return labels } function buildWeekDays(currentDate: Date, weekStartsOn: 0 | 1): Date[] { const days: Date[] = [] const start = getWeekStart(currentDate, weekStartsOn) for (let i = 0; i < 7; i++) { const d = new Date(start) d.setDate(d.getDate() + i) days.push(d) } return days } function buildHeaderTitle( view: ScheduleView, currentDate: Date, weekDays: Date[], locale: string, ): string { if (view === 'day') { return currentDate.toLocaleDateString(locale, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', }) } if (view === 'week') { const first = weekDays[0] const last = weekDays[6] const sameMonth = first.getMonth() === last.getMonth() if (sameMonth) { return `${first.toLocaleDateString(locale, { month: 'short', day: 'numeric' })} - ${last.getDate()}, ${last.getFullYear()}` } return `${first.toLocaleDateString(locale, { month: 'short', day: 'numeric' })} - ${last.toLocaleDateString(locale, { month: 'short', day: 'numeric' })}, ${last.getFullYear()}` } return currentDate.toLocaleDateString(locale, { year: 'numeric', month: 'long' }) } function buildMonthDays(currentDate: Date, weekStartsOn: 0 | 1): { date: Date; isCurrentMonth: boolean }[] { const year = currentDate.getFullYear() const month = currentDate.getMonth() const firstDay = new Date(year, month, 1) const lastDay = new Date(year, month + 1, 0) const days: { date: Date; isCurrentMonth: boolean }[] = [] let startPadding = firstDay.getDay() - weekStartsOn if (startPadding < 0) startPadding += 7 for (let i = startPadding - 1; i >= 0; i--) { days.push({ date: new Date(year, month, -i), isCurrentMonth: false }) } for (let i = 1; i <= lastDay.getDate(); i++) { days.push({ date: new Date(year, month, i), isCurrentMonth: true }) } const remaining = 42 - days.length for (let i = 1; i <= remaining; i++) { days.push({ date: new Date(year, month + 1, i), isCurrentMonth: false }) } return days } function buildMonthWeekdayLabels(locale: string, weekStartsOn: 0 | 1): string[] { const labels: string[] = [] const base = new Date(2024, 0, weekStartsOn === 1 ? 1 : 7) for (let i = 0; i < 7; i++) { const d = new Date(base) d.setDate(d.getDate() + i) labels.push(d.toLocaleDateString(locale, { weekday: 'short' })) } return labels } function getTimedItemStyle( startValue: string, endValue: string, dayDate: Date, dayStartHour: number, dayEndHour: number, slotDuration: number, slotHeight: number, requireSameDayEdge: boolean, minimumHeight: number, ): Record | null { const start = new Date(startValue) const end = new Date(endValue) if (requireSameDayEdge && !isSameDay(start, dayDate) && !isSameDay(end, dayDate)) return null const dayStartMin = dayStartHour * 60 const startMin = Math.max(dateToMinutes(start), dayStartMin) - dayStartMin const endMin = Math.min(dateToMinutes(end), dayEndHour * 60) - dayStartMin const pixelsPerMin = slotHeight / slotDuration return { top: `${startMin * pixelsPerMin}px`, height: `${Math.max((endMin - startMin) * pixelsPerMin, minimumHeight)}px`, } } function eventsForDate(events: readonly ScheduleEvent[], date: Date): ScheduleEvent[] { return events.filter((event) => isSameDay(new Date(event.start), date)) } function dateToMinutes(date: Date): number { return date.getHours() * 60 + date.getMinutes() } function getStatusClass(status?: ScheduleEventStatus): string { if (!status) return 'mint-schedule__event--confirmed' return `mint-schedule__event--${status}` } function getMonthStatusClass(status?: ScheduleEventStatus): string { if (!status) return 'mint-schedule__month-event--confirmed' return `mint-schedule__month-event--${status}` } function formatEventTime(event: ScheduleEvent): string { const start = new Date(event.start) const end = new Date(event.end) return `${formatTime(start.getHours(), start.getMinutes())} - ${formatTime(end.getHours(), end.getMinutes())}` }