diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index e5700b93..ce5ae349 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -5,6 +5,7 @@ import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship import { accommodationsApi, mapsApi } from '../../api/client' import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types' import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder' +import { splitReservationDateTime } from '../../utils/formatters' function renderLucideIcon(icon:LucideIcon, props = {}) { if (!_renderToStaticMarkup) return '' @@ -216,7 +217,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor const phase = pdfGetSpanPhase(r, day.id) const spanLabel = pdfGetSpanLabel(r, phase) const displayTime = pdfGetDisplayTime(r, day.id) - const time = displayTime?.includes('T') ? displayTime.split('T')[1]?.substring(0, 5) : '' + const time = splitReservationDateTime(displayTime).time ?? '' const titleHtml = `${spanLabel ? escHtml(spanLabel) + ': ' : ''}${escHtml(r.title)}` return `
diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index df23d690..4ae402ce 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -13,6 +13,7 @@ import { useSettingsStore } from '../../store/settingsStore' import { getLocaleForLanguage, useTranslation } from '../../i18n' import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types' import { isDayInAccommodationRange } from '../../utils/dayOrder' +import { splitReservationDateTime } from '../../utils/formatters' const WEATHER_ICON_MAP = { Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle, @@ -309,12 +310,17 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri {r.title} {linkedAssignment?.place && · {linkedAssignment.place.name}}
- {r.reservation_time?.includes('T') && ( - - {new Date(r.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })} - {r.reservation_end_time && ` – ${fmtTime(r.reservation_end_time)}`} - - )} + {(() => { + const { time: startTime } = splitReservationDateTime(r.reservation_time) + const { time: endTime } = splitReservationDateTime(r.reservation_end_time) + if (!startTime && !endTime) return null + return ( + + {startTime ? formatTime12(startTime, is12h) : ''} + {endTime ? ` – ${formatTime12(endTime, is12h)}` : ''} + + ) + })()} ) })} diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 633e1866..57c9bc87 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -28,7 +28,7 @@ import { getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems, type MergedItem, } from '../../utils/dayMerge' -import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters' +import { formatDate, formatTime, dayTotalCost, currencyDecimals, splitReservationDateTime } from '../../utils/formatters' import { useDayNotes } from '../../hooks/useDayNotes' import Tooltip from '../shared/Tooltip' import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types' @@ -1487,15 +1487,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ }}> {(() => { const RI = RES_ICONS[res.type] || Ticket; return })()} {confirmed ? t('planner.resConfirmed') : t('planner.resPending')} - {res.reservation_time?.includes('T') && ( - - {new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })} - {res.reservation_end_time && ` – ${(() => { - const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (res.reservation_time.split('T')[0] + 'T' + res.reservation_end_time) - return new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' }) - })()}`} - - )} + {(() => { + const { time: st } = splitReservationDateTime(res.reservation_time) + const { time: et } = splitReservationDateTime(res.reservation_end_time) + if (!st && !et) return null + return ( + + {st ? formatTime(st, locale, timeFormat) : ''} + {et ? ` – ${formatTime(et, locale, timeFormat)}` : ''} + + ) + })()} {(() => { const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) if (!meta) return null @@ -1722,18 +1724,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ {res.title} - {displayTime?.includes('T') && ( - - - {new Date(displayTime).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })} - {spanPhase === 'single' && res.reservation_end_time && (() => { - const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (displayTime.split('T')[0] + 'T' + res.reservation_end_time) - return ` – ${new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}` - })()} - {meta.departure_timezone && spanPhase === 'start' && ` ${meta.departure_timezone}`} - {meta.arrival_timezone && spanPhase === 'end' && ` ${meta.arrival_timezone}`} - - )} + {(() => { + const { time: dispTime } = splitReservationDateTime(displayTime) + const { time: endTime } = splitReservationDateTime(res.reservation_end_time) + if (!dispTime && !endTime) return null + return ( + + + {dispTime ? formatTime(dispTime, locale, timeFormat) : ''} + {spanPhase === 'single' && endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''} + {meta.departure_timezone && spanPhase === 'start' && ` ${meta.departure_timezone}`} + {meta.arrival_timezone && spanPhase === 'end' && ` ${meta.arrival_timezone}`} + + ) + })()} {subtitle && (
@@ -2094,13 +2098,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{res.title}
- {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:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) + {(() => { + const { date, time } = splitReservationDateTime(res.reservation_time) + const { time: endTime } = splitReservationDateTime(res.reservation_end_time) + const dateStr = date + ? new Date(date + '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' })}`} + const timeStr = time ? formatTime(time, locale, timeFormat) : '' + const endStr = endTime ? formatTime(endTime, locale, timeFormat) : '' + const parts: string[] = [] + if (dateStr) parts.push(dateStr) + if (timeStr) parts.push(timeStr + (endStr ? ` – ${endStr}` : '')) + return parts.join(', ') + })()}
{res.title}
- {res.reservation_time && ( -
-
{t('reservations.date')}
-
{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') && ( -
-
{t('reservations.time')}
-
- {new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })} - {res.reservation_end_time && ` – ${res.reservation_end_time}`} -
-
- )} + {(() => { + const { date, time: startTime } = splitReservationDateTime(res.reservation_time) + const { time: endTime } = splitReservationDateTime(res.reservation_end_time) + return ( + <> + {date && ( +
+
{t('reservations.date')}
+
{new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}
+
+ )} + {(startTime || endTime) && ( +
+
{t('reservations.time')}
+
+ {startTime ? formatTime(startTime, locale, timeFormat) : ''} + {endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''} +
+
+ )} + + ) + })()} {res.confirmation_number && (
{t('reservations.confirmationCode')}
diff --git a/client/src/components/Planner/ReservationsPanel.test.tsx b/client/src/components/Planner/ReservationsPanel.test.tsx index 2dcd9c86..0f9e01a6 100644 --- a/client/src/components/Planner/ReservationsPanel.test.tsx +++ b/client/src/components/Planner/ReservationsPanel.test.tsx @@ -389,4 +389,51 @@ describe('ReservationsPanel', () => { expect(screen.getByText('Pending 2')).toBeInTheDocument(); expect(screen.getByText('Pending 3')).toBeInTheDocument(); }); + + it('FE-PLANNER-RESP-041: dateless transport with legacy T-prefix shows time without "Invalid Date"', () => { + const day = buildDay({ date: null, day_number: 25 } as any); + const r = buildReservation({ + title: 'Cruise test', + type: 'cruise', + status: 'pending', + reservation_time: 'T10:00', + reservation_end_time: 'T18:00', + day_id: day.id, + end_day_id: day.id, + } as any); + render(); + expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument(); + expect(screen.getByText(/10:00/)).toBeInTheDocument(); + }); + + it('FE-PLANNER-RESP-042: dateless transport with bare time format shows time without "Invalid Date"', () => { + const day = buildDay({ date: null, day_number: 3 } as any); + const r = buildReservation({ + title: 'Car rental', + type: 'car', + status: 'pending', + reservation_time: '09:00', + reservation_end_time: '17:00', + day_id: day.id, + end_day_id: day.id, + } as any); + render(); + expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument(); + expect(screen.getByText(/09:00/)).toBeInTheDocument(); + }); + + it('FE-PLANNER-RESP-043: dated transport still shows date and time correctly', () => { + const day = buildDay({ date: '2026-07-15', day_number: 1 }); + const r = buildReservation({ + title: 'Flight out', + type: 'flight', + status: 'confirmed', + reservation_time: '2026-07-15T08:30', + reservation_end_time: '2026-07-15T10:45', + day_id: day.id, + } as any); + render(); + expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument(); + expect(screen.getByText(/08:30/)).toBeInTheDocument(); + }); }); diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index 7dc1a686..a341cc21 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -15,6 +15,7 @@ import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types' +import { splitReservationDateTime, formatTime } from '../../utils/formatters' interface AssignmentLookupEntry { dayNumber: number @@ -99,17 +100,13 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo } const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 - const fmtDate = (str) => { - const dateOnly = str.includes('T') ? str.split('T')[0] : str - return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' }) - } - const fmtTime = (str) => { - const d = new Date(str) - return d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' }) - } + const startDt = splitReservationDateTime(r.reservation_time) + const endDt = splitReservationDateTime(r.reservation_end_time) + const fmtDate = (date: string) => + new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' }) - const hasDate = !!r.reservation_time - const hasTime = r.reservation_time?.includes('T') + const hasDate = !!startDt.date + const hasTime = !!(startDt.time || endDt.time) const hasCode = !!r.confirmation_number const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length @@ -233,31 +230,25 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
)} {/* Date / Time row */} - {hasDate && ( -
-
-
{t('reservations.date')}
-
- {fmtDate(r.reservation_time)} - {(() => { - const endDatePart = r.reservation_end_time - ? r.reservation_end_time.includes('T') - ? r.reservation_end_time.split('T')[0] - : /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_end_time) - ? r.reservation_end_time - : null - : null - return endDatePart && endDatePart !== r.reservation_time.split('T')[0] - })() && ( - <> – {fmtDate(r.reservation_end_time)} - )} + {(hasDate || hasTime) && ( +
+ {hasDate && ( +
+
{t('reservations.date')}
+
+ {fmtDate(startDt.date!)} + {endDt.date && endDt.date !== startDt.date && ( + <> – {fmtDate(endDt.date)} + )} +
-
+ )} {hasTime && (
{t('reservations.time')}
- {fmtTime(r.reservation_time)}{r.reservation_end_time ? ` – ${r.reservation_end_time.includes('T') ? fmtTime(r.reservation_end_time) : fmtTime(r.reservation_time.split('T')[0] + 'T' + r.reservation_end_time)}` : ''} + {formatTime(startDt.time, locale, timeFormat)} + {endDt.time ? ` – ${formatTime(endDt.time, locale, timeFormat)}` : ''}
)} @@ -316,8 +307,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number }) if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform }) if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat }) - if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: fmtTime('2000-01-01T' + meta.check_in_time) + (meta.check_in_end_time ? ` – ${fmtTime('2000-01-01T' + meta.check_in_end_time)}` : '') }) - if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: fmtTime('2000-01-01T' + meta.check_out_time) }) + if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(meta.check_in_time, locale, timeFormat) + (meta.check_in_end_time ? ` – ${formatTime(meta.check_in_end_time, locale, timeFormat)}` : '') }) + if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) }) if (cells.length === 0) return null return (
1 ? `repeat(${Math.min(cells.length, 3)}, 1fr)` : '1fr' }}> diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index 367a25cc..8b8ca7bf 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -10,7 +10,7 @@ import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import { useTripStore } from '../../store/tripStore' import { useAddonStore } from '../../store/addonStore' -import { formatDate } from '../../utils/formatters' +import { formatDate, splitReservationDateTime } from '../../utils/formatters' import { openFile } from '../../utils/fileDownload' import apiClient from '../../api/client' import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types' @@ -141,8 +141,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel status: reservation.status || 'pending', start_day_id: reservation.day_id ?? '', end_day_id: reservation.end_day_id ?? '', - departure_time: reservation.reservation_time?.split('T')[1]?.slice(0, 5) ?? '', - arrival_time: reservation.reservation_end_time?.split('T')[1]?.slice(0, 5) ?? '', + departure_time: splitReservationDateTime(reservation.reservation_time).time ?? '', + arrival_time: splitReservationDateTime(reservation.reservation_end_time).time ?? '', confirmation_number: reservation.confirmation_number || '', notes: reservation.notes || '', meta_airline: meta.airline || '', @@ -179,7 +179,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel const buildTime = (day: Day | undefined, time: string): string | null => { if (!time) return null - return day?.date ? `${day.date}T${time}` : `T${time}` + return day?.date ? `${day.date}T${time}` : time } const metadata: Record = {} diff --git a/client/src/pages/SharedTripPage.tsx b/client/src/pages/SharedTripPage.tsx index 44a5ae6a..a7c17844 100644 --- a/client/src/pages/SharedTripPage.tsx +++ b/client/src/pages/SharedTripPage.tsx @@ -12,6 +12,7 @@ import { renderToStaticMarkup } from 'react-dom/server' import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react' import { isDayInAccommodationRange } from '../utils/dayOrder' import { getTransportForDay, getMergedItems } from '../utils/dayMerge' +import { splitReservationDateTime } from '../utils/formatters' const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship } @@ -219,7 +220,7 @@ export default function SharedTripPage() { const r = item.data const TIcon = TRANSPORT_ICONS[r.type] || Ticket const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {}) - const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : '' + const time = splitReservationDateTime(r.reservation_time).time ?? '' let sub = '' if (r.type === 'flight') sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ') else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ') @@ -276,8 +277,9 @@ export default function SharedTripPage() { {(reservations || []).map((r: any) => { 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.split('T')[0] : r.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : '' + const { date: rDate, time: rTime } = splitReservationDateTime(r.reservation_time) + const time = rTime ?? '' + const date = rDate ? new Date(rDate + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : '' return (
diff --git a/client/src/utils/formatters.test.ts b/client/src/utils/formatters.test.ts new file mode 100644 index 00000000..08c7ab73 --- /dev/null +++ b/client/src/utils/formatters.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest' +import { splitReservationDateTime } from './formatters' + +describe('splitReservationDateTime', () => { + it('parses full ISO datetime', () => { + expect(splitReservationDateTime('2026-06-25T10:00')).toEqual({ date: '2026-06-25', time: '10:00' }) + }) + + it('parses full datetime with seconds', () => { + expect(splitReservationDateTime('2026-06-25T10:00:30')).toEqual({ date: '2026-06-25', time: '10:00' }) + }) + + it('parses date-only string', () => { + expect(splitReservationDateTime('2026-06-25')).toEqual({ date: '2026-06-25', time: null }) + }) + + it('parses bare HH:MM (new dateless format)', () => { + expect(splitReservationDateTime('10:00')).toEqual({ date: null, time: '10:00' }) + }) + + it('parses bare single-digit hour time', () => { + expect(splitReservationDateTime('9:30')).toEqual({ date: null, time: '9:30' }) + }) + + it('handles legacy malformed T-prefixed time ("T10:00")', () => { + expect(splitReservationDateTime('T10:00')).toEqual({ date: null, time: '10:00' }) + }) + + it('returns null date for T-prefixed without valid date', () => { + const result = splitReservationDateTime('T23:59') + expect(result.date).toBeNull() + expect(result.time).toBe('23:59') + }) + + it('returns nulls for null input', () => { + expect(splitReservationDateTime(null)).toEqual({ date: null, time: null }) + }) + + it('returns nulls for undefined input', () => { + expect(splitReservationDateTime(undefined)).toEqual({ date: null, time: null }) + }) + + it('returns nulls for empty string', () => { + expect(splitReservationDateTime('')).toEqual({ date: null, time: null }) + }) + + it('returns nulls for unrecognized string', () => { + expect(splitReservationDateTime('garbage')).toEqual({ date: null, time: null }) + }) +}) diff --git a/client/src/utils/formatters.ts b/client/src/utils/formatters.ts index 7a6c01a2..d586f1cb 100644 --- a/client/src/utils/formatters.ts +++ b/client/src/utils/formatters.ts @@ -65,6 +65,18 @@ export function formatTime(timeStr: string | null | undefined, locale: string, t } catch { return timeStr } } +export function splitReservationDateTime(value?: string | null): { date: string | null; time: string | null } { + if (!value) return { date: null, time: null } + const isoDate = /^\d{4}-\d{2}-\d{2}$/ + if (value.includes('T')) { + const [d, t] = value.split('T') + return { date: isoDate.test(d) ? d : null, time: t ? t.slice(0, 5) : null } + } + if (isoDate.test(value)) return { date: value, time: null } + if (/^\d{1,2}:\d{2}/.test(value)) return { date: null, time: value.slice(0, 5) } + return { date: null, time: null } +} + export function dayTotalCost(dayId: number, assignments: AssignmentsMap, currency: string): string | null { const da = assignments[String(dayId)] || [] const total = da.reduce((s, a) => s + (parseFloat(a.place?.price || '') || 0), 0) diff --git a/server/src/services/tripService.ts b/server/src/services/tripService.ts index c977a260..83ad7171 100644 --- a/server/src/services/tripService.ts +++ b/server/src/services/tripService.ts @@ -506,6 +506,11 @@ export function exportICS(tripId: string | number): { ics: string; filename: str // Reservations as events for (const r of reservations) { if (!r.reservation_time) continue; + // Skip time-only values (no calendar date — occurs on relative "Day N" trips) + const hasDate = r.reservation_time.includes('T') + ? /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_time.split('T')[0]) + : /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_time); + if (!hasDate) continue; const hasTime = r.reservation_time.includes('T'); const meta = r.metadata ? (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata) : {};