diff --git a/src/components/EventCard.tsx b/src/components/EventCard.tsx index 93ab8d2..33bf5ab 100644 --- a/src/components/EventCard.tsx +++ b/src/components/EventCard.tsx @@ -11,12 +11,13 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { Calendar, Clock, MapPin, MoreVertical, Flag, Users } from 'lucide-react'; +import { Calendar, Clock, MapPin, MoreVertical, Flag, Users, Sparkles } 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 { EventCountdown } from '@/components/EventCountdown'; import { TrustBadge } from '@/components/TrustBadge'; import { OfficialBadge } from '@/components/OfficialBadge'; import { ProBadge } from '@/components/ProBadge'; @@ -27,6 +28,12 @@ import { EVENT_TYPE_LABELS } from '@/lib/types'; interface EventCardProps { event: SkateEvent; compact?: boolean; + /** + * Pre-computed going count from a batch query (see useBatchEventRsvps). When + * provided, the card skips its own per-card RSVP query — this avoids an N+1 + * query storm when rendering a grid of cards. + */ + goingCount?: number; } function formatDate(timestamp: number): string { @@ -46,35 +53,24 @@ function formatTime(timestamp: number): string { }); } -function getRelativeTime(timestamp: number): string { - const now = Date.now() / 1000; - const diff = timestamp - now; - - if (diff < 0) return 'Past'; - - const days = Math.floor(diff / 86400); - const hours = Math.floor((diff % 86400) / 3600); - - if (days > 0) { - return days === 1 ? 'Tomorrow' : `In ${days} days`; - } - if (hours > 0) { - return `In ${hours} hour${hours > 1 ? 's' : ''}`; - } - return 'Starting soon'; -} - -export function EventCard({ event, compact = false }: EventCardProps) { +export function EventCard({ event, compact = false, goingCount: goingCountProp }: EventCardProps) { const author = useAuthor(event.pubkey); const { user } = useCurrentUser(); const metadata = author.data?.metadata; const displayName = metadata?.name ?? genUserName(event.pubkey); const [showReportDialog, setShowReportDialog] = useState(false); - const relativeTime = getRelativeTime(event.start); - const isUpcoming = event.start > Date.now() / 1000; const isOwner = user?.pubkey === event.pubkey; - const { goingCount } = useEventRsvps(eventCoord(event)); + // Skip the per-card RSVP query when a batch count was supplied (grid view). + const { goingCount: goingCountSelf } = useEventRsvps( + goingCountProp === undefined ? eventCoord(event) : undefined, + ); + const goingCount = goingCountProp ?? goingCountSelf; + + // Sponsors ride along on the parsed event (no extra query). Title sponsor + // first, so paid placements get real visibility in the discovery feed. + const sponsors = event.sponsors ?? []; + const leadSponsor = sponsors.find((s) => s.tier === 'title') ?? sponsors[0]; if (compact) { return ( @@ -102,11 +98,7 @@ export function EventCard({ event, compact = false }: EventCardProps) {
- {isUpcoming && ( - - {relativeTime} - - )} +
@@ -153,8 +145,11 @@ export function EventCard({ event, compact = false }: EventCardProps) { )} + {/* Bottom legibility gradient for overlays */} +
+ {/* Date overlay */} -
+
{new Date(event.start * 1000).toLocaleDateString('en-US', { month: 'short' })} @@ -163,6 +158,8 @@ export function EventCard({ event, compact = false }: EventCardProps) { {new Date(event.start * 1000).getDate()}
+ {/* Live status pill — the at-a-glance signal in the grid */} +
{/* Type badges + Pro */}
@@ -176,6 +173,19 @@ export function EventCard({ event, compact = false }: EventCardProps) { ))}
+ {/* Sponsor hint — gives paid placements visibility in the feed */} + {leadSponsor && ( +
+ + + Presented by + {leadSponsor.name} + {sponsors.length > 1 && ( + +{sponsors.length - 1} + )} + +
+ )}
diff --git a/src/hooks/useEventRsvps.ts b/src/hooks/useEventRsvps.ts index 8e01059..2f2afd0 100644 --- a/src/hooks/useEventRsvps.ts +++ b/src/hooks/useEventRsvps.ts @@ -105,6 +105,57 @@ export function useEventRsvps(coord: string | undefined): EventRsvpSummary { }, [query.data, query.isLoading, user]); } +/** + * Batch RSVP counts for a whole list of events in ONE relay query. + * + * Rendering a grid of EventCards used to fire one RSVP query PER card (an N+1 + * storm on a 100-event page). This queries every event coordinate at once with + * a single `#a` filter, dedupes to the newest RSVP per person per event, and + * returns a Map of coordinate → { goingCount, tentativeCount }. + */ +export function useBatchEventRsvps( + coords: string[], +): Map { + const { querySkateContent, getQueryKeySuffix } = useSkateRelays(); + + // Stable key regardless of order so we don't refetch on re-render churn. + const sortedKey = useMemo(() => [...coords].sort().join(','), [coords]); + + const query = useQuery({ + queryKey: ['batch-event-rsvps', sortedKey, getQueryKeySuffix()], + enabled: coords.length > 0, + staleTime: 30_000, + queryFn: async (c) => { + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]); + const events = await querySkateContent( + [{ kinds: [CALENDAR_RSVP_KIND], '#a': coords, limit: 2000 }], + { signal }, + ); + + // newest RSVP per (coord, pubkey) + const latest = new Map(); + for (const ev of events) { + const rsvp = parseEventRsvp(ev); + if (!rsvp || !coords.includes(rsvp.eventCoord)) continue; + const key = `${rsvp.eventCoord}|${rsvp.pubkey}`; + const existing = latest.get(key); + if (!existing || rsvp.createdAt > existing.createdAt) latest.set(key, rsvp); + } + + const counts = new Map(); + for (const rsvp of latest.values()) { + const entry = counts.get(rsvp.eventCoord) ?? { goingCount: 0, tentativeCount: 0 }; + if (rsvp.status === 'accepted') entry.goingCount += 1; + else if (rsvp.status === 'tentative') entry.tentativeCount += 1; + counts.set(rsvp.eventCoord, entry); + } + return counts; + }, + }); + + return query.data ?? new Map(); +} + /** * 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. diff --git a/src/pages/EventsPage.tsx b/src/pages/EventsPage.tsx index 93a6be9..088feda 100644 --- a/src/pages/EventsPage.tsx +++ b/src/pages/EventsPage.tsx @@ -10,6 +10,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sh import { type TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter'; import { type SortOption } from '@/components/SortDropdown'; import { useSkateEvents } from '@/hooks/useSkateEvents'; +import { useBatchEventRsvps, eventCoord } from '@/hooks/useEventRsvps'; import { useTours } from '@/hooks/useTours'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useFollowing } from '@/hooks/useFollowing'; @@ -818,7 +819,8 @@ export default function EventsPage() { return [...new Set([...ep, ...tp])]; }, [events, tours]); const { data: trustScores } = useBatchTrustScores(allPubkeys); - const getTrustScore = (pubkey: string) => trustScores?.map.get ? (trustScores as unknown as Map).get(pubkey) : trustScores?.get(pubkey); + // `trustScores` is a plain Map (see useBatchTrustScores). + const getTrustScore = (pubkey: string): number | undefined => trustScores?.get(pubkey); // ── Comment counts ──────────────────────────────────────────────────────────── const allEventIds = useMemo(() => { @@ -831,6 +833,10 @@ export default function EventsPage() { const getCommentCount = (eventId: string) => allComments?.filter((c) => c.tags.find(([tag]) => tag === 'e')?.[1] === eventId).length || 0; + // ── Batch RSVP counts (one query for the whole grid, not one per card) ───────── + const eventCoords = useMemo(() => (events ?? []).map((e) => eventCoord(e)), [events]); + const batchRsvps = useBatchEventRsvps(eventCoords); + // ── Counts for tab badges ───────────────────────────────────────────────────── const rawCounts = useMemo(() => ({ events: events?.length ?? 0, @@ -1109,7 +1115,11 @@ export default function EventsPage() { filteredEvents.upcoming.length > 0 ? (
{filteredEvents.upcoming.map((event) => ( - + ))}
) : ( @@ -1128,7 +1138,11 @@ export default function EventsPage() { filteredEvents.past.length > 0 ? (
{filteredEvents.past.map((event) => ( - + ))}
) : (