import { useMemo, useState, useRef, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Route, MapPin, Clock, Users } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { SkateEvent, SkateTour } from '@/lib/types'; // ─── Types ────────────────────────────────────────────────────────────────── export interface CalendarItem { id: string; kind: 'event' | 'tour'; title: string; /** Start of the item (unix seconds). */ start: number; /** End of the item (unix seconds), if multi-day/ranged. */ end?: number; location?: string; to: string; /** RSVP "going" count for events. */ goingCount?: number; /** True for date-only items with no meaningful clock time (multi-day tours). */ allDay?: boolean; } interface EventsCalendarProps { events: SkateEvent[]; tours: SkateTour[]; /** RSVP going counts keyed by event id (optional). */ goingCounts?: Map; /** Which kinds to display. */ show: 'events' | 'tours'; } // ─── Date helpers (local time) ──────────────────────────────────────────────── const DAY_MS = 86_400_000; const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const MONTHS = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; /** Midnight (local) for a given date. */ function startOfDay(d: Date): Date { return new Date(d.getFullYear(), d.getMonth(), d.getDate()); } /** A stable YYYY-MM-DD key in local time. */ function dayKey(d: Date): string { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } function sameDay(a: Date, b: Date): boolean { return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); } /** Build the 6-week (42-cell) grid for a month, starting on Sunday. */ function buildGrid(view: Date): Date[] { const first = new Date(view.getFullYear(), view.getMonth(), 1); const gridStart = new Date(first); gridStart.setDate(first.getDate() - first.getDay()); // back to Sunday const cells: Date[] = []; for (let i = 0; i < 42; i++) { cells.push(new Date(gridStart.getTime() + i * DAY_MS)); } return cells; } // ─── Item mapping ───────────────────────────────────────────────────────────── function eventsToItems(events: SkateEvent[], goingCounts?: Map): CalendarItem[] { return events.map((e) => ({ id: e.id, kind: 'event' as const, title: e.title, start: e.start, end: e.end, location: e.location, to: `/event/${e.pubkey}/${e.identifier}`, goingCount: goingCounts?.get(e.event.id) ?? goingCounts?.get(e.id) ?? 0, })); } function toursToItems(tours: SkateTour[]): CalendarItem[] { // Only tours with a concrete date belong on a calendar: scheduled local // sessions plus multi-day national/international/destruction tours. Open-ended // "book anytime" local guides have no fixed date and are browsed as a list. return tours .filter((t) => typeof t.startDate === 'number') .map((t) => ({ id: t.id, kind: 'tour' as const, title: t.title, start: t.startDate as number, end: t.endDate, location: t.location ?? t.cities?.[0] ?? t.countries?.[0], to: `/tour/${t.pubkey}/${t.identifier}`, // Local sessions have a specific clock time; everything else is date-only. allDay: t.category !== 'local', })); } /** Does an item overlap a given calendar day? */ function itemOnDay(item: CalendarItem, day: Date): boolean { const dayStart = startOfDay(day).getTime() / 1000; const dayEnd = dayStart + 86_400; const s = item.start; const e = item.end && item.end > item.start ? item.end : item.start; return s < dayEnd && e >= dayStart; } function formatTime(unix: number): string { return new Date(unix * 1000).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); } function formatDayLong(d: Date): string { return d.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric' }); } // ─── Component ──────────────────────────────────────────────────────────────── export function EventsCalendar({ events, tours, goingCounts, show }: EventsCalendarProps) { const today = useMemo(() => startOfDay(new Date()), []); const [view, setView] = useState(() => new Date(today.getFullYear(), today.getMonth(), 1)); const [selected, setSelected] = useState(today); const accent = show === 'events' ? { dot: 'bg-[#ccff00]', ring: 'ring-[#ccff00]', text: 'text-[#ccff00]', soft: 'bg-[#ccff00]/15', border: 'border-[#ccff00]/40', solid: 'bg-[#ccff00] text-black' } : { dot: 'bg-cyan-400', ring: 'ring-cyan-400', text: 'text-cyan-400', soft: 'bg-cyan-500/15', border: 'border-cyan-500/40', solid: 'bg-cyan-500 text-white' }; const items = useMemo( () => (show === 'events' ? eventsToItems(events, goingCounts) : toursToItems(tours)), [show, events, tours, goingCounts], ); // Map of dayKey -> items on that day, for quick dot rendering. const grid = useMemo(() => buildGrid(view), [view]); const itemsByDay = useMemo(() => { const map = new Map(); for (const cell of grid) { const onDay = items.filter((it) => itemOnDay(it, cell)); if (onDay.length) map.set(dayKey(cell), onDay.sort((a, b) => a.start - b.start)); } return map; }, [grid, items]); const selectedItems = useMemo( () => items.filter((it) => itemOnDay(it, selected)).sort((a, b) => a.start - b.start), [items, selected], ); const goToMonth = (delta: number) => { setView((v) => new Date(v.getFullYear(), v.getMonth() + delta, 1)); }; const goToToday = () => { setView(new Date(today.getFullYear(), today.getMonth(), 1)); setSelected(today); }; // When user changes the month via arrows, keep the selection sensible: // if the selected day is no longer in view, select the 1st of the month. const prevViewRef = useRef(view); useEffect(() => { if (prevViewRef.current.getTime() !== view.getTime()) { prevViewRef.current = view; if (selected.getMonth() !== view.getMonth() || selected.getFullYear() !== view.getFullYear()) { // If today is in the viewed month, prefer today; else the 1st. if (today.getMonth() === view.getMonth() && today.getFullYear() === view.getFullYear()) { setSelected(today); } else { setSelected(new Date(view.getFullYear(), view.getMonth(), 1)); } } } }, [view, selected, today]); const totalThisMonth = useMemo( () => items.filter((it) => { const s = new Date(it.start * 1000); const e = it.end ? new Date(it.end * 1000) : s; // Overlaps the viewed month? const monthStart = new Date(view.getFullYear(), view.getMonth(), 1); const monthEnd = new Date(view.getFullYear(), view.getMonth() + 1, 1); return e >= monthStart && s < monthEnd; }).length, [items, view], ); return (
{/* ── Month grid ── */}
{/* Header */}

