"use client" import { useEffect, useMemo, useRef } from "react" import type { CalendarEvent, SignupStatus } from "@/lib/types" import { STRIPE_STYLE, getEventStyle } from "@/lib/room-theme" import { addDays, isSameDay, parseDateTime, WEEKDAY_LABELS } from "@/lib/date-utils" const HOUR_HEIGHT = 64 // px per hour interface WeekViewProps { weekStart: Date events: CalendarEvent[] myResponses: Record onEventClick: (event: CalendarEvent) => void } interface PositionedEvent { event: CalendarEvent start: Date end: Date top: number height: number lane: number lanes: number } // Military (24h) time, e.g. "09:00", "14:30". function formatMilitaryTime(date: Date): string { const h = String(date.getHours()).padStart(2, "0") const m = String(date.getMinutes()).padStart(2, "0") return `${h}:${m}` } function formatHourLabel(h: number): string { return `${String(h % 24).padStart(2, "0")}:00` } // Assign overlapping events to side-by-side lanes within a single day. function layoutDayEvents( events: { event: CalendarEvent; start: Date; end: Date }[], ): PositionedEvent[] { const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime()) const result: PositionedEvent[] = [] let cluster: typeof sorted = [] let clusterEnd = 0 const flush = () => { if (cluster.length === 0) return // Greedy lane assignment within the cluster. const laneEnds: number[] = [] const withLanes = cluster.map((item) => { let lane = laneEnds.findIndex((end) => end <= item.start.getTime()) if (lane === -1) { lane = laneEnds.length laneEnds.push(item.end.getTime()) } else { laneEnds[lane] = item.end.getTime() } return { ...item, lane } }) const lanes = laneEnds.length for (const item of withLanes) { const startHrs = item.start.getHours() + item.start.getMinutes() / 60 const endHrs = item.end.getHours() + item.end.getMinutes() / 60 const safeEnd = Math.max(endHrs, startHrs + 0.5) result.push({ event: item.event, start: item.start, end: item.end, top: startHrs * HOUR_HEIGHT, height: (safeEnd - startHrs) * HOUR_HEIGHT, lane: item.lane, lanes, }) } cluster = [] clusterEnd = 0 } for (const item of sorted) { if (cluster.length === 0 || item.start.getTime() < clusterEnd) { cluster.push(item) clusterEnd = Math.max(clusterEnd, item.end.getTime()) } else { flush() cluster.push(item) clusterEnd = item.end.getTime() } } flush() return result } export function WeekView({ weekStart, events, myResponses, onEventClick }: WeekViewProps) { const scrollRef = useRef(null) const hasAutoScrolled = useRef(false) const days = useMemo( () => Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)), [weekStart], ) // Full 24-hour day, always. const timeSlots = useMemo(() => Array.from({ length: 24 }, (_, i) => i), []) const gridHeight = 24 * HOUR_HEIGHT const positionedByDay = useMemo(() => { return days.map((day) => { const dayEvents = events .map((event) => ({ event, start: parseDateTime(event.start_time), end: parseDateTime(event.end_time), })) .filter((x) => !isNaN(x.start.getTime()) && isSameDay(x.start, day)) return layoutDayEvents(dayEvents) }) }, [days, events]) const today = new Date() const nowMinutes = today.getHours() * 60 + today.getMinutes() const nowTop = (nowMinutes / 60) * HOUR_HEIGHT const todayColumnIndex = days.findIndex((d) => isSameDay(d, today)) // Auto-scroll so the current time sits vertically centered in the // scrollable area, once per mount (and whenever the visible week // changes to include today). useEffect(() => { if (!scrollRef.current) return if (todayColumnIndex === -1) return if (hasAutoScrolled.current) return const container = scrollRef.current const target = nowTop - container.clientHeight / 2 container.scrollTop = Math.max(0, target) hasAutoScrolled.current = true }, [todayColumnIndex, nowTop]) return (
{/* Week Header */}
{days.map((date, i) => { const isToday = isSameDay(date, today) return (
{WEEKDAY_LABELS[i]}
{date.getDate()}
) })}
{/* Time grid */}
{/* Time labels */}
{timeSlots.map((hour) => (
{formatHourLabel(hour)}
))}
{/* Day columns */} {days.map((day, dayIndex) => { const isToday = isSameDay(day, today) return (
{timeSlots.map((hour) => (
))} {/* Current-time indicator line */} {isToday && (
)} {positionedByDay[dayIndex].map((pe, i) => { const style = getEventStyle(pe.event, myResponses[pe.event.id]) const cancelled = pe.event.status === "cancelled" const widthPct = 100 / pe.lanes return ( ) })}
) })}
) }