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) => (
-
+
))}
) : (