fix(admin): stop marking events expired up to 24h early (#909) (stable) (#917)

* 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:
Paul Nothaft
2026-07-30 12:14:54 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent b32ba1ed6b
commit 6891769124
5 changed files with 117 additions and 14 deletions
+43
View File
@@ -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]);
}