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:
parent
854d18c322
commit
135fbaab40
3 changed files with 107 additions and 32 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
Loading…
Reference in a new issue