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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-03 10:47:57 +07:00
parent 453adc4604
commit 7439b980bb
10 changed files with 605 additions and 8 deletions

26
NIP.md
View file

@ -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": "<optional note>",
"tags": [
["d", "<unique-id>"],
["a", "31923:<event-author-pubkey>:<event-d>", "", ""],
["e", "<event-id>"],
["status", "accepted"],
["p", "<event-author-pubkey>"],
["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:<host>:<d>"] }` and keep the newest per pubkey. Hosts see incoming RSVPs in their notification center via `{ kinds: [31925], "#p": ["<host-pubkey>"] }`.
## Social Interaction
Comments and reactions to skate spots and events use standard Nostr kinds:

View file

@ -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' },
};
/**

View file

@ -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 = (
<Avatar
className="h-7 w-7 border-2 border-background ring-0"
style={{ zIndex: 20 - index }}
title={name}
>
<AvatarImage src={pic} alt={name} />
<AvatarFallback className="text-[9px] font-bold">
{name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
);
if (!npub) return avatar;
return (
<Link to={`/${npub}`} className="transition-transform hover:-translate-y-0.5">
{avatar}
</Link>
);
}
/**
* 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 (
<div className={cn('flex items-center', className)}>
<div className="flex -space-x-2">
{shown.map((pk, i) => (
<StackAvatar key={pk} pubkey={pk} index={i} />
))}
</div>
{overflow > 0 && (
<span className="ml-2 text-xs font-bold text-muted-foreground">+{overflow}</span>
)}
</div>
);
}

View file

@ -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) {
<span className="truncate">{event.location}</span>
</span>
)}
{goingCount > 0 && (
<span className="flex items-center gap-1 text-green-400 font-bold">
<Users className="h-3 w-3" />
{goingCount}
</span>
)}
<TrustBadge pubkey={event.pubkey} size="sm" />
</div>
</div>
@ -228,7 +236,15 @@ export function EventCard({ event, compact = false }: EventCardProps) {
</p>
)}
<div className="flex items-center justify-end mt-4 pt-4 border-t border-[#ccff00]/20">
<div className="flex items-center justify-between mt-4 pt-4 border-t border-[#ccff00]/20">
{goingCount > 0 ? (
<span className="flex items-center gap-1.5 text-sm font-bold text-green-400">
<Users className="h-4 w-4" />
{goingCount} going
</span>
) : (
<span />
)}
<div className="flex items-center gap-2" onClick={(e) => e.preventDefault()}>
<ZapButton target={event.event} className="text-gray-400 hover:text-[#ccff00]" />
{/* More options menu */}

View file

@ -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 (
<div className={cn('rounded-xl border border-white/10 bg-white/[0.03] p-4 text-center', className)}>
<p className="mb-3 text-sm font-bold text-gray-300">
Are you going?{' '}
<span className="text-muted-foreground font-normal">Log in to RSVP.</span>
</p>
<LoginArea className="w-full justify-center" />
</div>
);
}
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 (
<div className={cn('space-y-2', className)}>
{isHost && (
<p className="text-xs text-muted-foreground">
You're hosting this — but you can still set your status.
</p>
)}
<div className="grid grid-cols-3 gap-2">
{OPTIONS.map(({ status, label, shortLabel, icon: Icon, activeClass }) => {
const active = myStatus === status;
return (
<button
key={status}
type="button"
disabled={isPending}
onClick={() => handleClick(status)}
aria-pressed={active}
className={cn(
'flex flex-col items-center justify-center gap-1 rounded-xl border-2 py-2.5 text-xs font-black uppercase tracking-wide transition-all active:scale-95 disabled:opacity-60',
active
? activeClass
: 'border-white/10 bg-white/5 text-gray-400 hover:border-white/20 hover:text-white',
)}
>
{isPending && active ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Icon className="h-4 w-4" />
)}
<span className="hidden sm:inline">{label}</span>
<span className="sm:hidden">{shortLabel}</span>
</button>
);
})}
</div>
{(goingCount > 0 || tentativeCount > 0) && (
<p className="text-center text-xs text-muted-foreground">
<span className="font-bold text-green-400">{goingCount}</span> going
{tentativeCount > 0 && (
<>
{' · '}
<span className="font-bold text-[#ccff00]">{tentativeCount}</span> interested
</>
)}
</p>
)}
</div>
);
}

View file

@ -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';
}

155
src/hooks/useEventRsvps.ts Normal file
View file

@ -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<SkateEvent, 'pubkey' | 'identifier'>): 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<string, EventRsvp>();
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 };
}

View file

@ -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' },

View file

@ -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() {
</CardContent>
</Card>
{/* RSVP / Attendance */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Users className="h-5 w-5 text-primary" />
{isPast ? 'Who came' : 'Who\u2019s going'}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!isPast && <RsvpButtons event={event} />}
{rsvpSummary.goingCount > 0 ? (
<div>
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-black uppercase tracking-widest text-muted-foreground">
Going · {rsvpSummary.goingCount}
</span>
{rsvpSummary.tentativeCount > 0 && (
<span className="text-xs text-muted-foreground">
{rsvpSummary.tentativeCount} interested
</span>
)}
</div>
<AttendeeStack pubkeys={rsvpSummary.going} max={8} />
</div>
) : (
!rsvpSummary.isLoading &&
isPast && (
<p className="text-sm text-muted-foreground">No RSVPs were recorded.</p>
)
)}
</CardContent>
</Card>
{/* Map (if has location) */}
{coordinates && (
<Card>

View file

@ -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<Exclude<DateWindow, 'all'>, 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<DateWindow, 'all'>): [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<TrustFilterLevel>(smartDefault);
const [sortBy, setSortBy] = useState<SortOption>('recent');
const [dateWindow, setDateWindow] = useState<DateWindow>('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' && (
<div className="mt-4 flex items-center gap-2 overflow-x-auto pb-1 scrollbar-none">
{(['all', 'today', 'weekend', 'week', 'month'] as DateWindow[]).map((w) => {
const active = dateWindow === w;
const label = w === 'all' ? 'Anytime' : DATE_WINDOW_LABELS[w];
return (
<button
key={w}
type="button"
onClick={() => setDateWindow(w)}
className={cn(
'flex-shrink-0 rounded-full border px-3 py-1.5 text-xs font-bold uppercase tracking-wide transition-all active:scale-95',
active
? 'border-[#ccff00] bg-[#ccff00] text-black'
: 'border-white/10 bg-white/5 text-gray-400 hover:border-white/20 hover:text-white',
)}
>
{label}
</button>
);
})}
</div>
)}
{/* ── Results ── */}
<div className="mt-6">