304 lines
11 KiB
TypeScript
304 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import { useMemo } from 'react'
|
|
import { useCalendarStore, useEffectiveSpatialGranularity } from '@/lib/store'
|
|
import { getSemanticLocationLabel } from '@/lib/location'
|
|
import { useMonthEvents } from '@/hooks/useEvents'
|
|
import { TemporalGranularity } from '@/lib/types'
|
|
import type { EventListItem } from '@/lib/types'
|
|
import { EventDetailModal } from './EventDetailModal'
|
|
import { clsx } from 'clsx'
|
|
|
|
const HOURS = Array.from({ length: 24 }, (_, i) => i)
|
|
|
|
function formatHour(hour: number): string {
|
|
if (hour === 0) return '12 AM'
|
|
if (hour < 12) return `${hour} AM`
|
|
if (hour === 12) return '12 PM'
|
|
return `${hour - 12} PM`
|
|
}
|
|
|
|
function getWeekDays(date: Date): Date[] {
|
|
const start = new Date(date)
|
|
start.setDate(date.getDate() - date.getDay()) // Start on Sunday
|
|
start.setHours(0, 0, 0, 0)
|
|
|
|
return Array.from({ length: 7 }, (_, i) => {
|
|
const d = new Date(start)
|
|
d.setDate(start.getDate() + i)
|
|
return d
|
|
})
|
|
}
|
|
|
|
function getEventPosition(event: EventListItem, dayStart: Date) {
|
|
const start = new Date(event.start)
|
|
const end = new Date(event.end)
|
|
const dayEnd = new Date(dayStart)
|
|
dayEnd.setHours(23, 59, 59, 999)
|
|
|
|
const effectiveStart = start < dayStart ? dayStart : start
|
|
const effectiveEnd = end > dayEnd ? dayEnd : end
|
|
|
|
const startMinutes = effectiveStart.getHours() * 60 + effectiveStart.getMinutes()
|
|
const endMinutes = effectiveEnd.getHours() * 60 + effectiveEnd.getMinutes()
|
|
const duration = Math.max(endMinutes - startMinutes, 30)
|
|
|
|
return {
|
|
top: (startMinutes / (24 * 60)) * 100,
|
|
height: (duration / (24 * 60)) * 100,
|
|
}
|
|
}
|
|
|
|
export function WeekView() {
|
|
const {
|
|
currentDate,
|
|
selectedEventId,
|
|
setSelectedEventId,
|
|
setCurrentDate,
|
|
setTemporalGranularity,
|
|
hiddenSources,
|
|
} = useCalendarStore()
|
|
const effectiveSpatial = useEffectiveSpatialGranularity()
|
|
|
|
const weekDays = useMemo(() => getWeekDays(currentDate), [currentDate])
|
|
|
|
const year = currentDate.getFullYear()
|
|
const month = currentDate.getMonth() + 1
|
|
|
|
// We may need events from adjacent months if the week spans months
|
|
const { data: eventsData, isLoading } = useMonthEvents(year, month)
|
|
|
|
const today = useMemo(() => {
|
|
const d = new Date()
|
|
d.setHours(0, 0, 0, 0)
|
|
return d
|
|
}, [])
|
|
|
|
const nowPosition = useMemo(() => {
|
|
const now = new Date()
|
|
const minutes = now.getHours() * 60 + now.getMinutes()
|
|
return (minutes / (24 * 60)) * 100
|
|
}, [])
|
|
|
|
// Group events by day key
|
|
const eventsByDay = useMemo(() => {
|
|
const map = new Map<string, { allDay: EventListItem[]; timed: EventListItem[] }>()
|
|
if (!eventsData?.results) return map
|
|
|
|
for (const event of eventsData.results) {
|
|
if (hiddenSources.includes(event.source)) continue
|
|
|
|
for (const day of weekDays) {
|
|
const dayKey = day.toISOString().split('T')[0]
|
|
const eventStart = new Date(event.start).toISOString().split('T')[0]
|
|
const eventEnd = new Date(event.end).toISOString().split('T')[0]
|
|
|
|
if (eventStart <= dayKey && eventEnd >= dayKey) {
|
|
if (!map.has(dayKey)) map.set(dayKey, { allDay: [], timed: [] })
|
|
const bucket = map.get(dayKey)!
|
|
if (event.all_day) {
|
|
if (!bucket.allDay.find((e) => e.id === event.id)) bucket.allDay.push(event)
|
|
} else {
|
|
if (!bucket.timed.find((e) => e.id === event.id)) bucket.timed.push(event)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return map
|
|
}, [eventsData?.results, weekDays, hiddenSources])
|
|
|
|
const hasAllDayEvents = useMemo(
|
|
() => Array.from(eventsByDay.values()).some((d) => d.allDay.length > 0),
|
|
[eventsByDay]
|
|
)
|
|
|
|
const handleDayClick = (date: Date) => {
|
|
setCurrentDate(date)
|
|
setTemporalGranularity(TemporalGranularity.DAY)
|
|
}
|
|
|
|
const weekRange = `${weekDays[0].toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} \u2013 ${weekDays[6].toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}`
|
|
|
|
return (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 flex flex-col h-full">
|
|
{/* Week header */}
|
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{weekRange}</h2>
|
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
|
Week {Math.ceil((currentDate.getDate() + new Date(year, month - 1, 1).getDay()) / 7)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Day headers */}
|
|
<div className="flex border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
|
{/* Spacer for time column */}
|
|
<div className="w-16 flex-shrink-0" />
|
|
|
|
{weekDays.map((day) => {
|
|
const isToday = day.getTime() === today.getTime()
|
|
const dayKey = day.toISOString().split('T')[0]
|
|
|
|
return (
|
|
<button
|
|
key={dayKey}
|
|
onClick={() => handleDayClick(day)}
|
|
className={clsx(
|
|
'flex-1 py-2 text-center border-l border-gray-200 dark:border-gray-700 transition-colors',
|
|
'hover:bg-gray-50 dark:hover:bg-gray-700/50',
|
|
isToday && 'bg-blue-50 dark:bg-blue-900/20'
|
|
)}
|
|
>
|
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
{day.toLocaleDateString('en-US', { weekday: 'short' })}
|
|
</div>
|
|
<div
|
|
className={clsx(
|
|
'text-lg font-semibold',
|
|
isToday
|
|
? 'text-blue-600 dark:text-blue-400'
|
|
: 'text-gray-900 dark:text-white'
|
|
)}
|
|
>
|
|
{day.getDate()}
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* All-day events row */}
|
|
{hasAllDayEvents && (
|
|
<div className="flex border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
|
<div className="w-16 flex-shrink-0 text-xs text-gray-400 text-right pr-2 py-1">
|
|
All day
|
|
</div>
|
|
{weekDays.map((day) => {
|
|
const dayKey = day.toISOString().split('T')[0]
|
|
const dayEvents = eventsByDay.get(dayKey)
|
|
return (
|
|
<div
|
|
key={`allday-${dayKey}`}
|
|
className="flex-1 border-l border-gray-200 dark:border-gray-700 p-0.5 min-h-[28px]"
|
|
>
|
|
{dayEvents?.allDay.map((event) => (
|
|
<button
|
|
key={event.id}
|
|
onClick={() => setSelectedEventId(event.id)}
|
|
className="block w-full text-[10px] px-1 py-0.5 rounded truncate hover:opacity-80 transition-opacity mb-0.5"
|
|
style={{
|
|
backgroundColor: event.source_color || '#3b82f6',
|
|
color: '#fff',
|
|
}}
|
|
>
|
|
{event.title}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Time grid */}
|
|
<div className="flex-1 overflow-auto relative">
|
|
<div className="relative" style={{ minHeight: '1440px' }}>
|
|
{/* Hour lines */}
|
|
{HOURS.map((hour) => (
|
|
<div
|
|
key={hour}
|
|
className="absolute w-full flex border-b border-gray-100 dark:border-gray-700/50"
|
|
style={{ top: `${(hour / 24) * 100}%`, height: `${(1 / 24) * 100}%` }}
|
|
>
|
|
<div className="w-16 flex-shrink-0 text-xs text-gray-400 dark:text-gray-500 text-right pr-2 pt-0.5">
|
|
{formatHour(hour)}
|
|
</div>
|
|
<div className="flex-1 flex">
|
|
{weekDays.map((day) => (
|
|
<div
|
|
key={day.toISOString()}
|
|
className="flex-1 border-l border-gray-200 dark:border-gray-700"
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* Now indicator */}
|
|
{weekDays.some((d) => d.getTime() === today.getTime()) && (
|
|
<div
|
|
className="absolute left-16 right-0 z-20 flex items-center pointer-events-none"
|
|
style={{ top: `${nowPosition}%` }}
|
|
>
|
|
<div className="w-2 h-2 rounded-full bg-red-500 -ml-1" />
|
|
<div className="flex-1 h-px bg-red-500" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Events overlay */}
|
|
<div className="absolute left-16 right-0 top-0 bottom-0 flex">
|
|
{weekDays.map((day, colIdx) => {
|
|
const dayKey = day.toISOString().split('T')[0]
|
|
const dayEvents = eventsByDay.get(dayKey)
|
|
const dayStart = new Date(day)
|
|
dayStart.setHours(0, 0, 0, 0)
|
|
|
|
return (
|
|
<div
|
|
key={dayKey}
|
|
className="flex-1 relative border-l border-gray-200 dark:border-gray-700"
|
|
>
|
|
{dayEvents?.timed.map((event) => {
|
|
const pos = getEventPosition(event, dayStart)
|
|
const locationLabel = getSemanticLocationLabel(event, effectiveSpatial)
|
|
return (
|
|
<button
|
|
key={event.id}
|
|
onClick={() => setSelectedEventId(event.id)}
|
|
className="absolute left-0.5 right-0.5 rounded px-1 py-0.5 text-left overflow-hidden hover:opacity-90 transition-opacity z-10"
|
|
style={{
|
|
top: `${pos.top}%`,
|
|
height: `${pos.height}%`,
|
|
minHeight: '18px',
|
|
backgroundColor: event.source_color || '#3b82f6',
|
|
color: '#fff',
|
|
}}
|
|
>
|
|
<div className="text-[10px] font-medium truncate">{event.title}</div>
|
|
<div className="text-[9px] opacity-75 truncate">
|
|
{new Date(event.start).toLocaleTimeString('en-US', {
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
})}
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Loading */}
|
|
{isLoading && (
|
|
<div className="absolute left-16 right-0 top-[25%] z-10 flex gap-1 px-1">
|
|
{weekDays.map((_, i) => (
|
|
<div key={i} className="flex-1 h-12 bg-gray-200 dark:bg-gray-600 rounded animate-pulse" />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Event detail modal */}
|
|
{selectedEventId && (
|
|
<EventDetailModal
|
|
eventId={selectedEventId}
|
|
onClose={() => setSelectedEventId(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|