* fix(admin): stop marking events expired up to 24h early (#909) (stable) differenceInDays truncates to whole days, so an event expiring in a few hours returned 0 and three admin surfaces treated it as gone: - EventsListPage: status chip said 'Expired' (days <= 0) while the public gallery — which compares real timestamps — correctly showed 'expires in X hours'. This is the reporter's exact symptom. - EventDetailsPage: same isExpired math on the detail view. - AdminDashboard: the expiring-soon card showed '0 days left' on the final day. Expired is now gated on the actual timestamp (expires_at <= now), and the countdown chips use ceiling days so the last day reads '1 day left' instead of flipping to Expired/0. * fix(admin): drop already-expired events from the dashboard card (#909 review round) The expiring-soon card ran Math.max(1, ceil(delta)), so an event that expired while the dashboard sat open (its query isn't polled) showed '1 day left' indefinitely from the stale cached row. Expired rows are now filtered out before render; the delta is therefore always positive and the clamp is gone. * fix(admin): refresh expiry status live at the boundary (#909 review round 2) Two review findings on the admin expiry surfaces: - The dashboard 'expiring soon' card, list badges, and detail banner are all computed inline from Date.now() at render, so a page left open across an event's expiry kept showing 'active'/'1 day left' until an unrelated render — which for editor/viewer roles (no health poll) never happens. - My round-1 client-side filter on the dashboard desynced the visible list from the cached total/stat ('no events expiring' beside 'view all N'). Both are fixed by new useExpiryRefresh: it fires once at the soonest future expiry (setTimeout, overflow-guarded). The dashboard refetches its expiring + stats queries — the backend already excludes expired events, so rows/total/stats come back consistent (filter removed). The list and detail pages bump a tick so the inline badges recompute. Hooks are placed above the loading early-returns (rules-of-hooks is disabled in eslint, so this was a latent crash otherwise). * fix(admin): expiry-refresh precision + filtered refetch (#909 review round 3) Three refinements to round-2's live-expiry work: - useExpiryRefresh now re-arms past setTimeout's ~24.8-day overflow limit (capped wake-up that re-evaluates) instead of dropping the timer, so a page mounted for weeks still updates. - The dashboard requests the expiring list ordered by expires_at asc, so the five shown rows ARE the soonest to expire — the timer schedules against the true next boundary even when >5 events are expiring (getEvents gains optional sortBy/sortOrder; backend already whitelists expires_at). - EventsListPage refetches instead of only re-rendering at the boundary: under the 'expiring' filter the backend drops expired rows, so a plain tick would leave a stale 'Expired' row + total. refetch keeps rows and totals correct under every filter. --------- Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
b32ba1ed6b
commit
6891769124
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// setTimeout stores its delay in a signed 32-bit int; anything larger
|
||||
// overflows and fires immediately. Events expiring weeks out don't need a
|
||||
// live tick anyway, so we simply don't schedule past this horizon.
|
||||
const MAX_TIMEOUT_MS = 2 ** 31 - 1;
|
||||
|
||||
/**
|
||||
* Fire `onExpiry` once, at the soonest future timestamp in `timestamps`
|
||||
* (#909 review). Admin expiry badges are computed inline from Date.now()
|
||||
* at render time, so without this a page left mounted across an event's
|
||||
* expiry keeps showing the stale "active"/"1 day left" state until an
|
||||
* unrelated render happens — which for editor/viewer roles (no health
|
||||
* poll) may never occur. When the callback updates state/data, the next
|
||||
* expiry reschedules automatically.
|
||||
*/
|
||||
export function useExpiryRefresh(
|
||||
timestamps: Array<string | null | undefined>,
|
||||
onExpiry: () => void,
|
||||
): void {
|
||||
const next = timestamps
|
||||
.map((t) => (t ? new Date(t).getTime() : NaN))
|
||||
.filter((n) => Number.isFinite(n) && n > Date.now())
|
||||
.sort((a, b) => a - b)[0];
|
||||
|
||||
// Bumped by a capped wake-up so the effect re-evaluates and re-arms when
|
||||
// the target is further out than a single setTimeout can represent.
|
||||
const [rearm, setRearm] = useState(0);
|
||||
useEffect(() => {
|
||||
if (next === undefined) return;
|
||||
// +1s so the timer lands just past the boundary, not exactly on it.
|
||||
const delay = next - Date.now() + 1000;
|
||||
if (delay > MAX_TIMEOUT_MS) {
|
||||
// Too far for one timer (setTimeout overflows past ~24.8 days and
|
||||
// fires immediately). Wake at the cap and re-arm with a now-smaller
|
||||
// remaining delay, so a page left mounted for weeks still updates.
|
||||
const id = window.setTimeout(() => setRearm((n) => n + 1), MAX_TIMEOUT_MS);
|
||||
return () => window.clearTimeout(id);
|
||||
}
|
||||
const id = window.setTimeout(onExpiry, Math.max(0, delay));
|
||||
return () => window.clearTimeout(id);
|
||||
}, [next, onExpiry, rearm]);
|
||||
}
|
||||
Reference in New Issue
Block a user