Fix event UX correctness: live countdown, cancellation banner, NIP-52 tag cleanup

- 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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-03 14:33:28 +07:00
parent 29d09eb6ec
commit 25c57cbb2f
4 changed files with 64 additions and 9 deletions

View file

@ -49,7 +49,7 @@ export function EventCountdown({
end?: number; end?: number;
className?: string; className?: string;
}) { }) {
const [, forceTick] = useState(0); const [tick, forceTick] = useState(0);
// Re-render periodically so the countdown stays fresh without a heavy timer. // Re-render periodically so the countdown stays fresh without a heavy timer.
useEffect(() => { useEffect(() => {
@ -57,7 +57,8 @@ export function EventCountdown({
return () => clearInterval(id); 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<EventStatus, string> = { const styles: Record<EventStatus, string> = {
live: 'bg-[#ff0040] text-white border-[#ff0040] animate-pulse', 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). */ /** Standalone helper to get just the status (for conditional styling). */
export function useEventStatus(start: number, end?: number): EventStatus { export function useEventStatus(start: number, end?: number): EventStatus {
const [, forceTick] = useState(0); const [tick, forceTick] = useState(0);
useEffect(() => { useEffect(() => {
const id = setInterval(() => forceTick((n) => n + 1), 30_000); const id = setInterval(() => forceTick((n) => n + 1), 30_000);
return () => clearInterval(id); return () => clearInterval(id);
}, []); }, []);
return useMemo(() => computeStatus(start, end).status, [start, end]); return useMemo(() => computeStatus(start, end).status, [start, end, tick]);
} }

View file

@ -13,7 +13,7 @@ import {
} from '@/lib/types'; } 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 * Given a calendar event coordinate ("31923:pubkey:d") we fetch every RSVP that
* references it, keep the newest per person, and expose: * references it, keep the newest per person, and expose:
@ -129,7 +129,7 @@ export function useRsvp() {
const tags: string[][] = [ const tags: string[][] = [
['d', d], ['d', d],
['a', coord, '', ''], ['a', coord],
['e', opts.event.id], ['e', opts.event.id],
['status', opts.status], ['status', opts.status],
// Let the host easily query RSVPs addressed to them. // Let the host easily query RSVPs addressed to them.

View file

@ -73,7 +73,7 @@ export function usePostEventUpdate() {
); );
const tags: string[][] = [ const tags: string[][] = [
['a', coord, '', ''], ['a', coord],
['e', opts.event.id], ['e', opts.event.id],
['update-kind', kind], ['update-kind', kind],
['t', 'skateboarding'], ['t', 'skateboarding'],

View file

@ -20,6 +20,7 @@ import { CommentsSection } from '@/components/comments/CommentsSection';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog'; import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { useSkateEvent } from '@/hooks/useSkateEvents'; import { useSkateEvent } from '@/hooks/useSkateEvents';
import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps'; import { useEventRsvps, eventCoord } from '@/hooks/useEventRsvps';
import { useEventUpdates } from '@/hooks/useEventUpdates';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -37,6 +38,8 @@ import {
Pencil, Pencil,
Music, Music,
Megaphone, Megaphone,
XCircle,
CalendarClock,
} from 'lucide-react'; } from 'lucide-react';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
@ -144,6 +147,19 @@ export default function EventDetailPage() {
// NIP-52 RSVPs for this event (counts + attendee avatars). // NIP-52 RSVPs for this event (counts + attendee avatars).
const rsvpSummary = useEventRsvps(event ? eventCoord(event) : undefined); 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 isUpcoming = event ? event.start > Date.now() / 1000 : false;
const isPast = event ? event.start < Date.now() / 1000 : false; const isPast = event ? event.start < Date.now() / 1000 : false;
@ -273,6 +289,38 @@ export default function EventDetailPage() {
/> />
</div> </div>
{/* Cancelled / rescheduled banner — loud, above everything */}
{isCancelled && (
<div className="mb-6 flex items-start gap-3 rounded-xl border-2 border-red-500/60 bg-red-500/10 p-4">
<XCircle className="mt-0.5 h-6 w-6 flex-shrink-0 text-red-400" />
<div className="min-w-0">
<p className="text-base font-black uppercase tracking-wide text-red-300">
This event was cancelled
</p>
{latestCancellation?.message && (
<p className="mt-1 whitespace-pre-wrap break-words text-sm text-red-100/90">
{latestCancellation.message}
</p>
)}
</div>
</div>
)}
{!isCancelled && latestReschedule && (
<div className="mb-6 flex items-start gap-3 rounded-xl border-2 border-amber-500/50 bg-amber-500/10 p-4">
<CalendarClock className="mt-0.5 h-6 w-6 flex-shrink-0 text-amber-400" />
<div className="min-w-0">
<p className="text-base font-black uppercase tracking-wide text-amber-300">
{latestReschedule.updateKind === 'relocation' ? 'New location' : 'Time changed'} — check the update
</p>
{latestReschedule.message && (
<p className="mt-1 whitespace-pre-wrap break-words text-sm text-amber-100/90">
{latestReschedule.message}
</p>
)}
</div>
</div>
)}
<div className="grid lg:grid-cols-3 gap-8"> <div className="grid lg:grid-cols-3 gap-8">
{/* Main Content */} {/* Main Content */}
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
@ -286,7 +334,7 @@ export default function EventDetailPage() {
alt={event.title} alt={event.title}
className={cn( className={cn(
'h-full w-full object-cover', '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() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{!isPast && <RsvpButtons event={event} />} {isCancelled ? (
<p className="rounded-lg border border-red-500/30 bg-red-500/5 px-3 py-2 text-sm font-medium text-red-300">
RSVPs are closed — this event was cancelled by the host.
</p>
) : (
!isPast && <RsvpButtons event={event} />
)}
{(rsvpSummary.goingCount > 0 || rsvpSummary.tentativeCount > 0) ? ( {(rsvpSummary.goingCount > 0 || rsvpSummary.tentativeCount > 0) ? (
<AttendeeList going={rsvpSummary.going} tentative={rsvpSummary.tentative} /> <AttendeeList going={rsvpSummary.going} tentative={rsvpSummary.tentative} />