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,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} 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 { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
|
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
|
||||||
import { ZapButton } from '@/components/ZapButton';
|
import { ZapButton } from '@/components/ZapButton';
|
||||||
|
import { EventCountdown } from '@/components/EventCountdown';
|
||||||
import { TrustBadge } from '@/components/TrustBadge';
|
import { TrustBadge } from '@/components/TrustBadge';
|
||||||
import { OfficialBadge } from '@/components/OfficialBadge';
|
import { OfficialBadge } from '@/components/OfficialBadge';
|
||||||
import { ProBadge } from '@/components/ProBadge';
|
import { ProBadge } from '@/components/ProBadge';
|
||||||
|
|
@ -27,6 +28,12 @@ import { EVENT_TYPE_LABELS } from '@/lib/types';
|
||||||
interface EventCardProps {
|
interface EventCardProps {
|
||||||
event: SkateEvent;
|
event: SkateEvent;
|
||||||
compact?: boolean;
|
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 {
|
function formatDate(timestamp: number): string {
|
||||||
|
|
@ -46,35 +53,24 @@ function formatTime(timestamp: number): string {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRelativeTime(timestamp: number): string {
|
export function EventCard({ event, compact = false, goingCount: goingCountProp }: EventCardProps) {
|
||||||
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) {
|
|
||||||
const author = useAuthor(event.pubkey);
|
const author = useAuthor(event.pubkey);
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const metadata = author.data?.metadata;
|
const metadata = author.data?.metadata;
|
||||||
const displayName = metadata?.name ?? genUserName(event.pubkey);
|
const displayName = metadata?.name ?? genUserName(event.pubkey);
|
||||||
const [showReportDialog, setShowReportDialog] = useState(false);
|
const [showReportDialog, setShowReportDialog] = useState(false);
|
||||||
|
|
||||||
const relativeTime = getRelativeTime(event.start);
|
|
||||||
const isUpcoming = event.start > Date.now() / 1000;
|
|
||||||
const isOwner = user?.pubkey === event.pubkey;
|
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) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -102,11 +98,7 @@ export function EventCard({ event, compact = false }: EventCardProps) {
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
<ProBadge featuredUntil={event.featuredUntil} compact />
|
<ProBadge featuredUntil={event.featuredUntil} compact />
|
||||||
{isUpcoming && (
|
<EventCountdown start={event.start} end={event.end} />
|
||||||
<Badge className="text-xs bg-[#ff0040] text-white border-0 font-bold uppercase">
|
|
||||||
{relativeTime}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -153,8 +145,11 @@ export function EventCard({ event, compact = false }: EventCardProps) {
|
||||||
<Calendar className="h-12 w-12 text-primary/40" />
|
<Calendar className="h-12 w-12 text-primary/40" />
|
||||||
</div>
|
</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 */}
|
{/* 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="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">
|
<div className="text-xs font-medium text-primary uppercase">
|
||||||
{new Date(event.start * 1000).toLocaleDateString('en-US', { month: 'short' })}
|
{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()}
|
{new Date(event.start * 1000).getDate()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Live status pill — the at-a-glance signal in the grid */}
|
||||||
|
<EventCountdown start={event.start} end={event.end} className="shadow-lg" />
|
||||||
</div>
|
</div>
|
||||||
{/* Type badges + Pro */}
|
{/* Type badges + Pro */}
|
||||||
<div className="absolute top-3 right-3 flex flex-wrap gap-1 justify-end">
|
<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>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,57 @@ export function useEventRsvps(coord: string | undefined): EventRsvpSummary {
|
||||||
}, [query.data, query.isLoading, user]);
|
}, [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.
|
* 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.
|
* 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 TrustFilterLevel, filterByTrustLevel } from '@/components/TrustFilter';
|
||||||
import { type SortOption } from '@/components/SortDropdown';
|
import { type SortOption } from '@/components/SortDropdown';
|
||||||
import { useSkateEvents } from '@/hooks/useSkateEvents';
|
import { useSkateEvents } from '@/hooks/useSkateEvents';
|
||||||
|
import { useBatchEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
|
||||||
import { useTours } from '@/hooks/useTours';
|
import { useTours } from '@/hooks/useTours';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { useFollowing } from '@/hooks/useFollowing';
|
import { useFollowing } from '@/hooks/useFollowing';
|
||||||
|
|
@ -818,7 +819,8 @@ export default function EventsPage() {
|
||||||
return [...new Set([...ep, ...tp])];
|
return [...new Set([...ep, ...tp])];
|
||||||
}, [events, tours]);
|
}, [events, tours]);
|
||||||
const { data: trustScores } = useBatchTrustScores(allPubkeys);
|
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 ────────────────────────────────────────────────────────────
|
// ── Comment counts ────────────────────────────────────────────────────────────
|
||||||
const allEventIds = useMemo(() => {
|
const allEventIds = useMemo(() => {
|
||||||
|
|
@ -831,6 +833,10 @@ export default function EventsPage() {
|
||||||
const getCommentCount = (eventId: string) =>
|
const getCommentCount = (eventId: string) =>
|
||||||
allComments?.filter((c) => c.tags.find(([tag]) => tag === 'e')?.[1] === eventId).length || 0;
|
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 ─────────────────────────────────────────────────────
|
// ── Counts for tab badges ─────────────────────────────────────────────────────
|
||||||
const rawCounts = useMemo(() => ({
|
const rawCounts = useMemo(() => ({
|
||||||
events: events?.length ?? 0,
|
events: events?.length ?? 0,
|
||||||
|
|
@ -1109,7 +1115,11 @@ export default function EventsPage() {
|
||||||
filteredEvents.upcoming.length > 0 ? (
|
filteredEvents.upcoming.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 stagger-children">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 stagger-children">
|
||||||
{filteredEvents.upcoming.map((event) => (
|
{filteredEvents.upcoming.map((event) => (
|
||||||
<EventCard key={event.id} event={event} />
|
<EventCard
|
||||||
|
key={event.id}
|
||||||
|
event={event}
|
||||||
|
goingCount={batchRsvps.get(eventCoord(event))?.goingCount ?? 0}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -1128,7 +1138,11 @@ export default function EventsPage() {
|
||||||
filteredEvents.past.length > 0 ? (
|
filteredEvents.past.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 stagger-children">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 stagger-children">
|
||||||
{filteredEvents.past.map((event) => (
|
{filteredEvents.past.map((event) => (
|
||||||
<EventCard key={event.id} event={event} />
|
<EventCard
|
||||||
|
key={event.id}
|
||||||
|
event={event}
|
||||||
|
goingCount={batchRsvps.get(eventCoord(event))?.goingCount ?? 0}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue