Events discovery polish + real bug/perf fixes across the grid

UX (all participants):
- EventCard now shows a live status pill (In 2 days / HAPPENING NOW / ENDED) on
  both the full and compact variants — the at-a-glance signal was previously
  detail-page only
- EventCard shows a "Presented by <sponsor>" ribbon so paid sponsor placements
  get real visibility in the discovery feed (not just after a click)
- Added a bottom legibility gradient so overlays stay readable on any cover

FIX (crash risk): EventsPage getTrustScore did `trustScores?.map.get` — since
useBatchTrustScores returns a plain Map (no `.map`), this threw TypeError once
scores loaded. Now reads the Map directly.

PERF (N+1 query storm): rendering the grid fired one RSVP query PER card
(~100 queries on a full page). Added useBatchEventRsvps — a single #a-filtered
query for all event coordinates — and thread goingCount into EventCard as a prop.
Cards without the prop (detail page, hosting dashboard) keep their own query.

Removed now-dead getRelativeTime/relativeTime/isUpcoming (superseded by the
EventCountdown pill).

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-03 14:47:02 +07:00
parent 854d18c322
commit 135fbaab40
3 changed files with 107 additions and 32 deletions

View file

@ -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) {
</h3>
<div className="flex items-center gap-1 flex-shrink-0">
<ProBadge featuredUntil={event.featuredUntil} compact />
{isUpcoming && (
<Badge className="text-xs bg-[#ff0040] text-white border-0 font-bold uppercase">
{relativeTime}
</Badge>
)}
<EventCountdown start={event.start} end={event.end} />
</div>
</div>
@ -153,8 +145,11 @@ export function EventCard({ event, compact = false }: EventCardProps) {
<Calendar className="h-12 w-12 text-primary/40" />
</div>
)}
{/* Bottom legibility gradient for overlays */}
<div className="absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-black/70 to-transparent pointer-events-none" />
{/* Date overlay */}
<div className="absolute top-3 left-3">
<div className="absolute top-3 left-3 flex items-center gap-2">
<div className="bg-background/95 backdrop-blur-sm rounded-lg px-3 py-2 text-center shadow-lg">
<div className="text-xs font-medium text-primary uppercase">
{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()}
</div>
</div>
{/* Live status pill — the at-a-glance signal in the grid */}
<EventCountdown start={event.start} end={event.end} className="shadow-lg" />
</div>
{/* Type badges + Pro */}
<div className="absolute top-3 right-3 flex flex-wrap gap-1 justify-end">
@ -176,6 +173,19 @@ export function EventCard({ event, compact = false }: EventCardProps) {
</Badge>
))}
</div>
{/* Sponsor hint — gives paid placements visibility in the feed */}
{leadSponsor && (
<div className="absolute bottom-3 left-3 right-3 flex items-center gap-1.5 text-[11px] font-bold text-white/90">
<Sparkles className="h-3 w-3 flex-shrink-0 text-[#ccff00]" />
<span className="truncate">
<span className="text-white/60 uppercase tracking-wide">Presented by </span>
{leadSponsor.name}
{sponsors.length > 1 && (
<span className="text-white/60"> +{sponsors.length - 1}</span>
)}
</span>
</div>
)}
</div>
<CardHeader className="pb-2">

View file

@ -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<string, { goingCount: number; tentativeCount: number }> {
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<string, EventRsvp>();
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<string, { goingCount: number; tentativeCount: number }>();
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.

View file

@ -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<string, number>).get(pubkey) : trustScores?.get(pubkey);
// `trustScores` is a plain Map<pubkey, score> (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 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 stagger-children">
{filteredEvents.upcoming.map((event) => (
<EventCard key={event.id} event={event} />
<EventCard
key={event.id}
event={event}
goingCount={batchRsvps.get(eventCoord(event))?.goingCount ?? 0}
/>
))}
</div>
) : (
@ -1128,7 +1138,11 @@ export default function EventsPage() {
filteredEvents.past.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 stagger-children">
{filteredEvents.past.map((event) => (
<EventCard key={event.id} event={event} />
<EventCard
key={event.id}
event={event}
goingCount={batchRsvps.get(eventCoord(event))?.goingCount ?? 0}
/>
))}
</div>
) : (