From 7439b980bb13ae0026c97fe90a56e26f5a1da118 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 3 Sep 2026 10:47:57 +0700 Subject: [PATCH] Events Phase 1: NIP-52 RSVPs, attendee stacks, date filter, host notifications - Adopt NIP-52 RSVP (kind 31925): parseEventRsvp + RsvpStatus types - useEventRsvps (counts by status, attendee pubkeys, my status) + useRsvp mutation that replaces the user's prior RSVP via a stable d-tag - RsvpButtons (Going / Interested / Can't make it) + AttendeeStack avatar row - EventDetailPage: "Who's going" card with RSVP control + attendee avatars - EventCard: going count on both compact and full cards - EventsPage: quick date-window chips (Today / Weekend / Week / Month) - Activity bell: host RSVP notifications (new 'rsvp' activity type) - Document RSVP schema in NIP.md Co-authored-by: shakespeare.diy --- NIP.md | 26 ++++ src/components/ActivityNotificationCard.tsx | 3 +- src/components/AttendeeStack.tsx | 71 +++++++++ src/components/EventCard.tsx | 20 ++- src/components/RsvpButtons.tsx | 138 +++++++++++++++++ src/hooks/useActivityNotifications.ts | 28 +++- src/hooks/useEventRsvps.ts | 155 ++++++++++++++++++++ src/lib/types.ts | 48 ++++++ src/pages/EventDetailPage.tsx | 40 +++++ src/pages/EventsPage.tsx | 84 ++++++++++- 10 files changed, 605 insertions(+), 8 deletions(-) create mode 100644 src/components/AttendeeStack.tsx create mode 100644 src/components/RsvpButtons.tsx create mode 100644 src/hooks/useEventRsvps.ts diff --git a/NIP.md b/NIP.md index 6bd1ce2..ec1d91d 100644 --- a/NIP.md +++ b/NIP.md @@ -342,6 +342,32 @@ Skateboarding and music culture are deeply connected. These event types support Events can have multiple type tags. For example, a video premiere with a live band would have both `premiere` and `concert` tags. +### Event RSVPs (Attendance) + +Attendance uses the standard **NIP-52 RSVP** (kind `31925`). A skater responds to a calendar event with `accepted` (going), `tentative` (interested), or `declined` (can't make it). RSVPs are addressable, so publishing a new one with the same `d` replaces the prior status โ€” letting a skater freely switch. + +```json +{ + "kind": 31925, + "content": "", + "tags": [ + ["d", ""], + ["a", "31923::", "", ""], + ["e", ""], + ["status", "accepted"], + ["p", ""], + ["fb", "busy"], + ["alt", "RSVP (accepted) to a skate event"] + ] +} +``` + +- The `a` tag references the calendar event coordinate being responded to (required). +- The `p` tag targets the host so hosts can query RSVPs addressed to them (and get notified). +- `fb` (`free`/`busy`) is omitted for `declined` per NIP-52. + +Clients query an event's RSVPs with `{ kinds: [31925], "#a": ["31923::"] }` and keep the newest per pubkey. Hosts see incoming RSVPs in their notification center via `{ kinds: [31925], "#p": [""] }`. + ## Social Interaction Comments and reactions to skate spots and events use standard Nostr kinds: diff --git a/src/components/ActivityNotificationCard.tsx b/src/components/ActivityNotificationCard.tsx index c248fa3..cdeca13 100644 --- a/src/components/ActivityNotificationCard.tsx +++ b/src/components/ActivityNotificationCard.tsx @@ -1,6 +1,6 @@ import { Link } from 'react-router-dom'; import { formatDistanceToNow } from 'date-fns'; -import { Heart, Repeat2, Zap, AtSign, MessageCircle } from 'lucide-react'; +import { Heart, Repeat2, Zap, AtSign, MessageCircle, CalendarCheck } from 'lucide-react'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; @@ -21,6 +21,7 @@ const TYPE_META: Record< zap: { icon: Zap, color: 'text-yellow-400', ring: 'bg-yellow-500/15' }, mention: { icon: AtSign, color: 'text-sky-400', ring: 'bg-sky-500/15' }, reply: { icon: MessageCircle, color: 'text-teal-400', ring: 'bg-teal-500/15' }, + rsvp: { icon: CalendarCheck, color: 'text-emerald-400', ring: 'bg-emerald-500/15' }, }; /** diff --git a/src/components/AttendeeStack.tsx b/src/components/AttendeeStack.tsx new file mode 100644 index 0000000..81af954 --- /dev/null +++ b/src/components/AttendeeStack.tsx @@ -0,0 +1,71 @@ +import { Link } from 'react-router-dom'; +import { nip19 } from 'nostr-tools'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { useAuthor } from '@/hooks/useAuthor'; +import { genUserName } from '@/lib/genUserName'; +import { cn } from '@/lib/utils'; + +function StackAvatar({ pubkey, index }: { pubkey: string; index: number }) { + const author = useAuthor(pubkey); + const name = author.data?.metadata?.name ?? genUserName(pubkey); + const pic = author.data?.metadata?.picture; + + const npub = (() => { + try { + return nip19.npubEncode(pubkey); + } catch { + return null; + } + })(); + + const avatar = ( + + + + {name.slice(0, 2).toUpperCase()} + + + ); + + if (!npub) return avatar; + return ( + + {avatar} + + ); +} + +/** + * A horizontal, overlapping stack of attendee avatars with a "+N" overflow. + * Purely presentational โ€” pass a list of pubkeys. + */ +export function AttendeeStack({ + pubkeys, + max = 5, + className, +}: { + pubkeys: string[]; + max?: number; + className?: string; +}) { + if (pubkeys.length === 0) return null; + const shown = pubkeys.slice(0, max); + const overflow = pubkeys.length - shown.length; + + return ( +
+
+ {shown.map((pk, i) => ( + + ))} +
+ {overflow > 0 && ( + +{overflow} + )} +
+ ); +} diff --git a/src/components/EventCard.tsx b/src/components/EventCard.tsx index 9add509..93ab8d2 100644 --- a/src/components/EventCard.tsx +++ b/src/components/EventCard.tsx @@ -11,10 +11,11 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { Calendar, Clock, MapPin, MoreVertical, Flag } from 'lucide-react'; +import { Calendar, Clock, MapPin, MoreVertical, Flag, Users } from 'lucide-react'; import { useAuthor } from '@/hooks/useAuthor'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { genUserName } from '@/lib/genUserName'; +import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps'; import { ZapButton } from '@/components/ZapButton'; import { TrustBadge } from '@/components/TrustBadge'; import { OfficialBadge } from '@/components/OfficialBadge'; @@ -73,6 +74,7 @@ export function EventCard({ event, compact = false }: EventCardProps) { const relativeTime = getRelativeTime(event.start); const isUpcoming = event.start > Date.now() / 1000; const isOwner = user?.pubkey === event.pubkey; + const { goingCount } = useEventRsvps(eventCoord(event)); if (compact) { return ( @@ -119,6 +121,12 @@ export function EventCard({ event, compact = false }: EventCardProps) { {event.location} )} + {goingCount > 0 && ( + + + {goingCount} + + )} @@ -228,7 +236,15 @@ export function EventCard({ event, compact = false }: EventCardProps) {

)} -
+
+ {goingCount > 0 ? ( + + + {goingCount} going + + ) : ( + + )}
e.preventDefault()}> {/* More options menu */} diff --git a/src/components/RsvpButtons.tsx b/src/components/RsvpButtons.tsx new file mode 100644 index 0000000..1bfaa5c --- /dev/null +++ b/src/components/RsvpButtons.tsx @@ -0,0 +1,138 @@ +import { Check, HelpCircle, X, Loader2 } from 'lucide-react'; +import { LoginArea } from '@/components/auth/LoginArea'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useToast } from '@/hooks/useToast'; +import { useEventRsvps, useRsvp } from '@/hooks/useEventRsvps'; +import { eventCoord } from '@/hooks/useEventRsvps'; +import { cn } from '@/lib/utils'; +import type { RsvpStatus, SkateEvent } from '@/lib/types'; + +const OPTIONS: { + status: RsvpStatus; + label: string; + shortLabel: string; + icon: typeof Check; + activeClass: string; +}[] = [ + { + status: 'accepted', + label: "I'm Going", + shortLabel: 'Going', + icon: Check, + activeClass: 'bg-green-500 border-green-500 text-black', + }, + { + status: 'tentative', + label: 'Interested', + shortLabel: 'Maybe', + icon: HelpCircle, + activeClass: 'bg-[#ccff00] border-[#ccff00] text-black', + }, + { + status: 'declined', + label: "Can't make it", + shortLabel: "Can't", + icon: X, + activeClass: 'bg-red-500/80 border-red-500 text-white', + }, +]; + +/** + * NIP-52 RSVP control. A segmented Going / Interested / Can't-make-it toggle + * that publishes (or replaces) the user's RSVP. Tapping the active option again + * is a no-op. Logged-out users see a login prompt instead. + */ +export function RsvpButtons({ event, className }: { event: SkateEvent; className?: string }) { + const { user } = useCurrentUser(); + const { toast } = useToast(); + const coord = eventCoord(event); + const { myRsvp, myStatus, goingCount, tentativeCount } = useEventRsvps(coord); + const { mutateAsync: rsvp, isPending } = useRsvp(); + + const isHost = user?.pubkey === event.pubkey; + + if (!user) { + return ( +
+

+ Are you going?{' '} + Log in to RSVP. +

+ +
+ ); + } + + const handleClick = async (status: RsvpStatus) => { + if (status === myStatus || isPending) return; + try { + await rsvp({ + event, + status, + existingIdentifier: myRsvp?.identifier, + }); + const msg = + status === 'accepted' + ? "You're going! ๐Ÿ›น" + : status === 'tentative' + ? 'Marked as interested.' + : 'Marked as not going.'; + toast({ title: msg }); + } catch (err) { + toast({ + title: 'Could not RSVP', + description: err instanceof Error ? err.message : 'Please try again.', + variant: 'destructive', + }); + } + }; + + return ( +
+ {isHost && ( +

+ You're hosting this โ€” but you can still set your status. +

+ )} +
+ {OPTIONS.map(({ status, label, shortLabel, icon: Icon, activeClass }) => { + const active = myStatus === status; + return ( + + ); + })} +
+ {(goingCount > 0 || tentativeCount > 0) && ( +

+ {goingCount} going + {tentativeCount > 0 && ( + <> + {' ยท '} + {tentativeCount} interested + + )} +

+ )} +
+ ); +} diff --git a/src/hooks/useActivityNotifications.ts b/src/hooks/useActivityNotifications.ts index f4eb9ed..e671b2c 100644 --- a/src/hooks/useActivityNotifications.ts +++ b/src/hooks/useActivityNotifications.ts @@ -16,7 +16,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; * load minimal (a single filter) per the project's query-efficiency rules. */ -export type ActivityType = 'reaction' | 'repost' | 'zap' | 'mention' | 'reply'; +export type ActivityType = 'reaction' | 'repost' | 'zap' | 'mention' | 'reply' | 'rsvp'; export interface ActivityNotification { id: string; @@ -32,6 +32,8 @@ export interface ActivityNotification { targetEventId?: string; /** Sats amount for zaps, parsed from the bolt11/description when available. */ amountSats?: number; + /** For RSVPs: accepted / tentative / declined. */ + rsvpStatus?: 'accepted' | 'tentative' | 'declined'; } const REACTION_KIND = 7; @@ -39,6 +41,7 @@ const REPOST_KINDS = [6, 16]; const ZAP_RECEIPT_KIND = 9735; const TEXT_NOTE_KIND = 1; const COMMENT_KIND = 1111; +const RSVP_KIND = 31925; /** Parse the sats amount out of a zap receipt (9735) if we can. */ function parseZapAmount(event: NostrEvent): number | undefined { @@ -102,9 +105,10 @@ export function useActivityNotifications() { ZAP_RECEIPT_KIND, TEXT_NOTE_KIND, COMMENT_KIND, + RSVP_KIND, ], '#p': [pubkey], - limit: 100, + limit: 120, }, ], { signal }, @@ -171,6 +175,20 @@ export function useActivityNotifications() { content: event.content, targetEventId, }); + } else if (event.kind === RSVP_KIND) { + // Someone RSVP'd to an event I'm hosting (RSVP p-tags the host). + const status = event.tags.find(([t]) => t === 'status')?.[1]; + if (status !== 'accepted' && status !== 'tentative' && status !== 'declined') continue; + out.push({ + id: event.id, + type: 'rsvp', + pubkey: event.pubkey, + createdAt: event.created_at, + event, + content: event.content, + targetEventId, + rsvpStatus: status, + }); } } @@ -199,6 +217,12 @@ export function activityVerb(n: ActivityNotification): string { return 'mentioned you'; case 'reply': return 'replied to you'; + case 'rsvp': + return n.rsvpStatus === 'accepted' + ? "is going to your event" + : n.rsvpStatus === 'tentative' + ? 'is interested in your event' + : "can't make your event"; default: return 'interacted with you'; } diff --git a/src/hooks/useEventRsvps.ts b/src/hooks/useEventRsvps.ts new file mode 100644 index 0000000..cb8ca1c --- /dev/null +++ b/src/hooks/useEventRsvps.ts @@ -0,0 +1,155 @@ +import { useMemo } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useSkateRelays } from '@/hooks/useSkateRelays'; +import { + CALENDAR_EVENT_KIND, + CALENDAR_RSVP_KIND, + parseEventRsvp, + type EventRsvp, + type RsvpStatus, + type SkateEvent, +} from '@/lib/types'; + +/** + * NIP-52 RSVP layer for skate calendar events (kind 31925). + * โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + * Given a calendar event coordinate ("31923:pubkey:d") we fetch every RSVP that + * references it, keep the newest per person, and expose: + * โ€ข counts by status (going / interested / declined) + * โ€ข the ordered lists of pubkeys (for avatar stacks) + * โ€ข the logged-in user's own current status + * + * Publishing a new RSVP reuses the person's existing `d` identifier so it + * REPLACES their prior RSVP (addressable), letting them freely switch between + * Going / Interested / Can't make it. + */ + +export interface EventRsvpSummary { + going: string[]; + tentative: string[]; + declined: string[]; + goingCount: number; + tentativeCount: number; + /** All RSVPs, newest per person. */ + rsvps: EventRsvp[]; + /** The logged-in user's current RSVP, if any. */ + myRsvp: EventRsvp | null; + myStatus: RsvpStatus | null; + isLoading: boolean; +} + +/** Build the addressable coordinate for a calendar event. */ +export function eventCoord(event: Pick): string { + return `${CALENDAR_EVENT_KIND}:${event.pubkey}:${event.identifier}`; +} + +export function useEventRsvps(coord: string | undefined): EventRsvpSummary { + const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + const { user } = useCurrentUser(); + + const query = useQuery({ + queryKey: ['event-rsvps', coord, getQueryKeySuffix()], + enabled: !!coord, + staleTime: 30_000, + queryFn: async (c) => { + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]); + const events = await querySkateContent( + [{ kinds: [CALENDAR_RSVP_KIND], '#a': [coord!], limit: 500 }], + { signal }, + ); + + // Keep the newest RSVP per pubkey (an addressable replace can arrive as + // multiple copies across relays / revisions). + const byPubkey = new Map(); + for (const ev of events) { + const rsvp = parseEventRsvp(ev); + if (!rsvp || rsvp.eventCoord !== coord) continue; + const existing = byPubkey.get(rsvp.pubkey); + if (!existing || rsvp.createdAt > existing.createdAt) { + byPubkey.set(rsvp.pubkey, rsvp); + } + } + return Array.from(byPubkey.values()); + }, + }); + + return useMemo(() => { + const rsvps = query.data ?? []; + const going: string[] = []; + const tentative: string[] = []; + const declined: string[] = []; + + // Newest first so avatar stacks show the most recent RSVPs. + const sorted = [...rsvps].sort((a, b) => b.createdAt - a.createdAt); + for (const r of sorted) { + if (r.status === 'accepted') going.push(r.pubkey); + else if (r.status === 'tentative') tentative.push(r.pubkey); + else if (r.status === 'declined') declined.push(r.pubkey); + } + + const myRsvp = user ? rsvps.find((r) => r.pubkey === user.pubkey) ?? null : null; + + return { + going, + tentative, + declined, + goingCount: going.length, + tentativeCount: tentative.length, + rsvps: sorted, + myRsvp, + myStatus: myRsvp?.status ?? null, + isLoading: query.isLoading, + }; + }, [query.data, query.isLoading, user]); +} + +/** + * Publish (or replace) the logged-in user's RSVP to a calendar event. + * Reuses an existing RSVP `d` so switching status replaces the prior one. + */ +export function useRsvp() { + const { mutateAsync: publish, isPending } = useNostrPublish(); + const { user } = useCurrentUser(); + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: async (opts: { + event: SkateEvent; + status: RsvpStatus; + /** Reuse the existing RSVP identifier when the user already RSVP'd. */ + existingIdentifier?: string; + note?: string; + }) => { + if (!user) throw new Error('You must be logged in to RSVP.'); + + const coord = eventCoord(opts.event); + const d = opts.existingIdentifier || crypto.randomUUID(); + + const tags: string[][] = [ + ['d', d], + ['a', coord, '', ''], + ['e', opts.event.id], + ['status', opts.status], + // Let the host easily query RSVPs addressed to them. + ['p', opts.event.pubkey], + // Free/busy hint (omit for declined per NIP-52). + ...(opts.status !== 'declined' ? [['fb', 'busy']] : []), + ['alt', `RSVP (${opts.status}) to a skate event`], + ]; + + return publish({ + kind: CALENDAR_RSVP_KIND, + content: opts.note ?? '', + tags, + }); + }, + onSuccess: (_data, variables) => { + const coord = eventCoord(variables.event); + queryClient.invalidateQueries({ queryKey: ['event-rsvps', coord] }); + }, + }); + + return { ...mutation, isPending: isPending || mutation.isPending }; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 83c05a4..7484ceb 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -46,6 +46,7 @@ function clampStr(s: string | undefined, max: number): string | undefined { export const SKATE_SPOT_KIND = 30818; // Addressable event - editable per pubkey+kind+d-tag export const SKATE_SHOP_KIND = 30017; // NIP-15 Stall (marketplace) export const CALENDAR_EVENT_KIND = 31923; +export const CALENDAR_RSVP_KIND = 31925; // NIP-52 Calendar Event RSVP (accepted/tentative/declined) export const SKATE_SKATER_KIND = 35000; // Skater profile โ€” the app-native "I skate here" identity export const SKATE_COACH_KIND = 35001; export const SKATE_FILMER_KIND = 35002; // Skate filmer (video) profiles @@ -843,6 +844,53 @@ export function parseSkateEvent(event: NostrEvent): SkateEvent | null { }; } +// โ”€โ”€โ”€ NIP-52 Calendar Event RSVP (kind 31925) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export type RsvpStatus = 'accepted' | 'tentative' | 'declined'; + +export interface EventRsvp { + id: string; + /** The pubkey of the person RSVP'ing. */ + pubkey: string; + /** The RSVP's own `d` identifier. */ + identifier: string; + /** Coordinate of the calendar event being responded to ("31923:pubkey:d"). */ + eventCoord: string; + status: RsvpStatus; + /** Optional free-form note. */ + note?: string; + createdAt: number; + event: NostrEvent; +} + +/** + * Parse a NIP-52 RSVP (kind 31925). Requires a valid `a` coordinate to a + * calendar event and a recognized `status`. + */ +export function parseEventRsvp(event: NostrEvent): EventRsvp | null { + if (event.kind !== CALENDAR_RSVP_KIND) return null; + + const d = event.tags.find(([name]) => name === 'd')?.[1]; + const eventCoord = event.tags.find(([name]) => name === 'a')?.[1]; + const status = event.tags.find(([name]) => name === 'status')?.[1] as RsvpStatus | undefined; + + if (!d || !eventCoord || !status) return null; + if (!['accepted', 'tentative', 'declined'].includes(status)) return null; + // Must reference a calendar event coordinate. + if (!eventCoord.startsWith(`${CALENDAR_EVENT_KIND}:`)) return null; + + return { + id: event.id, + pubkey: event.pubkey, + identifier: d, + eventCoord, + status, + note: event.content?.slice(0, MAX_DESCRIPTION_LEN) || undefined, + createdAt: event.created_at, + event, + }; +} + // Main spot categories (mutually exclusive - user picks ONE) export const SPOT_CATEGORIES = { street: { label: 'Street', emoji: '๐Ÿ›ฃ๏ธ', description: 'Random found spots, urban terrain' }, diff --git a/src/pages/EventDetailPage.tsx b/src/pages/EventDetailPage.tsx index 595841d..7003c86 100644 --- a/src/pages/EventDetailPage.tsx +++ b/src/pages/EventDetailPage.tsx @@ -11,9 +11,12 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Skeleton } from '@/components/ui/skeleton'; import { Button } from '@/components/ui/button'; import { ZapButton } from '@/components/ZapButton'; +import { RsvpButtons } from '@/components/RsvpButtons'; +import { AttendeeStack } from '@/components/AttendeeStack'; import { CommentsSection } from '@/components/comments/CommentsSection'; import { DeleteEntityDialog } from '@/components/DeleteEntityDialog'; import { useSkateEvent } from '@/hooks/useSkateEvents'; +import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; import { EVENT_TYPE_LABELS, SKATE_CREW_KIND, SKATE_MUSICIAN_KIND } from '@/lib/types'; @@ -101,6 +104,9 @@ export default function EventDetailPage() { return { lat: latitude, lng: longitude }; }, [event?.geohash]); + // NIP-52 RSVPs for this event (counts + attendee avatars). + const rsvpSummary = useEventRsvps(event ? eventCoord(event) : undefined); + const isUpcoming = event ? event.start > Date.now() / 1000 : false; const isPast = event ? event.start < Date.now() / 1000 : false; @@ -361,6 +367,40 @@ export default function EventDetailPage() { + {/* RSVP / Attendance */} + + + + + {isPast ? 'Who came' : 'Who\u2019s going'} + + + + {!isPast && } + + {rsvpSummary.goingCount > 0 ? ( +
+
+ + Going ยท {rsvpSummary.goingCount} + + {rsvpSummary.tentativeCount > 0 && ( + + {rsvpSummary.tentativeCount} interested + + )} +
+ +
+ ) : ( + !rsvpSummary.isLoading && + isPast && ( +

No RSVPs were recorded.

+ ) + )} +
+
+ {/* Map (if has location) */} {coordinates && ( diff --git a/src/pages/EventsPage.tsx b/src/pages/EventsPage.tsx index 4cc732f..bca04b3 100644 --- a/src/pages/EventsPage.tsx +++ b/src/pages/EventsPage.tsx @@ -64,6 +64,50 @@ const ACCENT = { type MainView = 'events' | 'tours'; +// โ”€โ”€โ”€ Date window filter (skaters plan by weekend) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +type DateWindow = 'all' | 'today' | 'weekend' | 'week' | 'month'; + +const DATE_WINDOW_LABELS: Record, string> = { + today: 'Today', + weekend: 'This Weekend', + week: 'This Week', + month: 'This Month', +}; + +/** + * Returns [startSec, endSec) for a date window, in local time. `weekend` covers + * the upcoming Sat+Sun (or the current weekend if it's already Sat/Sun). + */ +function dateWindowRange(window: Exclude): [number, number] { + const now = new Date(); + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const sec = (d: Date) => Math.floor(d.getTime() / 1000); + const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n); + + switch (window) { + case 'today': + return [sec(startOfToday), sec(addDays(startOfToday, 1))]; + case 'weekend': { + // day: 0=Sun โ€ฆ 6=Sat. Find the coming Saturday (or today if Sat/Sun). + const day = startOfToday.getDay(); + let satOffset: number; + if (day === 6) satOffset = 0; // Saturday + else if (day === 0) satOffset = -1; // Sunday โ†’ weekend started yesterday + else satOffset = 6 - day; // upcoming Saturday + const sat = addDays(startOfToday, satOffset); + const end = addDays(sat, 2); // Sat 00:00 โ†’ Mon 00:00 + // Never start the window in the past. + const start = sec(sat) < sec(startOfToday) ? sec(startOfToday) : sec(sat); + return [start, sec(end)]; + } + case 'week': + return [sec(startOfToday), sec(addDays(startOfToday, 7))]; + case 'month': + return [sec(startOfToday), sec(addDays(startOfToday, 30))]; + } +} + // โ”€โ”€โ”€ FAB โ€” create event / tour โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function CreateFAB({ mainView }: { mainView: MainView }) { @@ -735,6 +779,7 @@ export default function EventsPage() { const [activeTab, setActiveTab] = useState<'upcoming' | 'past'>('upcoming'); const [trustFilter, setTrustFilter] = useState(smartDefault); const [sortBy, setSortBy] = useState('recent'); + const [dateWindow, setDateWindow] = useState('all'); // โ”€โ”€ Tour filters โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const [tourSearchQuery, setTourSearchQuery] = useState(''); @@ -811,6 +856,12 @@ export default function EventsPage() { let upcoming = filtered.filter((e) => e.start >= now); let past = filtered.filter((e) => e.start < now); + // Date window applies to upcoming events only. + if (dateWindow !== 'all') { + const [ws, we] = dateWindowRange(dateWindow); + upcoming = upcoming.filter((e) => e.start >= ws && e.start < we); + } + const sortFn = (a: typeof events[0], b: typeof events[0]) => { if (sortBy === 'trust') { const diff = (getTrustScore(b.pubkey) || 0) - (getTrustScore(a.pubkey) || 0); @@ -829,7 +880,7 @@ export default function EventsPage() { past.sort((a, b) => sortBy === 'recent' ? b.start - a.start : sortFn(a, b)); return { upcoming, past }; - }, [events, searchQuery, locationQuery, selectedTypes, trustFilter, trustScores, sortBy, now, allComments]); + }, [events, searchQuery, locationQuery, selectedTypes, trustFilter, trustScores, sortBy, dateWindow, now, allComments]); // โ”€โ”€ Filter tours โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const filteredTours = useMemo(() => { @@ -885,7 +936,8 @@ export default function EventsPage() { const activeEventFilterCount = selectedTypes.length + (trustFilter !== 'all' ? 1 : 0) + - (sortBy !== 'recent' ? 1 : 0); + (sortBy !== 'recent' ? 1 : 0) + + (dateWindow !== 'all' ? 1 : 0); const activeTourFilterCount = (tourCategory !== 'all' ? 1 : 0) + @@ -901,6 +953,7 @@ export default function EventsPage() { setSelectedTypes([]); setTrustFilter('all'); setSortBy('recent'); + setDateWindow('all'); }; const handleClearTourFilters = () => { @@ -933,7 +986,7 @@ export default function EventsPage() { const handleSearchChange = mainView === 'events' ? setSearchQuery : setTourSearchQuery; const handleLocationChange = mainView === 'events' ? setLocationQuery : setTourLocationQuery; - const hasEventFilters = !!(searchQuery || locationQuery || selectedTypes.length > 0 || trustFilter !== 'all' || sortBy !== 'recent'); + const hasEventFilters = !!(searchQuery || locationQuery || selectedTypes.length > 0 || trustFilter !== 'all' || sortBy !== 'recent' || dateWindow !== 'all'); const hasTourFilters = !!(tourSearchQuery || tourLocationQuery || tourCategory !== 'all' || tourSkillLevel !== 'all' || tourTrustFilter !== 'all' || tourSortBy !== 'recent'); return ( @@ -989,6 +1042,31 @@ export default function EventsPage() { totalCount={totalCount} /> + {/* โ”€โ”€ Quick date-window chips (events ยท upcoming) โ”€โ”€ */} + {mainView === 'events' && activeTab === 'upcoming' && ( +
+ {(['all', 'today', 'weekend', 'week', 'month'] as DateWindow[]).map((w) => { + const active = dateWindow === w; + const label = w === 'all' ? 'Anytime' : DATE_WINDOW_LABELS[w]; + return ( + + ); + })} +
+ )} + {/* โ”€โ”€ Results โ”€โ”€ */}