From 487f55f2d9463d85898555472cd66ae69d1d0f31 Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:23:55 +0200 Subject: [PATCH] fix(admin): stop marking events expired up to 24h early (#909) (#916) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-authored-by: Paul Nothaft --- frontend/src/pages/admin/AdminDashboard.tsx | 5 +++-- frontend/src/pages/admin/EventDetailsPage.tsx | 12 ++++++++---- frontend/src/pages/admin/EventsListPage.tsx | 12 +++++++++--- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/frontend/src/pages/admin/AdminDashboard.tsx b/frontend/src/pages/admin/AdminDashboard.tsx index 8ca892bc..2240f867 100644 --- a/frontend/src/pages/admin/AdminDashboard.tsx +++ b/frontend/src/pages/admin/AdminDashboard.tsx @@ -15,7 +15,7 @@ import { Check, X } from 'lucide-react'; -import { differenceInDays, parseISO } from 'date-fns'; +import { parseISO } from 'date-fns'; import { useTranslation } from 'react-i18next'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useMutationWithToast } from '../../hooks'; @@ -237,7 +237,8 @@ export const AdminDashboard: React.FC = () => { ) : (
{expiringEvents.map((event) => { - const daysLeft = differenceInDays(parseISO(event.expires_at!), new Date()); + // Ceiling (#909): truncation showed "0 days" on the last day. + const daysLeft = Math.max(1, Math.ceil((parseISO(event.expires_at!).getTime() - Date.now()) / 86400000)); return (
{ } const expiresAtDate = safeParseDate(event.expires_at); - const daysUntilExpiration = expiresAtDate ? differenceInDays(expiresAtDate, new Date()) : null; - const isExpired = daysUntilExpiration !== null && daysUntilExpiration <= 0; - const isExpiring = daysUntilExpiration !== null && daysUntilExpiration > 0 && daysUntilExpiration <= 7; + // Timestamp comparison, not truncated whole days (#909): the old + // differenceInDays <= 0 marked events "expired" up to 24h early. + // Ceiling keeps the countdown at "1 day" through the final day. + const isExpired = expiresAtDate !== null && expiresAtDate.getTime() <= Date.now(); + const daysUntilExpiration = expiresAtDate + ? Math.ceil((expiresAtDate.getTime() - Date.now()) / 86400000) + : null; + const isExpiring = !isExpired && daysUntilExpiration !== null && daysUntilExpiration > 0 && daysUntilExpiration <= 7; const handleStartEdit = () => { setEditForm({ diff --git a/frontend/src/pages/admin/EventsListPage.tsx b/frontend/src/pages/admin/EventsListPage.tsx index f8dd6bd0..cd1d3408 100644 --- a/frontend/src/pages/admin/EventsListPage.tsx +++ b/frontend/src/pages/admin/EventsListPage.tsx @@ -18,7 +18,7 @@ import { ChevronLeft, ChevronRight } from 'lucide-react'; -import { parseISO, differenceInDays } from 'date-fns'; +import { parseISO } from 'date-fns'; import { toast } from 'react-toastify'; import { useModal, useMutationWithToast } from '../../hooks'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; @@ -258,8 +258,14 @@ export const EventsListPage: React.FC = () => { if (!event.expires_at) return { label: t('events.active'), color: 'text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/40' }; - const days = differenceInDays(parseISO(event.expires_at), new Date()); - if (days <= 0) return { label: t('events.expired'), color: 'text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-900/40' }; + // Expired means the timestamp has actually passed (#909): + // differenceInDays truncates to whole days, so an event expiring in a + // few hours returned 0 and showed "Expired" while the public gallery + // (which compares real timestamps) correctly showed it active. + const expiresAt = parseISO(event.expires_at); + if (expiresAt.getTime() <= Date.now()) return { label: t('events.expired'), color: 'text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-900/40' }; + // Ceiling so the last day reads "1 day left", never "0 days". + const days = Math.ceil((expiresAt.getTime() - Date.now()) / 86400000); if (days <= 7) return { label: t('events.daysLeft', { count: days }), color: 'text-orange-600 dark:text-orange-400 bg-orange-100 dark:bg-orange-900/40' }; return { label: t('events.active'), color: 'text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/40' };