diff --git a/package.json b/package.json index 2e16387..ee60e76 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "skate-spots", "private": true, - "version": "1.2.33", + "version": "1.2.34", "type": "module", "homepage": "https://skate-spots.com", "scripts": { diff --git a/src/components/EventsCalendar.tsx b/src/components/EventsCalendar.tsx new file mode 100644 index 0000000..e0e09aa --- /dev/null +++ b/src/components/EventsCalendar.tsx @@ -0,0 +1,374 @@ +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; +} + +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 range belong on a calendar (mostly + // competition/"destruction" tours). Open-ended local guides have no fixed + // date and are better 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}`, + })); +} + +/** 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.kind === 'tour' + ? '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. +

+
+ )} +
+
+ ); +} diff --git a/src/pages/EventsPage.tsx b/src/pages/EventsPage.tsx index ba9319c..2bbf343 100644 --- a/src/pages/EventsPage.tsx +++ b/src/pages/EventsPage.tsx @@ -5,7 +5,9 @@ import { Layout } from '@/components/Layout'; import { EventCard, EventCardSkeleton } from '@/components/EventCard'; import { TourCard, TourCardSkeleton } from '@/components/TourCard'; import { HostingDashboard } from '@/components/HostingDashboard'; +import { EventsCalendar } from '@/components/EventsCalendar'; import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { type TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter'; import { type SortOption } from '@/components/SortDropdown'; @@ -39,6 +41,8 @@ import { CheckCircle2, ChevronDown, Megaphone, + LayoutGrid, + CalendarDays, } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -728,6 +732,30 @@ function TourSkeletonGrid({ count = 6 }: { count?: number }) { ); } +function CalendarSkeleton() { + return ( +
+
+
+ + +
+
+ {Array.from({ length: 42 }).map((_, i) => ( + + ))} +
+
+
+ + {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ ); +} + // ─── Empty states ───────────────────────────────────────────────────────────── function EmptyState({ @@ -799,6 +827,8 @@ export default function EventsPage() { // ── View state ────────────────────────────────────────────────────────────── const [mainView, setMainView] = useState('events'); + // Grid (list) vs month-calendar layout. Applies to events & tours only. + const [layout, setLayout] = useState<'list' | 'calendar'>('list'); // ── Event filters ─────────────────────────────────────────────────────────── const [searchQuery, setSearchQuery] = useState(''); @@ -845,6 +875,15 @@ export default function EventsPage() { const eventCoords = useMemo(() => (events ?? []).map((e) => eventCoord(e)), [events]); const batchRsvps = useBatchEventRsvps(eventCoords); + // Going counts keyed by SkateEvent.id, for the calendar agenda. + const goingById = useMemo(() => { + const map = new Map(); + for (const e of events ?? []) { + map.set(e.id, batchRsvps.get(eventCoord(e))?.goingCount ?? 0); + } + return map; + }, [events, batchRsvps]); + // ── Counts for tab badges ───────────────────────────────────────────────────── const rawCounts = useMemo(() => ({ events: events?.length ?? 0, @@ -1086,8 +1125,8 @@ export default function EventsPage() { totalCount={totalCount} /> - {/* ── Quick date-window chips (events · upcoming) ── */} - {mainView === 'events' && activeTab === 'upcoming' && ( + {/* ── Quick date-window chips (events · upcoming · list) ── */} + {mainView === 'events' && activeTab === 'upcoming' && layout === 'list' && (
{(['all', 'today', 'weekend', 'week', 'month'] as DateWindow[]).map((w) => { const active = dateWindow === w; @@ -1113,14 +1152,69 @@ export default function EventsPage() {
)} + {/* ── Layout toggle (list ⇄ calendar) — events & tours only ── */} + {mainView !== 'hosting' && ( +
+
+ {([ + { key: 'list' as const, label: 'List', Icon: LayoutGrid }, + { key: 'calendar' as const, label: 'Calendar', Icon: CalendarDays }, + ]).map(({ key, label, Icon }) => { + const active = layout === key; + const activeCls = mainView === 'events' + ? 'bg-[#ccff00] text-black' + : 'bg-cyan-500 text-white'; + return ( + + ); + })} +
+
+ )} + {/* ── Results ── */}
{/* Hosting dashboard */} {mainView === 'hosting' && } - {/* Events */} - {mainView === 'events' && ( + {/* Calendar layout — events */} + {mainView === 'events' && layout === 'calendar' && ( + eventsLoading ? ( + + ) : ( + + ) + )} + + {/* Calendar layout — tours */} + {mainView === 'tours' && layout === 'calendar' && ( + toursLoading ? ( + + ) : ( + + ) + )} + + {/* Events (list) */} + {mainView === 'events' && layout === 'list' && ( eventsLoading ? ( ) : activeTab === 'upcoming' ? ( @@ -1169,8 +1263,8 @@ export default function EventsPage() { ) )} - {/* Tours */} - {mainView === 'tours' && ( + {/* Tours (list) */} + {mainView === 'tours' && layout === 'list' && ( toursLoading ? ( ) : filteredTours.length > 0 ? (