Add month-calendar view for events & tours (1.2.34)

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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-07 22:52:52 +07:00
parent 9d069cb2f0
commit f253fc55fa
3 changed files with 475 additions and 7 deletions

View file

@ -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": {

View file

@ -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<string, number>;
/** 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<string, number>): 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<Date>(() => new Date(today.getFullYear(), today.getMonth(), 1));
const [selected, setSelected] = useState<Date>(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<string, CalendarItem[]>();
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 (
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_360px] gap-4 md:gap-6">
{/* ── Month grid ── */}
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-3 md:p-5">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-baseline gap-2 min-w-0">
<h2 className="text-lg md:text-2xl font-black uppercase tracking-tight truncate">
{MONTHS[view.getMonth()]}
</h2>
<span className="text-gray-500 text-lg md:text-2xl font-black">{view.getFullYear()}</span>
</div>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={goToToday}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[11px] font-bold uppercase tracking-wide text-gray-300 transition-all hover:border-white/25 hover:text-white active:scale-95"
>
Today
</button>
<button
type="button"
aria-label="Previous month"
onClick={() => goToMonth(-1)}
className="rounded-full border border-white/10 bg-white/5 p-2 text-gray-300 transition-all hover:border-white/25 hover:text-white active:scale-95"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
type="button"
aria-label="Next month"
onClick={() => goToMonth(1)}
className="rounded-full border border-white/10 bg-white/5 p-2 text-gray-300 transition-all hover:border-white/25 hover:text-white active:scale-95"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
{/* Weekday labels */}
<div className="grid grid-cols-7 gap-1 md:gap-1.5 mb-1 md:mb-2">
{WEEKDAYS.map((wd) => (
<div key={wd} className="text-center text-[10px] md:text-xs font-bold uppercase tracking-wider text-gray-500 py-1">
<span className="md:hidden">{wd[0]}</span>
<span className="hidden md:inline">{wd}</span>
</div>
))}
</div>
{/* Day cells */}
<div className="grid grid-cols-7 gap-1 md:gap-1.5">
{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 (
<button
key={cell.getTime()}
type="button"
onClick={() => setSelected(startOfDay(cell))}
aria-label={`${formatDayLong(cell)}${has ? `, ${dayItems.length} ${show}` : ''}`}
aria-pressed={isSelected}
className={cn(
'relative aspect-square rounded-lg md:rounded-xl border flex flex-col items-center justify-start pt-1 md:pt-1.5 transition-all active:scale-95',
isSelected
? cn('ring-2', accent.ring, accent.soft, accent.border)
: 'border-white/5 hover:border-white/20',
!inMonth && 'opacity-35',
has && !isSelected && 'bg-white/[0.03]',
)}
>
<span
className={cn(
'text-[11px] md:text-sm font-bold leading-none flex items-center justify-center',
isToday
? cn('h-5 w-5 md:h-6 md:w-6 rounded-full', accent.solid)
: inMonth ? 'text-gray-200' : 'text-gray-500',
)}
>
{cell.getDate()}
</span>
{/* Item dots (max 3, then +N) */}
{has && (
<div className="mt-auto mb-1 md:mb-1.5 flex items-center gap-0.5">
{dayItems.slice(0, 3).map((it, i) => (
<span key={i} className={cn('h-1 w-1 md:h-1.5 md:w-1.5 rounded-full', accent.dot)} />
))}
{dayItems.length > 3 && (
<span className="text-[7px] md:text-[9px] font-bold text-gray-400 leading-none ml-0.5">
+{dayItems.length - 3}
</span>
)}
</div>
)}
</button>
);
})}
</div>
{/* Footer summary */}
<div className="mt-4 flex items-center justify-between text-xs text-gray-500">
<span className="flex items-center gap-1.5">
<span className={cn('h-2 w-2 rounded-full', accent.dot)} />
{show === 'events' ? 'Event' : 'Tour'} day
</span>
<span>
<span className={cn('font-bold', accent.text)}>{totalThisMonth}</span>{' '}
{show === 'events' ? 'event' : 'tour'}{totalThisMonth === 1 ? '' : 's'} this month
</span>
</div>
</div>
{/* ── Selected-day agenda ── */}
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-4 md:p-5 lg:sticky lg:top-24 lg:self-start">
<div className="flex items-center gap-2 mb-4">
{show === 'events'
? <CalendarIcon className={cn('h-4 w-4', accent.text)} />
: <Route className={cn('h-4 w-4', accent.text)} />}
<h3 className="text-sm font-black uppercase tracking-wide">
{sameDay(selected, today) ? 'Today' : formatDayLong(selected)}
</h3>
</div>
{selectedItems.length > 0 ? (
<div className="space-y-2.5">
{selectedItems.map((it) => {
const multiDay = it.end && it.end > it.start && !sameDay(new Date(it.start * 1000), new Date(it.end * 1000));
return (
<Link
key={it.id}
to={it.to}
className={cn(
'group block rounded-xl border p-3 transition-all hover:bg-white/[0.04] active:scale-[0.99]',
accent.border,
'bg-white/[0.02]',
)}
>
<div className="flex items-start gap-2.5">
<span className={cn('mt-1 h-2 w-2 flex-shrink-0 rounded-full', accent.dot)} />
<div className="min-w-0 flex-1">
<p className="font-bold text-sm leading-snug text-white group-hover:underline decoration-white/30 truncate">
{it.title}
</p>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-gray-400">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{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)}
</span>
{it.location && (
<span className="flex items-center gap-1 truncate">
<MapPin className="h-3 w-3 flex-shrink-0" />
<span className="truncate">{it.location}</span>
</span>
)}
{it.kind === 'event' && (it.goingCount ?? 0) > 0 && (
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{it.goingCount} going
</span>
)}
</div>
</div>
</div>
</Link>
);
})}
</div>
) : (
<div className="rounded-xl border border-dashed border-white/10 py-8 px-4 text-center">
<p className="text-sm text-gray-500">
Nothing on this day.
</p>
<p className="mt-1 text-xs text-gray-600">
Tap a highlighted day to see what's on.
</p>
</div>
)}
</div>
</div>
);
}

