From 897e1bff2664e23004287fe2dcddc8bd23688086 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 3 Apr 2026 21:18:56 +0200 Subject: [PATCH] fix(dates): use UTC parsing and display for date-only strings (#351) Date-only strings parsed with new Date(dateStr + 'T00:00:00') were interpreted relative to the local timezone, causing off-by-one day display for users west of UTC. Fixed across 16 files by parsing as UTC ('T00:00:00Z') and displaying with timeZone: 'UTC'. --- client/src/components/Budget/BudgetPanel.tsx | 2 +- client/src/components/Collab/WhatsNextWidget.tsx | 2 +- client/src/components/PDF/TripPDF.tsx | 8 ++++---- client/src/components/Photos/PhotoGallery.tsx | 2 +- client/src/components/Planner/DayDetailPanel.tsx | 8 ++++---- client/src/components/Planner/DayPlanSidebar.tsx | 4 ++-- client/src/components/Planner/PlaceInspector.tsx | 2 +- client/src/components/Planner/PlacesSidebar.tsx | 2 +- .../src/components/Planner/ReservationModal.tsx | 4 ++-- .../src/components/Planner/ReservationsPanel.tsx | 4 ++-- client/src/components/Trips/TripFormModal.tsx | 6 +++--- client/src/components/Vacay/holidays.ts | 16 ++++++++-------- .../components/shared/CustomDateTimePicker.tsx | 12 ++++++------ client/src/pages/DashboardPage.tsx | 4 ++-- client/src/pages/SharedTripPage.tsx | 6 +++--- client/src/utils/formatters.ts | 4 ++-- 16 files changed, 43 insertions(+), 43 deletions(-) diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx index 0f447bb..a0cbd3d 100644 --- a/client/src/components/Budget/BudgetPanel.tsx +++ b/client/src/components/Budget/BudgetPanel.tsx @@ -489,7 +489,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro const d = currencyDecimals(currency) const fmtPrice = (v: number | null | undefined) => v != null ? v.toFixed(d) : '' - const fmtDate = (iso: string) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' }) } + const fmtDate = (iso: string) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00Z'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) } const header = ['Category', 'Name', 'Date', 'Total (' + currency + ')', 'Persons', 'Days', 'Per Person', 'Per Day', 'Per Person/Day', 'Note'] const rows = [header.join(sep)] diff --git a/client/src/components/Collab/WhatsNextWidget.tsx b/client/src/components/Collab/WhatsNextWidget.tsx index 6ad678d..c5fd11a 100644 --- a/client/src/components/Collab/WhatsNextWidget.tsx +++ b/client/src/components/Collab/WhatsNextWidget.tsx @@ -23,7 +23,7 @@ function formatDayLabel(date, t, locale) { if (d.toDateString() === now.toDateString()) return t('collab.whatsNext.today') || 'Today' if (d.toDateString() === tomorrow.toDateString()) return t('collab.whatsNext.tomorrow') || 'Tomorrow' - return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short' }) + return new Date(date + 'T00:00:00Z').toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } interface TripMember { diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index c83cc4b..2a4c03a 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -61,15 +61,15 @@ function categoryIconSvg(iconName, color = '#6366f1', size = 24) { function shortDate(d, locale) { if (!d) return '' - return new Date(d + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' }) + return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } function longDateRange(days, locale) { const dd = [...days].filter(d => d.date).sort((a, b) => a.day_number - b.day_number) if (!dd.length) return null - const f = new Date(dd[0].date + 'T00:00:00') - const l = new Date(dd[dd.length - 1].date + 'T00:00:00') - return `${f.toLocaleDateString(locale, { day: 'numeric', month: 'long' })} – ${l.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' })}` + const f = new Date(dd[0].date + 'T00:00:00Z') + const l = new Date(dd[dd.length - 1].date + 'T00:00:00Z') + return `${f.toLocaleDateString(locale, { day: 'numeric', month: 'long', timeZone: 'UTC' })} – ${l.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })}` } function dayCost(assignments, dayId, locale) { diff --git a/client/src/components/Photos/PhotoGallery.tsx b/client/src/components/Photos/PhotoGallery.tsx index fe64a19..4e7a90d 100644 --- a/client/src/components/Photos/PhotoGallery.tsx +++ b/client/src/components/Photos/PhotoGallery.tsx @@ -213,5 +213,5 @@ function PhotoThumbnail({ photo, days, places, onClick }: PhotoThumbnailProps) { function formatDate(dateStr, locale) { if (!dateStr) return '' - return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' }) + return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) } diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index 22cc76e..49cd6ff 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -154,9 +154,9 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri if (!day) return null - const formattedDate = day.date ? new Date(day.date + 'T00:00:00').toLocaleDateString( + const formattedDate = day.date ? new Date(day.date + 'T00:00:00Z').toLocaleDateString( getLocaleForLanguage(language), - { weekday: 'long', day: 'numeric', month: 'long' } + { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC' } ) : null const placesWithCoords = places.filter(p => p.lat && p.lng) @@ -445,7 +445,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))} options={days.map((d, i) => ({ value: d.id, - label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })}` : ''}`, + label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })}` : ''}`, }))} size="sm" /> @@ -457,7 +457,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))} options={days.map((d, i) => ({ value: d.id, - label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })}` : ''}`, + label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })}` : ''}`, }))} size="sm" /> diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index b9850f3..b83270e 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -743,7 +743,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{trip?.title}
{(trip?.start_date || trip?.end_date) && (
- {[trip.start_date, trip.end_date].filter(Boolean).map(d => new Date(d + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })).join(' – ')} + {[trip.start_date, trip.end_date].filter(Boolean).map(d => new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })).join(' – ')} {days.length > 0 && ` · ${days.length} ${t('dayplan.days')}`}
)} @@ -1671,7 +1671,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ {res.reservation_time?.includes('T') ? new Date(res.reservation_time).toLocaleString(locale, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' }) : res.reservation_time - ? new Date(res.reservation_time + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' }) + ? new Date(res.reservation_time + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) : '' } {res.reservation_end_time?.includes('T') && ` – ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`} diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index f6c00a5..13deb63 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -373,7 +373,7 @@ export default function PlaceInspector({ {res.reservation_time && (
{t('reservations.date')}
-
{new Date(res.reservation_time).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}
+
{new Date((res.reservation_time.includes('T') ? res.reservation_time.split('T')[0] : res.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}
)} {res.reservation_time?.includes('T') && ( diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx index 91e50a5..ce8c6c5 100644 --- a/client/src/components/Planner/PlacesSidebar.tsx +++ b/client/src/components/Planner/PlacesSidebar.tsx @@ -424,7 +424,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
{i + 1}
{day.title || t('dayplan.dayN', { n: i + 1 })}
- {day.date &&
{new Date(day.date + 'T00:00:00').toLocaleDateString()}
} + {day.date &&
{new Date(day.date + 'T00:00:00Z').toLocaleDateString(undefined, { timeZone: 'UTC' })}
}
{(assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id) && } diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index dbc3ab3..8a376d7 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -572,6 +572,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p function formatDate(dateStr, locale) { if (!dateStr) return '' - const d = new Date(dateStr + 'T00:00:00') - return d.toLocaleDateString(locale || undefined, { day: 'numeric', month: 'short' }) + const d = new Date(dateStr + 'T00:00:00Z') + return d.toLocaleDateString(locale || undefined, { day: 'numeric', month: 'short', timeZone: 'UTC' }) } diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index b6f9c55..4d37fab 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -84,8 +84,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo } const fmtDate = (str) => { - const d = new Date(str) - return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' }) + const dateOnly = str.includes('T') ? str.split('T')[0] : str + return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } const fmtTime = (str) => { const d = new Date(str) diff --git a/client/src/components/Trips/TripFormModal.tsx b/client/src/components/Trips/TripFormModal.tsx index 4e834b9..aff4012 100644 --- a/client/src/components/Trips/TripFormModal.tsx +++ b/client/src/components/Trips/TripFormModal.tsx @@ -197,10 +197,10 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp if (!prev.end_date || prev.end_date < value) { next.end_date = value } else if (prev.start_date) { - const oldStart = new Date(prev.start_date + 'T00:00:00') - const oldEnd = new Date(prev.end_date + 'T00:00:00') + const oldStart = new Date(prev.start_date + 'T00:00:00Z') + const oldEnd = new Date(prev.end_date + 'T00:00:00Z') const duration = Math.round((oldEnd - oldStart) / 86400000) - const newEnd = new Date(value + 'T00:00:00') + const newEnd = new Date(value + 'T00:00:00Z') newEnd.setDate(newEnd.getDate() + duration) next.end_date = newEnd.toISOString().split('T')[0] } diff --git a/client/src/components/Vacay/holidays.ts b/client/src/components/Vacay/holidays.ts index fe5903b..61f92a9 100644 --- a/client/src/components/Vacay/holidays.ts +++ b/client/src/components/Vacay/holidays.ts @@ -104,18 +104,18 @@ export function getHolidays(year: number, bundesland: string = 'NW'): Record(null) const dropRef = useRef(null) - const parsed = value ? new Date(value + 'T00:00:00') : null - const [viewYear, setViewYear] = useState(parsed?.getFullYear() || new Date().getFullYear()) - const [viewMonth, setViewMonth] = useState(parsed?.getMonth() ?? new Date().getMonth()) + const parsed = value ? new Date(value + 'T00:00:00Z') : null + const [viewYear, setViewYear] = useState(parsed?.getUTCFullYear() || new Date().getFullYear()) + const [viewMonth, setViewMonth] = useState(parsed?.getUTCMonth() ?? new Date().getMonth()) useEffect(() => { const handler = (e: MouseEvent) => { @@ -36,7 +36,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {}, com }, [open]) useEffect(() => { - if (open && parsed) { setViewYear(parsed.getFullYear()); setViewMonth(parsed.getMonth()) } + if (open && parsed) { setViewYear(parsed.getUTCFullYear()); setViewMonth(parsed.getUTCMonth()) } }, [open]) const prevMonth = () => { if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1) } else setViewMonth(m => m - 1) } @@ -47,7 +47,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {}, com const startDay = (getWeekday(viewYear, viewMonth, 1) + 6) % 7 const weekdays = Array.from({ length: 7 }, (_, i) => new Date(2024, 0, i + 1).toLocaleDateString(locale, { weekday: 'narrow' })) - const displayValue = parsed ? parsed.toLocaleDateString(locale, compact ? { day: '2-digit', month: '2-digit', year: '2-digit' } : { day: 'numeric', month: 'short', year: 'numeric' }) : null + const displayValue = parsed ? parsed.toLocaleDateString(locale, compact ? { day: '2-digit', month: '2-digit', year: '2-digit', timeZone: 'UTC' } : { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }) : null const selectDay = (day: number) => { const y = String(viewYear) @@ -57,7 +57,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {}, com setOpen(false) } - const selectedDay = parsed && parsed.getFullYear() === viewYear && parsed.getMonth() === viewMonth ? parsed.getDate() : null + const selectedDay = parsed && parsed.getUTCFullYear() === viewYear && parsed.getUTCMonth() === viewMonth ? parsed.getUTCDate() : null const today = new Date() const isToday = (d: number) => today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index 16f36d0..a386379 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -59,12 +59,12 @@ function getTripStatus(trip: DashboardTrip): string | null { function formatDate(dateStr: string | null | undefined, locale: string = 'en-US'): string | null { if (!dateStr) return null - return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' }) + return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }) } function formatDateShort(dateStr: string | null | undefined, locale: string = 'en-US'): string | null { if (!dateStr) return null - return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' }) + return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) } function sortTrips(trips: DashboardTrip[]): DashboardTrip[] { diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx index d8fb54a..09fa866 100644 --- a/client/src/pages/SharedTripPage.tsx +++ b/client/src/pages/SharedTripPage.tsx @@ -106,7 +106,7 @@ export default function SharedTripPage() { {(trip.start_date || trip.end_date) && (
- {[trip.start_date, trip.end_date].filter(Boolean).map((d: string) => new Date(d + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })).join(' — ')} + {[trip.start_date, trip.end_date].filter(Boolean).map((d: string) => new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' })).join(' — ')} {days?.length > 0 && ·} {days?.length > 0 && {days.length} {t('shared.days')}} @@ -199,7 +199,7 @@ export default function SharedTripPage() {
{di + 1}
{day.title || `Day ${day.day_number}`}
- {day.date &&
{new Date(day.date + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}
} + {day.date &&
{new Date(day.date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}
}
{dayAccs.map((acc: any) => ( @@ -274,7 +274,7 @@ export default function SharedTripPage() { const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {}) const TIcon = TRANSPORT_ICONS[r.type] || Ticket const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : '' - const date = r.reservation_time ? new Date(r.reservation_time.includes('T') ? r.reservation_time : r.reservation_time + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' }) : '' + const date = r.reservation_time ? new Date((r.reservation_time.includes('T') ? r.reservation_time.split('T')[0] : r.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : '' return (
diff --git a/client/src/utils/formatters.ts b/client/src/utils/formatters.ts index 7e7ebc0..980f85a 100644 --- a/client/src/utils/formatters.ts +++ b/client/src/utils/formatters.ts @@ -10,9 +10,9 @@ export function formatDate(dateStr: string | null | undefined, locale: string, t if (!dateStr) return null const opts: Intl.DateTimeFormatOptions = { weekday: 'short', day: 'numeric', month: 'short', + timeZone: timeZone || 'UTC', } - if (timeZone) opts.timeZone = timeZone - return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, opts) + return new Date(dateStr + 'T00:00:00Z').toLocaleDateString(locale, opts) } export function formatTime(timeStr: string | null | undefined, locale: string, timeFormat: string): string {