From df24316837af3eae91d7fe1eee2ea6e01427d2d4 Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Wed, 18 Jun 2025 21:10:10 -0400 Subject: [PATCH] feat(lodging): improve lodging date handling with all-day event support and timezone adjustments --- .../src/lib/components/LodgingCard.svelte | 48 ++++-- .../src/lib/components/LodgingModal.svelte | 19 +++ .../lib/components/TransportationCard.svelte | 23 ++- frontend/src/lib/index.ts | 156 ++++++++++++------ .../src/routes/collections/[id]/+page.svelte | 2 +- 5 files changed, 170 insertions(+), 78 deletions(-) diff --git a/frontend/src/lib/components/LodgingCard.svelte b/frontend/src/lib/components/LodgingCard.svelte index fe27bff..d4a07a3 100644 --- a/frontend/src/lib/components/LodgingCard.svelte +++ b/frontend/src/lib/components/LodgingCard.svelte @@ -120,23 +120,39 @@ {/if} - {#if lodging.check_in && lodging.check_out} -
- {$t('adventures.dates')}: -

- {#if isAllDay(lodging.check_in)} - {formatAllDayDate(lodging.check_in)} – - {formatAllDayDate(lodging.check_out)} - {:else} - {formatDateInTimezone(lodging.check_in, lodging.timezone)} – - {formatDateInTimezone(lodging.check_out, lodging.timezone)} - {#if lodging.timezone} - ({lodging.timezone}) +

+ {#if lodging.check_in} +
+ {$t('adventures.check_in')}: + + {#if isAllDay(lodging.check_in)} + {formatAllDayDate(lodging.check_in)} + {:else} + {formatDateInTimezone(lodging.check_in, lodging.timezone)} + {#if lodging.timezone} + ({lodging.timezone}) + {/if} {/if} - {/if} -

-
- {/if} + +
+ {/if} + + {#if lodging.check_out} +
+ {$t('adventures.check_out')}: + + {#if isAllDay(lodging.check_out)} + {formatAllDayDate(lodging.check_out)} + {:else} + {formatDateInTimezone(lodging.check_out, lodging.timezone)} + {#if lodging.timezone} + ({lodging.timezone}) + {/if} + {/if} + +
+ {/if} +
diff --git a/frontend/src/lib/components/LodgingModal.svelte b/frontend/src/lib/components/LodgingModal.svelte index 594d505..5db3818 100644 --- a/frontend/src/lib/components/LodgingModal.svelte +++ b/frontend/src/lib/components/LodgingModal.svelte @@ -6,6 +6,7 @@ import type { Collection, Lodging } from '$lib/types'; import LocationDropdown from './LocationDropdown.svelte'; import DateRangeCollapse from './DateRangeCollapse.svelte'; + import { isAllDay } from '$lib'; const dispatch = createEventDispatcher(); @@ -82,6 +83,24 @@ async function handleSubmit(event: Event) { event.preventDefault(); + lodging.timezone = lodgingTimezone || null; + + // Auto-set end date if missing but start date exists + if (lodging.check_in && !lodging.check_out) { + const startDate = new Date(lodging.check_in); + const nextDay = new Date(startDate); + nextDay.setDate(nextDay.getDate() + 1); + + if (isAllDay(lodging.check_in)) { + // For all-day, set to next day at 00:00:00 + lodging.check_out = nextDay.toISOString().split('T')[0] + 'T00:00:00'; + } else { + // For timed events, set to next day at 9:00 AM + nextDay.setHours(9, 0, 0, 0); + lodging.check_out = nextDay.toISOString(); + } + } + // Create or update lodging... const url = lodging.id === '' ? '/api/lodging' : `/api/lodging/${lodging.id}`; const method = lodging.id === '' ? 'POST' : 'PATCH'; diff --git a/frontend/src/lib/components/TransportationCard.svelte b/frontend/src/lib/components/TransportationCard.svelte index 16dd996..f419e34 100644 --- a/frontend/src/lib/components/TransportationCard.svelte +++ b/frontend/src/lib/components/TransportationCard.svelte @@ -8,7 +8,8 @@ import DeleteWarning from './DeleteWarning.svelte'; // import ArrowDownThick from '~icons/mdi/arrow-down-thick'; import { TRANSPORTATION_TYPES_ICONS } from '$lib'; - import { formatDateInTimezone } from '$lib/dateUtils'; + import { formatAllDayDate, formatDateInTimezone } from '$lib/dateUtils'; + import { isAllDay } from '$lib'; function getTransportationIcon(type: string) { if (type in TRANSPORTATION_TYPES_ICONS) { @@ -161,9 +162,13 @@
{$t('adventures.start')}: - {formatDateInTimezone(transportation.date, transportation.start_timezone)} - {#if transportation.start_timezone} - ({transportation.start_timezone}) + {#if isAllDay(transportation.date) && (!transportation.end_date || isAllDay(transportation.end_date))} + {formatAllDayDate(transportation.date)} + {:else} + {formatDateInTimezone(transportation.date, transportation.start_timezone)} + {#if transportation.start_timezone} + ({transportation.start_timezone}) + {/if} {/if}
@@ -173,9 +178,13 @@
{$t('adventures.end')}: - {formatDateInTimezone(transportation.end_date, transportation.end_timezone)} - {#if transportation.end_timezone} - ({transportation.end_timezone}) + {#if isAllDay(transportation.end_date) && (!transportation.date || isAllDay(transportation.date))} + {formatAllDayDate(transportation.end_date)} + {:else} + {formatDateInTimezone(transportation.end_date, transportation.end_timezone)} + {#if transportation.end_timezone} + ({transportation.end_timezone}) + {/if} {/if}
diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts index f4f74dc..4f890e8 100644 --- a/frontend/src/lib/index.ts +++ b/frontend/src/lib/index.ts @@ -1,5 +1,8 @@ import inspirationalQuotes from './json/quotes.json'; import randomBackgrounds from './json/backgrounds.json'; + +// @ts-ignore +import { DateTime } from 'luxon'; import type { Adventure, Background, @@ -144,13 +147,6 @@ function getLocalDateString(date: Date): string { return `${year}-${month}-${day}`; } -// Helper to check if a given date string represents midnight (all-day) -// Improved isAllDay function to handle different ISO date formats -export function isAllDay(dateStr: string): boolean { - // Check for various midnight formats in UTC - return dateStr.endsWith('T00:00:00Z') || dateStr.endsWith('T00:00:00.000Z'); -} - export function groupTransportationsByDate( transportations: Transportation[], startDate: Date, @@ -158,82 +154,127 @@ export function groupTransportationsByDate( ): Record { const groupedTransportations: Record = {}; - // Initialize all days in the range + // Initialize days for (let i = 0; i < numberOfDays; i++) { - const currentDate = new Date(startDate); - currentDate.setDate(startDate.getDate() + i); - const dateString = getLocalDateString(currentDate); + const currentDate = DateTime.fromJSDate(startDate).plus({ days: i }); + const dateString = currentDate.toISODate(); // 'YYYY-MM-DD' groupedTransportations[dateString] = []; } transportations.forEach((transportation) => { if (transportation.date) { - const transportationDate = getLocalDateString(new Date(transportation.date)); - if (transportation.end_date) { - const endDate = new Date(transportation.end_date).toISOString().split('T')[0]; + // Check if it's all-day: start has 00:00:00 AND (no end OR end also has 00:00:00) + const startHasZeros = transportation.date.includes('T00:00:00'); + const endHasZeros = transportation.end_date + ? transportation.end_date.includes('T00:00:00') + : true; + const isTranspoAllDay = startHasZeros && endHasZeros; - // Loop through all days and include transportation if it falls within the range - for (let i = 0; i < numberOfDays; i++) { - const currentDate = new Date(startDate); - currentDate.setDate(startDate.getDate() + i); - const dateString = getLocalDateString(currentDate); + let startDT: DateTime; + let endDT: DateTime; - // Include the current day if it falls within the transportation date range - if (dateString >= transportationDate && dateString <= endDate) { - if (groupedTransportations[dateString]) { - groupedTransportations[dateString].push(transportation); - } - } + if (isTranspoAllDay) { + // For all-day events, extract just the date part and ignore timezone + const dateOnly = transportation.date.split('T')[0]; // Get 'YYYY-MM-DD' + startDT = DateTime.fromISO(dateOnly); // This creates a date without time/timezone + + endDT = transportation.end_date + ? DateTime.fromISO(transportation.end_date.split('T')[0]) + : startDT; + } else { + // For timed events, use timezone conversion + startDT = DateTime.fromISO(transportation.date, { + zone: transportation.start_timezone ?? 'UTC' + }); + + endDT = transportation.end_date + ? DateTime.fromISO(transportation.end_date, { + zone: transportation.end_timezone ?? transportation.start_timezone ?? 'UTC' + }) + : startDT; + } + + const startDateStr = startDT.toISODate(); + const endDateStr = endDT.toISODate(); + + // Loop through all days in range + for (let i = 0; i < numberOfDays; i++) { + const currentDate = DateTime.fromJSDate(startDate).plus({ days: i }); + const currentDateStr = currentDate.toISODate(); + + if (currentDateStr >= startDateStr && currentDateStr <= endDateStr) { + groupedTransportations[currentDateStr]?.push(transportation); } - } else if (groupedTransportations[transportationDate]) { - // If there's no end date, add transportation to the start date only - groupedTransportations[transportationDate].push(transportation); } } }); return groupedTransportations; } - export function groupLodgingByDate( - transportations: Lodging[], + lodging: Lodging[], startDate: Date, numberOfDays: number ): Record { - const groupedTransportations: Record = {}; + const groupedLodging: Record = {}; - // Initialize all days in the range using local dates - for (let i = 0; i < numberOfDays; i++) { - const currentDate = new Date(startDate); - currentDate.setDate(startDate.getDate() + i); - const dateString = getLocalDateString(currentDate); - groupedTransportations[dateString] = []; + // Initialize days (excluding last day for lodging) + // If trip is 7/1 to 7/4 (4 days), show lodging only on 7/1, 7/2, 7/3 + const lodgingDays = numberOfDays - 1; + + for (let i = 0; i < lodgingDays; i++) { + const currentDate = DateTime.fromJSDate(startDate).plus({ days: i }); + const dateString = currentDate.toISODate(); // 'YYYY-MM-DD' + groupedLodging[dateString] = []; } - transportations.forEach((transportation) => { - if (transportation.check_in) { - // Use local date string conversion - const transportationDate = getLocalDateString(new Date(transportation.check_in)); - if (transportation.check_out) { - const endDate = getLocalDateString(new Date(transportation.check_out)); + lodging.forEach((hotel) => { + if (hotel.check_in) { + // Check if it's all-day: start has 00:00:00 AND (no end OR end also has 00:00:00) + const startHasZeros = hotel.check_in.includes('T00:00:00'); + const endHasZeros = hotel.check_out ? hotel.check_out.includes('T00:00:00') : true; + const isAllDay = startHasZeros && endHasZeros; - // Loop through all days and include transportation if it falls within the transportation date range - for (let i = 0; i < numberOfDays; i++) { - const currentDate = new Date(startDate); - currentDate.setDate(startDate.getDate() + i); - const dateString = getLocalDateString(currentDate); + let startDT: DateTime; + let endDT: DateTime; - if (dateString >= transportationDate && dateString <= endDate) { - groupedTransportations[dateString].push(transportation); - } + if (isAllDay) { + // For all-day events, extract just the date part and ignore timezone + const dateOnly = hotel.check_in.split('T')[0]; // Get 'YYYY-MM-DD' + startDT = DateTime.fromISO(dateOnly); // This creates a date without time/timezone + + endDT = hotel.check_out ? DateTime.fromISO(hotel.check_out.split('T')[0]) : startDT; + } else { + // For timed events, use timezone conversion + startDT = DateTime.fromISO(hotel.check_in, { + zone: hotel.timezone ?? 'UTC' + }); + + endDT = hotel.check_out + ? DateTime.fromISO(hotel.check_out, { + zone: hotel.timezone ?? 'UTC' + }) + : startDT; + } + + const startDateStr = startDT.toISODate(); + const endDateStr = endDT.toISODate(); + + // Loop through lodging days only (excluding last day) + for (let i = 0; i < lodgingDays; i++) { + const currentDate = DateTime.fromJSDate(startDate).plus({ days: i }); + const currentDateStr = currentDate.toISODate(); + + // Show lodging on days where check-in occurs through the day before check-out + // For lodging, we typically want to show it on the nights you're staying + if (currentDateStr >= startDateStr && currentDateStr < endDateStr) { + groupedLodging[currentDateStr]?.push(hotel); } - } else if (groupedTransportations[transportationDate]) { - groupedTransportations[transportationDate].push(transportation); } } }); - return groupedTransportations; + return groupedLodging; } export function groupNotesByDate( @@ -292,6 +333,13 @@ export function groupChecklistsByDate( return groupedChecklists; } +// Helper to check if a given date string represents midnight (all-day) +// Improved isAllDay function to handle different ISO date formats +export function isAllDay(dateStr: string): boolean { + // Check for various midnight formats in UTC + return dateStr.endsWith('T00:00:00Z') || dateStr.endsWith('T00:00:00.000Z'); +} + export function continentCodeToString(code: string) { switch (code) { case 'AF': diff --git a/frontend/src/routes/collections/[id]/+page.svelte b/frontend/src/routes/collections/[id]/+page.svelte index cca5d6b..cd75934 100644 --- a/frontend/src/routes/collections/[id]/+page.svelte +++ b/frontend/src/routes/collections/[id]/+page.svelte @@ -1042,7 +1042,7 @@ numberOfDays + 1 )[dateString] || []} {@const dayLodging = - groupLodgingByDate(lodging, new Date(collection.start_date), numberOfDays)[ + groupLodgingByDate(lodging, new Date(collection.start_date), numberOfDays + 1)[ dateString ] || []} {@const dayNotes =