{MONTHS[view.getMonth()]}

{view.getFullYear()}
{/* Weekday labels */}
{WEEKDAYS.map((wd) => (
{wd[0]} {wd}
))}
{/* Day cells */}
{grid.map((cell) => { const inMonth = cell.getMonth() === view.getMonth(); const isToday = sameDay(cell, today); const isSelected = sameDay(cell, selected); const dayItems = itemsByDay.get(dayKey(cell)) ?? []; const has = dayItems.length > 0; return ( ); })}
{/* Footer summary */}
{show === 'events' ? 'Event' : 'Tour'} day {totalThisMonth}{' '} {show === 'events' ? 'event' : 'tour'}{totalThisMonth === 1 ? '' : 's'} this month
{/* ── Selected-day agenda ── */}
{show === 'events' ? : }

{sameDay(selected, today) ? 'Today' : formatDayLong(selected)}

{selectedItems.length > 0 ? (
{selectedItems.map((it) => { const multiDay = it.end && it.end > it.start && !sameDay(new Date(it.start * 1000), new Date(it.end * 1000)); return (

{it.title}

{multiDay ? `${new Date(it.start * 1000).toLocaleDateString([], { month: 'short', day: 'numeric' })} – ${new Date((it.end as number) * 1000).toLocaleDateString([], { month: 'short', day: 'numeric' })}` : it.allDay ? 'All day' : formatTime(it.start)} {it.location && ( {it.location} )} {it.kind === 'event' && (it.goingCount ?? 0) > 0 && ( {it.goingCount} going )}
); })}
) : (

Nothing on this day.

Tap a highlighted day to see what's on.

)}
); }