250 lines
8.4 KiB
TypeScript
250 lines
8.4 KiB
TypeScript
"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<number, SignupStatus | undefined>
|
||
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<HTMLDivElement>(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 (
|
||
<div
|
||
ref={scrollRef}
|
||
className="h-full overflow-auto rounded-xl border border-white/20 bg-white/20 shadow-xl backdrop-blur-lg"
|
||
>
|
||
{/* Week Header */}
|
||
<div className="sticky top-0 z-10 grid grid-cols-[4rem_repeat(7,1fr)] border-b border-white/20 bg-white/10 backdrop-blur-lg">
|
||
<div className="p-2" />
|
||
{days.map((date, i) => {
|
||
const isToday = isSameDay(date, today)
|
||
return (
|
||
<div key={i} className="border-l border-white/20 p-2 text-center">
|
||
<div className="text-xs font-medium text-white/70">{WEEKDAY_LABELS[i]}</div>
|
||
<div
|
||
className={`mx-auto mt-1 flex h-8 w-8 items-center justify-center text-lg font-medium text-white ${
|
||
isToday ? "rounded-full bg-blue-500" : ""
|
||
}`}
|
||
>
|
||
{date.getDate()}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* Time grid */}
|
||
<div className="relative grid grid-cols-[4rem_repeat(7,1fr)]">
|
||
{/* Time labels */}
|
||
<div>
|
||
{timeSlots.map((hour) => (
|
||
<div
|
||
key={hour}
|
||
className="border-b border-white/10 pr-2 text-right text-xs text-white/70"
|
||
style={{ height: HOUR_HEIGHT }}
|
||
>
|
||
{formatHourLabel(hour)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Day columns */}
|
||
{days.map((day, dayIndex) => {
|
||
const isToday = isSameDay(day, today)
|
||
return (
|
||
<div
|
||
key={dayIndex}
|
||
className="relative border-l border-white/20"
|
||
style={{ height: gridHeight }}
|
||
>
|
||
{timeSlots.map((hour) => (
|
||
<div
|
||
key={hour}
|
||
className="border-b border-white/10"
|
||
style={{ height: HOUR_HEIGHT }}
|
||
/>
|
||
))}
|
||
|
||
{/* Current-time indicator line */}
|
||
{isToday && (
|
||
<div
|
||
className="pointer-events-none absolute left-0 right-0 z-10 flex items-center"
|
||
style={{ top: nowTop }}
|
||
>
|
||
<div className="-ml-1 h-2 w-2 shrink-0 rounded-full bg-red-500" />
|
||
<div className="h-px flex-1 bg-red-500" />
|
||
</div>
|
||
)}
|
||
|
||
{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 (
|
||
<button
|
||
key={pe.event.id + "-" + i}
|
||
onClick={() => onEventClick(pe.event)}
|
||
className={`animate-fade-in absolute flex flex-col items-stretch justify-start overflow-hidden rounded-md p-1.5 text-left text-xs text-white shadow-md transition-all duration-150 ease-in-out hover:z-20 hover:-translate-y-0.5 hover:shadow-lg ${style.bg} ${
|
||
style.dashed ? "border border-dashed border-white/70" : ""
|
||
} ${style.outline ? "border-2 border-white/80" : ""} ${
|
||
style.muted ? "opacity-60" : ""
|
||
}`}
|
||
style={{
|
||
top: pe.top,
|
||
height: pe.height,
|
||
left: `calc(${pe.lane * widthPct}% + 2px)`,
|
||
width: `calc(${widthPct}% - 4px)`,
|
||
...(style.striped ? STRIPE_STYLE : {}),
|
||
}}
|
||
>
|
||
<div className={cancelled ? "opacity-50" : undefined}>
|
||
<div className={`truncate font-medium ${cancelled ? "line-through" : ""}`}>
|
||
{pe.event.title}
|
||
</div>
|
||
{pe.height > 34 && (
|
||
<div className="mt-0.5 truncate text-[10px] opacity-80">
|
||
{formatMilitaryTime(pe.start)}
|
||
{" – "}
|
||
{formatMilitaryTime(pe.end)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|