View file

@ -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 (
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_360px] gap-4 md:gap-6">
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-3 md:p-5">
<div className="flex items-center justify-between mb-4">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-8 w-28" />
</div>
<div className="grid grid-cols-7 gap-1.5">
{Array.from({ length: 42 }).map((_, i) => (
<Skeleton key={i} className="aspect-square rounded-xl" />
))}
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.02] p-4 md:p-5 space-y-3">
<Skeleton className="h-5 w-32" />
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full rounded-xl" />
))}
</div>
</div>
);
}
// ─── Empty states ─────────────────────────────────────────────────────────────
function EmptyState({
@ -799,6 +827,8 @@ export default function EventsPage() {
// ── View state ──────────────────────────────────────────────────────────────
const [mainView, setMainView] = useState<MainView>('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<string, number>();
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' && (
<div className="mt-2 md:mt-4 grid grid-cols-5 gap-1.5 md:flex md:items-center md:gap-2">
{(['all', 'today', 'weekend', 'week', 'month'] as DateWindow[]).map((w) => {
const active = dateWindow === w;
@ -1113,14 +1152,69 @@ export default function EventsPage() {
</div>
)}
{/* ── Layout toggle (list ⇄ calendar) — events & tours only ── */}
{mainView !== 'hosting' && (
<div className="mt-3 md:mt-5 flex items-center justify-end">
<div className="inline-flex rounded-full border border-white/10 bg-white/5 p-0.5">
{([
{ 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 (
<button
key={key}
type="button"
onClick={() => setLayout(key)}
aria-pressed={active}
className={cn(
'flex items-center gap-1.5 rounded-full px-3 py-1.5 text-[11px] font-bold uppercase tracking-wide transition-all active:scale-95',
active ? activeCls : 'text-gray-400 hover:text-white',
)}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
);
})}
</div>
</div>
)}
{/* ── Results ── */}
<div className="mt-3 md:mt-6">
{/* Hosting dashboard */}
{mainView === 'hosting' && <HostingDashboard />}
{/* Events */}
{mainView === 'events' && (
{/* Calendar layout — events */}
{mainView === 'events' && layout === 'calendar' && (
eventsLoading ? (
<CalendarSkeleton />
) : (
<EventsCalendar
show="events"
events={activeTab === 'upcoming' ? filteredEvents.upcoming : filteredEvents.past}
tours={[]}
goingCounts={goingById}
/>
)
)}
{/* Calendar layout — tours */}
{mainView === 'tours' && layout === 'calendar' && (
toursLoading ? (
<CalendarSkeleton />
) : (
<EventsCalendar show="tours" events={[]} tours={filteredTours} />
)
)}
{/* Events (list) */}
{mainView === 'events' && layout === 'list' && (
eventsLoading ? (
<SkeletonGrid />
) : activeTab === 'upcoming' ? (
@ -1169,8 +1263,8 @@ export default function EventsPage() {
)
)}
{/* Tours */}
{mainView === 'tours' && (
{/* Tours (list) */}
{mainView === 'tours' && layout === 'list' && (
toursLoading ? (
<TourSkeletonGrid />
) : filteredTours.length > 0 ? (