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

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 <[email protected]>
This commit is contained in:
Paul Nothaft
2026-07-29 22:23:55 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent aca3c8e4bc
commit 487f55f2d9
3 changed files with 20 additions and 9 deletions
+3 -2
View File
@@ -15,7 +15,7 @@ import {
Check, Check,
X X
} from 'lucide-react'; } from 'lucide-react';
import { differenceInDays, parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useMutationWithToast } from '../../hooks'; import { useMutationWithToast } from '../../hooks';
@@ -237,7 +237,8 @@ export const AdminDashboard: React.FC = () => {
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{expiringEvents.map((event) => { {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 ( return (
<div <div
@@ -1,7 +1,6 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { differenceInDays } from 'date-fns';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
@@ -290,9 +289,14 @@ export const EventDetailsPage: React.FC = () => {
} }
const expiresAtDate = safeParseDate(event.expires_at); const expiresAtDate = safeParseDate(event.expires_at);
const daysUntilExpiration = expiresAtDate ? differenceInDays(expiresAtDate, new Date()) : null; // Timestamp comparison, not truncated whole days (#909): the old
const isExpired = daysUntilExpiration !== null && daysUntilExpiration <= 0; // differenceInDays <= 0 marked events "expired" up to 24h early.
const isExpiring = daysUntilExpiration !== null && daysUntilExpiration > 0 && daysUntilExpiration <= 7; // 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 = () => { const handleStartEdit = () => {
setEditForm({ setEditForm({
+9 -3
View File
@@ -18,7 +18,7 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight ChevronRight
} from 'lucide-react'; } from 'lucide-react';
import { parseISO, differenceInDays } from 'date-fns'; import { parseISO } from 'date-fns';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useModal, useMutationWithToast } from '../../hooks'; import { useModal, useMutationWithToast } from '../../hooks';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; 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' }; 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()); // Expired means the timestamp has actually passed (#909):
if (days <= 0) return { label: t('events.expired'), color: 'text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-900/40' }; // 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' }; 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' }; return { label: t('events.active'), color: 'text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/40' };