Files
calendar/frontend(experimental)/newchat/components/week-view.tsx
T
2026-08-20 12:23:30 +02:00

250 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
)
}