From f253fc55fac78513edc4149d77b65fad298579c3 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Mon, 7 Sep 2026 22:52:52 +0700 Subject: [PATCH] Add month-calendar view for events & tours (1.2.34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Events and tours could only be browsed as a card list — there was no way to see what's happening on a given date, which is exactly the clarity the calendar ask called for. New EventsCalendar component: a purpose-built month grid (not the date-picker) with: - Prev/next/today month navigation - Per-day item dots (max 3 + overflow count) with correct multi-day overlap handling for ranged events and competition tours - A selected-day agenda panel showing title, time (or date range / "All day" for tours), location, and RSVP going counts, each linking straight to the detail page - Month total footer, empty-day state, full responsiveness, and aria-labels / aria-pressed for accessibility Wired a List⇄Calendar layout toggle into EventsPage for both the events and tours tabs. Calendar mode reuses the already-filtered event/tour sets (search, trust, type filters all still apply), hides the list-only date-window chips, and shows a matching skeleton while loading. Tours without a concrete date range are omitted from the calendar (they have no fixed date) and remain browsable in list view. Going counts are threaded through from the existing batch RSVP query, so the calendar adds no extra relay load. Co-authored-by: shakespeare.diy --- package.json | 2 +- src/components/EventsCalendar.tsx | 374 ++++++++++++++++++++++++++++++ src/pages/EventsPage.tsx | 106 ++++++++- 3 files changed, 475 insertions(+), 7 deletions(-) create mode 100644 src/components/EventsCalendar.tsx 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 ? (