import { useEffect, useState, useCallback, useMemo } from 'react' import { useNavigate } from 'react-router-dom' import { fetchAPI } from '../api/client' import ErrorBanner from '../components/ErrorBanner' interface CalendarEvent { id: number media_type: string title: string date: string year: number | null status: string poster_url: string } interface CalendarResponse { data: CalendarEvent[] month: string } const typeColors: Record = { movie: { bg: 'bg-indigo-600/20', border: 'border-l-indigo-500', text: 'text-indigo-300' }, series: { bg: 'bg-emerald-600/20', border: 'border-l-emerald-500', text: 'text-emerald-300' }, music: { bg: 'bg-amber-600/20', border: 'border-l-amber-500', text: 'text-amber-300' }, audiobook: { bg: 'bg-rose-600/20', border: 'border-l-rose-500', text: 'text-rose-300' }, podcast: { bg: 'bg-violet-600/20', border: 'border-l-violet-500', text: 'text-violet-300' }, } const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] const monthNames = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ] export default function Calendar() { const navigate = useNavigate() const now = useMemo(() => new Date(), []) const [year, setYear] = useState(now.getFullYear()) const [month, setMonth] = useState(now.getMonth()) const [events, setEvents] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const monthParam = `${year}-${String(month + 1).padStart(2, '0')}` const fetchData = useCallback(() => { setLoading(true) setError(null) fetchAPI(`/api/calendar?month=${monthParam}`) .then(res => { setEvents(res.data ?? []) }) .catch(err => setError(err.message || 'Failed to load calendar events')) .finally(() => setLoading(false)) }, [monthParam]) useEffect(() => { fetchData() }, [fetchData]) const goToPrevMonth = () => { if (month === 0) { setMonth(11) setYear(y => y - 1) } else { setMonth(m => m - 1) } } const goToNextMonth = () => { if (month === 11) { setMonth(0) setYear(y => y + 1) } else { setMonth(m => m + 1) } } const goToToday = () => { setYear(now.getFullYear()) setMonth(now.getMonth()) } const handleEventClick = (event: CalendarEvent) => { navigate(`/library/${event.media_type}/${event.id}`) } // Calendar grid calculations const firstDay = new Date(year, month, 1).getDay() const daysInMonth = new Date(year, month + 1, 0).getDate() const today = new Date() const isCurrentMonth = today.getFullYear() === year && today.getMonth() === month const todayDate = today.getDate() // Group events by day const eventsByDay = useMemo(() => { const map = new Map() for (const event of events) { const day = parseInt(event.date.split('-')[2] ?? '0', 10) if (day > 0) { const existing = map.get(day) ?? [] existing.push(event) map.set(day, existing) } } return map }, [events]) // Build the 42-cell grid (6 rows × 7 cols) const cells: (number | null)[] = [] for (let i = 0; i < firstDay; i++) { cells.push(null) } for (let d = 1; d <= daysInMonth; d++) { cells.push(d) } while (cells.length < 42) { cells.push(null) } return (

Calendar

Upcoming release dates for monitored media

{monthNames[month]} {year}
{error && } {/* Day-of-week headers */}
{dayLabels.map(d => (
{d}
))}
{loading ? (
{Array.from({ length: 42 }).map((_, i) => (
))}
) : (
{cells.map((day, idx) => { if (day === null) { return
} const dayEvents = eventsByDay.get(day) ?? [] const isToday = isCurrentMonth && day === todayDate const maxVisible = 3 return (
{day}
{dayEvents.slice(0, maxVisible).map(event => { const colors = typeColors[event.media_type] ?? { bg: 'bg-gray-600/20', border: 'border-l-gray-500', text: 'text-gray-300' } return ( ) })} {dayEvents.length > maxVisible && ( +{dayEvents.length - maxVisible} more )}
) })}
)}
) }