From 25c57cbb2fcec04df673b0a3d1e4ec34009e9a77 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 3 Sep 2026 14:33:28 +0700 Subject: [PATCH] Fix event UX correctness: live countdown, cancellation banner, NIP-52 tag cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FIX: EventCountdown label never updated (memo ignored the interval tick) — now recomputes every 30s for both EventCountdown and useEventStatus - Add loud cancelled / rescheduled banner on EventDetailPage derived from host updates; close RSVP + dim hero when cancelled so attendees don't show up to a dead jam - Clean up non-standard NIP-52 `a` tags (['a', coord, '', ''] → ['a', coord]) in RSVP and host-update publishing - Correct misleading kind comment in useEventRsvps Co-authored-by: shakespeare.diy --- src/components/EventCountdown.tsx | 9 ++--- src/hooks/useEventRsvps.ts | 4 +-- src/hooks/useEventUpdates.ts | 2 +- src/pages/EventDetailPage.tsx | 58 +++++++++++++++++++++++++++++-- 4 files changed, 64 insertions(+), 9 deletions(-) diff --git a/src/components/EventCountdown.tsx b/src/components/EventCountdown.tsx index cee1255..ae98c4b 100644 --- a/src/components/EventCountdown.tsx +++ b/src/components/EventCountdown.tsx @@ -49,7 +49,7 @@ export function EventCountdown({ end?: number; className?: string; }) { - const [, forceTick] = useState(0); + const [tick, forceTick] = useState(0); // Re-render periodically so the countdown stays fresh without a heavy timer. useEffect(() => { @@ -57,7 +57,8 @@ export function EventCountdown({ return () => clearInterval(id); }, []); - const info = useMemo(() => computeStatus(start, end), [start, end]); + // `tick` is a dependency so the label actually recomputes on each interval. + const info = useMemo(() => computeStatus(start, end), [start, end, tick]); const styles: Record = { live: 'bg-[#ff0040] text-white border-[#ff0040] animate-pulse', @@ -87,10 +88,10 @@ export function EventCountdown({ /** Standalone helper to get just the status (for conditional styling). */ export function useEventStatus(start: number, end?: number): EventStatus { - const [, forceTick] = useState(0); + const [tick, forceTick] = useState(0); useEffect(() => { const id = setInterval(() => forceTick((n) => n + 1), 30_000); return () => clearInterval(id); }, []); - return useMemo(() => computeStatus(start, end).status, [start, end]); + return useMemo(() => computeStatus(start, end).status, [start, end, tick]); } diff --git a/src/hooks/useEventRsvps.ts b/src/hooks/useEventRsvps.ts index cb8ca1c..8e01059 100644 --- a/src/hooks/useEventRsvps.ts +++ b/src/hooks/useEventRsvps.ts @@ -13,7 +13,7 @@ import { } from '@/lib/types'; /** - * NIP-52 RSVP layer for skate calendar events (kind 31925). + * NIP-52 RSVP layer for skate calendar events (calendar event kind 31923, RSVP kind 31925). * ───────────────────────────────────────────────────────────────────────────── * Given a calendar event coordinate ("31923:pubkey:d") we fetch every RSVP that * references it, keep the newest per person, and expose: @@ -129,7 +129,7 @@ export function useRsvp() { const tags: string[][] = [ ['d', d], - ['a', coord, '', ''], + ['a', coord], ['e', opts.event.id], ['status', opts.status], // Let the host easily query RSVPs addressed to them. diff --git a/src/hooks/useEventUpdates.ts b/src/hooks/useEventUpdates.ts index 6547ae0..f2a4ad3 100644 --- a/src/hooks/useEventUpdates.ts +++ b/src/hooks/useEventUpdates.ts @@ -73,7 +73,7 @@ export function usePostEventUpdate() { ); const tags: string[][] = [ - ['a', coord, '', ''], + ['a', coord], ['e', opts.event.id], ['update-kind', kind], ['t', 'skateboarding'], diff --git a/src/pages/EventDetailPage.tsx b/src/pages/EventDetailPage.tsx index 463c98c..a915f2c 100644 --- a/src/pages/EventDetailPage.tsx +++ b/src/pages/EventDetailPage.tsx @@ -20,6 +20,7 @@ import { CommentsSection } from '@/components/comments/CommentsSection'; import { DeleteEntityDialog } from '@/components/DeleteEntityDialog'; import { useSkateEvent } from '@/hooks/useSkateEvents'; import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps'; +import { useEventUpdates } from '@/hooks/useEventUpdates'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; import { cn } from '@/lib/utils'; @@ -37,6 +38,8 @@ import { Pencil, Music, Megaphone, + XCircle, + CalendarClock, } from 'lucide-react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useToast } from '@/hooks/useToast'; @@ -144,6 +147,19 @@ export default function EventDetailPage() { // NIP-52 RSVPs for this event (counts + attendee avatars). const rsvpSummary = useEventRsvps(event ? eventCoord(event) : undefined); + // Host updates → detect if the host cancelled or rescheduled, so we can warn + // attendees loudly instead of letting them show up to a dead jam. + const { data: eventUpdates } = useEventUpdates(event ? eventCoord(event) : undefined); + const latestCancellation = useMemo( + () => eventUpdates?.find((u) => u.updateKind === 'cancelled') ?? null, + [eventUpdates], + ); + const latestReschedule = useMemo(() => { + if (latestCancellation) return null; // cancellation wins + return eventUpdates?.find((u) => u.updateKind === 'reschedule' || u.updateKind === 'relocation') ?? null; + }, [eventUpdates, latestCancellation]); + const isCancelled = !!latestCancellation; + const isUpcoming = event ? event.start > Date.now() / 1000 : false; const isPast = event ? event.start < Date.now() / 1000 : false; @@ -273,6 +289,38 @@ export default function EventDetailPage() { /> + {/* Cancelled / rescheduled banner — loud, above everything */} + {isCancelled && ( +
+ +
+

+ This event was cancelled +

+ {latestCancellation?.message && ( +

+ {latestCancellation.message} +

+ )} +
+
+ )} + {!isCancelled && latestReschedule && ( +
+ +
+

+ {latestReschedule.updateKind === 'relocation' ? 'New location' : 'Time changed'} — check the update +

+ {latestReschedule.message && ( +

+ {latestReschedule.message} +

+ )} +
+
+ )} +
{/* Main Content */}
@@ -286,7 +334,7 @@ export default function EventDetailPage() { alt={event.title} className={cn( 'h-full w-full object-cover', - isPast && 'grayscale-[35%] brightness-75', + (isPast || isCancelled) && 'grayscale-[35%] brightness-75', )} /> ) : ( @@ -493,7 +541,13 @@ export default function EventDetailPage() { - {!isPast && } + {isCancelled ? ( +

+ RSVPs are closed — this event was cancelled by the host. +

+ ) : ( + !isPast && + )} {(rsvpSummary.goingCount > 0 || rsvpSummary.tentativeCount > 0) ? (