From c1302bb54ab272c2a98c53ce0d508b7d39e9674b Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Sat, 6 Sep 2025 20:52:05 -0400 Subject: [PATCH] [BUG] Single day Collections will think location visits are out of date range Fixes #827 --- .../src/lib/components/ChecklistCard.svelte | 21 +- .../src/lib/components/ChecklistModal.svelte | 2 +- .../src/lib/components/LocationCard.svelte | 17 +- .../src/lib/components/LodgingCard.svelte | 37 +--- frontend/src/lib/components/NoteCard.svelte | 15 +- frontend/src/lib/components/NoteModal.svelte | 2 +- .../lib/components/TransportationCard.svelte | 55 +---- .../lib/components/TransportationModal.svelte | 4 + frontend/src/lib/dateUtils.ts | 200 +++++++++++++----- 9 files changed, 188 insertions(+), 165 deletions(-) diff --git a/frontend/src/lib/components/ChecklistCard.svelte b/frontend/src/lib/components/ChecklistCard.svelte index 9b60029e..adda2f13 100644 --- a/frontend/src/lib/components/ChecklistCard.svelte +++ b/frontend/src/lib/components/ChecklistCard.svelte @@ -9,29 +9,18 @@ import TrashCan from '~icons/mdi/trash-can'; import Calendar from '~icons/mdi/calendar'; import DeleteWarning from './DeleteWarning.svelte'; + import { isEntityOutsideCollectionDateRange } from '$lib/dateUtils'; export let checklist: Checklist; export let user: User | null = null; - export let collection: Collection | null = null; + export let collection: Collection; let isWarningModalOpen: boolean = false; - let unlinked: boolean = false; + let outsideCollectionRange: boolean = false; $: { - if (collection?.start_date && collection.end_date) { - const startOutsideRange = - checklist.date && - collection.start_date < checklist.date && - collection.end_date < checklist.date; - - const endOutsideRange = - checklist.date && - collection.start_date > checklist.date && - collection.end_date > checklist.date; - - unlinked = !!(startOutsideRange || endOutsideRange || !checklist.date); - } + outsideCollectionRange = isEntityOutsideCollectionDateRange(checklist, collection); } function editChecklist() { @@ -71,7 +60,7 @@

{checklist.name}

{$t('adventures.checklist')}
- {#if unlinked} + {#if outsideCollectionRange}
{$t('adventures.out_of_range')}
{/if}
diff --git a/frontend/src/lib/components/ChecklistModal.svelte b/frontend/src/lib/components/ChecklistModal.svelte index 1e16985a..50f17627 100644 --- a/frontend/src/lib/components/ChecklistModal.svelte +++ b/frontend/src/lib/components/ChecklistModal.svelte @@ -14,7 +14,7 @@ let items: ChecklistItem[] = []; - let constrainDates: boolean = false; + let constrainDates: boolean = true; items = checklist?.items || []; diff --git a/frontend/src/lib/components/LocationCard.svelte b/frontend/src/lib/components/LocationCard.svelte index 2549ab97..01249612 100644 --- a/frontend/src/lib/components/LocationCard.svelte +++ b/frontend/src/lib/components/LocationCard.svelte @@ -22,6 +22,7 @@ import StarOutline from '~icons/mdi/star-outline'; import Eye from '~icons/mdi/eye'; import EyeOff from '~icons/mdi/eye-off'; + import { isEntityOutsideCollectionDateRange } from '$lib/dateUtils'; export let type: string | null = null; export let user: User | null; @@ -48,17 +49,13 @@ } } - let unlinked: boolean = false; + let outsideCollectionRange: boolean = false; - // Reactive block to update `unlinked` when dependencies change $: { - if (collection && collection?.start_date && collection.end_date) { - unlinked = adventure.visits.every((visit) => { - if (!visit.start_date || !visit.end_date) return true; - const isBeforeVisit = collection.end_date && collection.end_date < visit.start_date; - const isAfterVisit = collection.start_date && collection.start_date > visit.end_date; - return isBeforeVisit || isAfterVisit; - }); + if (collection) { + outsideCollectionRange = adventure.visits.every((visit) => + isEntityOutsideCollectionDateRange(visit, collection) + ); } } @@ -199,7 +196,7 @@ > {adventure.is_visited ? $t('adventures.visited') : $t('adventures.planned')} - {#if unlinked} + {#if outsideCollectionRange}
{$t('adventures.out_of_range')}
{/if} diff --git a/frontend/src/lib/components/LodgingCard.svelte b/frontend/src/lib/components/LodgingCard.svelte index 1baeb1ea..f9be38af 100644 --- a/frontend/src/lib/components/LodgingCard.svelte +++ b/frontend/src/lib/components/LodgingCard.svelte @@ -7,7 +7,7 @@ import { t } from 'svelte-i18n'; import DeleteWarning from './DeleteWarning.svelte'; import { LODGING_TYPES_ICONS } from '$lib'; - import { formatDateInTimezone } from '$lib/dateUtils'; + import { formatDateInTimezone, isEntityOutsideCollectionDateRange } from '$lib/dateUtils'; import { formatAllDayDate } from '$lib/dateUtils'; import { isAllDay } from '$lib'; import CardCarousel from './CardCarousel.svelte'; @@ -31,38 +31,11 @@ dispatch('edit', lodging); } - let unlinked: boolean = false; + let outsideCollectionRange: boolean = false; $: { - if (collection?.start_date && collection.end_date) { - // Parse transportation dates - let transportationStartDate = lodging.check_in - ? new Date(lodging.check_in.split('T')[0]) // Ensure proper date parsing - : null; - let transportationEndDate = lodging.check_out - ? new Date(lodging.check_out.split('T')[0]) - : null; - - // Parse collection dates - let collectionStartDate = new Date(collection.start_date); - let collectionEndDate = new Date(collection.end_date); - - // Check if the collection range is outside the transportation range - const startOutsideRange = - transportationStartDate && - collectionStartDate < transportationStartDate && - collectionEndDate < transportationStartDate; - - const endOutsideRange = - transportationEndDate && - collectionStartDate > transportationEndDate && - collectionEndDate > transportationEndDate; - - unlinked = !!( - startOutsideRange || - endOutsideRange || - (!transportationStartDate && !transportationEndDate) - ); + if (collection) { + outsideCollectionRange = isEntityOutsideCollectionDateRange(lodging, collection); } } @@ -120,7 +93,7 @@ {$t(`lodging.${lodging.type}`)} {getLodgingIcon(lodging.type)} - {#if unlinked} + {#if outsideCollectionRange}
{$t('adventures.out_of_range')}
{/if} diff --git a/frontend/src/lib/components/NoteCard.svelte b/frontend/src/lib/components/NoteCard.svelte index 0e699a54..6fb6b693 100644 --- a/frontend/src/lib/components/NoteCard.svelte +++ b/frontend/src/lib/components/NoteCard.svelte @@ -15,23 +15,18 @@ import TrashCan from '~icons/mdi/trash-can'; import Calendar from '~icons/mdi/calendar'; import DeleteWarning from './DeleteWarning.svelte'; + import { isEntityOutsideCollectionDateRange } from '$lib/dateUtils'; export let note: Note; export let user: User | null = null; export let collection: Collection | null = null; let isWarningModalOpen: boolean = false; - let unlinked: boolean = false; + let outsideCollectionRange: boolean = false; $: { - if (collection?.start_date && collection.end_date) { - const startOutsideRange = - note.date && collection.start_date < note.date && collection.end_date < note.date; - - const endOutsideRange = - note.date && collection.start_date > note.date && collection.end_date > note.date; - - unlinked = !!(startOutsideRange || endOutsideRange || !note.date); + if (collection) { + outsideCollectionRange = isEntityOutsideCollectionDateRange(note, collection); } } @@ -73,7 +68,7 @@

{note.name}

{$t('adventures.note')}
- {#if unlinked} + {#if outsideCollectionRange}
{$t('adventures.out_of_range')}
{/if}
diff --git a/frontend/src/lib/components/NoteModal.svelte b/frontend/src/lib/components/NoteModal.svelte index 7f66fa47..fce82bd0 100644 --- a/frontend/src/lib/components/NoteModal.svelte +++ b/frontend/src/lib/components/NoteModal.svelte @@ -17,7 +17,7 @@ export let collection: Collection; export let user: User | null = null; - let constrainDates: boolean = false; + let constrainDates: boolean = true; let isReadOnly = !(note && user?.uuid == note?.user) && diff --git a/frontend/src/lib/components/TransportationCard.svelte b/frontend/src/lib/components/TransportationCard.svelte index f5418781..5533c559 100644 --- a/frontend/src/lib/components/TransportationCard.svelte +++ b/frontend/src/lib/components/TransportationCard.svelte @@ -8,7 +8,11 @@ import DeleteWarning from './DeleteWarning.svelte'; // import ArrowDownThick from '~icons/mdi/arrow-down-thick'; import { TRANSPORTATION_TYPES_ICONS } from '$lib'; - import { formatAllDayDate, formatDateInTimezone } from '$lib/dateUtils'; + import { + formatAllDayDate, + formatDateInTimezone, + isEntityOutsideCollectionDateRange + } from '$lib/dateUtils'; import { isAllDay } from '$lib'; import CardCarousel from './CardCarousel.svelte'; @@ -36,52 +40,11 @@ dispatch('edit', transportation); } - let unlinked: boolean = false; + let outsideCollectionRange: boolean = false; $: { - if (collection?.start_date && collection.end_date) { - // Parse transportation dates - let transportationStartDate = transportation.date - ? new Date(transportation.date.split('T')[0]) // Ensure proper date parsing - : null; - let transportationEndDate = transportation.end_date - ? new Date(transportation.end_date.split('T')[0]) - : null; - - // Parse collection dates - let collectionStartDate = new Date(collection.start_date); - let collectionEndDate = new Date(collection.end_date); - - // // Debugging outputs - // console.log( - // 'Transportation Start Date:', - // transportationStartDate, - // 'Transportation End Date:', - // transportationEndDate - // ); - // console.log( - // 'Collection Start Date:', - // collectionStartDate, - // 'Collection End Date:', - // collectionEndDate - // ); - - // Check if the collection range is outside the transportation range - const startOutsideRange = - transportationStartDate && - collectionStartDate < transportationStartDate && - collectionEndDate < transportationStartDate; - - const endOutsideRange = - transportationEndDate && - collectionStartDate > transportationEndDate && - collectionEndDate > transportationEndDate; - - unlinked = !!( - startOutsideRange || - endOutsideRange || - (!transportationStartDate && !transportationEndDate) - ); + if (collection) { + outsideCollectionRange = isEntityOutsideCollectionDateRange(transportation, collection); } } @@ -165,7 +128,7 @@ {#if transportation.type === 'plane' && transportation.flight_number}
{transportation.flight_number}
{/if} - {#if unlinked} + {#if outsideCollectionRange}
{$t('adventures.out_of_range')}
{/if} diff --git a/frontend/src/lib/components/TransportationModal.svelte b/frontend/src/lib/components/TransportationModal.svelte index 6c3ecba1..497a639e 100644 --- a/frontend/src/lib/components/TransportationModal.svelte +++ b/frontend/src/lib/components/TransportationModal.svelte @@ -173,6 +173,10 @@ Math.round(transportation.destination_longitude * 1e6) / 1e6; } + if (transportation.date && !transportation.end_date) { + transportation.end_date = transportation.date; + } + if (!transportation.type) { transportation.type = 'other'; } diff --git a/frontend/src/lib/dateUtils.ts b/frontend/src/lib/dateUtils.ts index a844f76e..d72aa7e3 100644 --- a/frontend/src/lib/dateUtils.ts +++ b/frontend/src/lib/dateUtils.ts @@ -1,34 +1,27 @@ // @ts-ignore import { DateTime } from 'luxon'; +import type { Checklist, Collection, Lodging, Note, Transportation, Visit } from './types'; +import { isAllDay } from '$lib'; /** * Convert a UTC ISO date to a datetime-local value in the specified timezone - * @param utcDate - UTC date in ISO format or null - * @param timezone - Target timezone (defaults to browser timezone) - * @returns Formatted local datetime string for input fields (YYYY-MM-DDTHH:MM) */ export function toLocalDatetime( utcDate: string | null, timezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone ): string { if (!utcDate) return ''; - const dt = DateTime.fromISO(utcDate, { zone: 'UTC' }); if (!dt.isValid) return ''; - const isoString = dt.setZone(timezone).toISO({ suppressSeconds: true, includeOffset: false }); - return isoString ? isoString.slice(0, 16) : ''; } /** * Convert a local datetime to UTC - * @param localDate - Local datetime string in ISO format - * @param timezone - Source timezone (defaults to browser timezone) - * @returns UTC datetime in ISO format or null */ export function toUTCDatetime( localDate: string, @@ -36,15 +29,12 @@ export function toUTCDatetime( allDay: boolean = false ): string | null { if (!localDate) return null; - if (allDay) { - // Treat input as date-only, set UTC midnight manually + // Treat as date only, set UTC midnight return DateTime.fromISO(localDate, { zone: 'UTC' }) .startOf('day') .toISO({ suppressMilliseconds: true }); } - - // Normal timezone conversion for datetime-local input return DateTime.fromISO(localDate, { zone: timezone }) .toUTC() .toISO({ suppressMilliseconds: true }); @@ -52,8 +42,6 @@ export function toUTCDatetime( /** * Updates local datetime values based on UTC date and timezone - * @param params Object containing UTC date and timezone - * @returns Object with updated local datetime string */ export function updateLocalDate({ utcDate, @@ -62,15 +50,11 @@ export function updateLocalDate({ utcDate: string | null; timezone: string; }) { - return { - localDate: toLocalDatetime(utcDate, timezone) - }; + return { localDate: toLocalDatetime(utcDate, timezone) }; } /** * Updates UTC datetime values based on local datetime and timezone - * @param params Object containing local date and timezone - * @returns Object with updated UTC datetime string */ export function updateUTCDate({ localDate, @@ -81,40 +65,27 @@ export function updateUTCDate({ timezone: string; allDay?: boolean; }) { - return { - utcDate: toUTCDatetime(localDate, timezone, allDay) - }; + return { utcDate: toUTCDatetime(localDate, timezone, allDay) }; } /** * Validate date ranges using UTC comparison - * @param startDate - Start date string in UTC (ISO format) - * @param endDate - End date string in UTC (ISO format) - * @returns Object with validation result and optional error message */ export function validateDateRange( startDate: string, endDate: string ): { valid: boolean; error?: string } { if (endDate && !startDate) { - return { - valid: false, - error: 'Start date is required when end date is provided' - }; + return { valid: false, error: 'Start date is required when end date is provided' }; } - if ( startDate && endDate && DateTime.fromISO(startDate, { zone: 'utc' }).toMillis() > DateTime.fromISO(endDate, { zone: 'utc' }).toMillis() ) { - return { - valid: false, - error: 'Start date must be before end date (based on UTC)' - }; + return { valid: false, error: 'Start date must be before end date (based on UTC)' }; } - return { valid: true }; } @@ -135,11 +106,6 @@ export function formatDateInTimezone(utcDate: string, timezone: string | null): } } -/** - * Format UTC date for display - * @param utcDate - UTC date in ISO format - * @returns Formatted date string without seconds (YYYY-MM-DD HH:MM) - */ export function formatUTCDate(utcDate: string | null): string { if (!utcDate) return ''; const dateTime = DateTime.fromISO(utcDate); @@ -147,18 +113,10 @@ export function formatUTCDate(utcDate: string | null): string { return dateTime.toISO()?.slice(0, 16).replace('T', ' ') || ''; } -/** - * Format all-day date for display without timezone conversion - * @param dateString - Date string in ISO format (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS) - * @returns Formatted date string (e.g., "Jun 1, 2025") - */ export function formatAllDayDate(dateString: string): string { if (!dateString) return ''; - - // Extract just the date part and add midday time to avoid timezone issues const datePart = dateString.split('T')[0]; const dateWithMidday = `${datePart}T12:00:00`; - return new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'short', @@ -166,6 +124,150 @@ export function formatAllDayDate(dateString: string): string { }).format(new Date(dateWithMidday)); } +// ==== FIXED TIMEZONE-AWARE DATE RANGE LOGIC ==== + +/** + * Extracts start and end dates from various entity types (Luxon DateTime) + * Returns also isAllDay flag for correct comparison logic + */ +function getEntityDateRange(entity: Visit | Transportation | Lodging | Note | Checklist): { + start: DateTime | null; + end: DateTime | null; + isAllDay: boolean; +} { + let start: DateTime | null = null; + let end: DateTime | null = null; + let isAllDayEvent = false; + try { + let timezone = (entity as Visit).timezone || Intl.DateTimeFormat().resolvedOptions().timeZone; + if ('start_date' in entity && 'end_date' in entity) { + // Check if all-day (no time portion) + isAllDayEvent = isAllDay(entity.start_date) && isAllDay(entity.end_date); + console; + if (isAllDayEvent) { + start = entity.start_date + ? DateTime.fromISO(entity.start_date.split('T')[0], { zone: 'UTC' }).startOf('day') + : null; + end = entity.end_date + ? DateTime.fromISO(entity.end_date.split('T')[0], { zone: 'UTC' }).endOf('day') + : null; + } else { + start = DateTime.fromISO(entity.start_date, { zone: 'UTC' }).setZone(timezone); + end = DateTime.fromISO(entity.end_date, { zone: 'UTC' }).setZone(timezone); + } + } else if ('date' in entity && 'end_date' in entity) { + isAllDayEvent = !!(entity.date && entity.date.length === 10); + if (isAllDayEvent) { + start = DateTime.fromISO(entity.date, { zone: 'UTC' }).startOf('day'); + end = DateTime.fromISO(entity.end_date, { zone: 'UTC' }).endOf('day'); + } else { + start = DateTime.fromISO(entity.date, { zone: 'UTC' }).setZone(timezone); + end = DateTime.fromISO(entity.end_date, { zone: 'UTC' }).setZone(timezone); + } + } else if ('check_in' in entity && 'check_out' in entity) { + isAllDayEvent = !!(entity.check_in && entity.check_in.length === 10); + if (isAllDayEvent) { + start = DateTime.fromISO(entity.check_in, { zone: 'UTC' }).startOf('day'); + end = DateTime.fromISO(entity.check_out, { zone: 'UTC' }).endOf('day'); + } else { + start = DateTime.fromISO(entity.check_in, { zone: 'UTC' }).setZone(timezone); + end = DateTime.fromISO(entity.check_out, { zone: 'UTC' }).setZone(timezone); + } + } else if ('date' in entity) { + isAllDayEvent = !!(entity.date && entity.date.length === 10); + if (isAllDayEvent) { + start = DateTime.fromISO(entity.date, { zone: 'UTC' }).startOf('day'); + end = start; + } else { + start = DateTime.fromISO(entity.date, { zone: 'UTC' }).setZone(timezone); + end = start; + } + } + } catch (error) { + console.error('Error processing entity dates:', error); + } + return { start, end, isAllDay: isAllDayEvent }; +} + +/** + * Extract collection start/end as Luxon DateTime w/allDay logic + */ +function getCollectionDateRange(collection: Collection): { + start: DateTime | null; + end: DateTime | null; + isAllDay: boolean; +} { + if (!collection.start_date || !collection.end_date) { + return { start: null, end: null, isAllDay: false }; + } + + // Assume collection always uses full datetimes in ISO string + const isAllDay = collection.start_date.length === 10 && collection.end_date.length === 10; + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const start = isAllDay + ? DateTime.fromISO(collection.start_date, { zone: 'UTC' }).startOf('day') + : DateTime.fromISO(collection.start_date, { zone: 'UTC' }).setZone(timezone); + const end = isAllDay + ? DateTime.fromISO(collection.end_date, { zone: 'UTC' }).endOf('day') + : DateTime.fromISO(collection.end_date, { zone: 'UTC' }).setZone(timezone); + return { start, end, isAllDay }; +} + +/** + * Checks if an entity falls within a collection's date range (timezone-safe, all-day-aware) + */ +export function isEntityInCollectionDateRange( + entity: Visit | Transportation | Lodging | Note | Checklist, + collection: Collection +): boolean { + if (!collection?.start_date || !collection.end_date) { + return false; + } + + const { start: entityStart, end: entityEnd, isAllDay: entityAllDay } = getEntityDateRange(entity); + const { + start: collStart, + end: collEnd, + isAllDay: collAllDay + } = getCollectionDateRange(collection); + + // If any dates are missing, don't match + if (!entityStart || !collStart) return false; + + // If either side is all-day, use date comparison + if (entityAllDay || collAllDay) { + // Compare only date components + const entStartDate = entityStart.startOf('day'); + const entEndDate = (entityEnd || entityStart).endOf('day'); + const colStartDate = collStart.startOf('day'); + const colEndDate = collEnd.endOf('day'); + return entStartDate <= colEndDate && entEndDate >= colStartDate; + } else { + // Compare actual DateTimes + const entEnd = entityEnd || entityStart; + return entityStart <= collEnd && entEnd >= collStart; + } +} + +export function isEntityOutsideCollectionDateRange( + entity: Visit | Transportation | Lodging | Note | Checklist, + collection: Collection +): boolean { + return !isEntityInCollectionDateRange(entity, collection); +} + +export function getEntitiesInDateRange< + T extends Visit | Transportation | Lodging | Note | Checklist +>(entities: T[], collection: Collection): T[] { + return entities.filter((entity) => isEntityInCollectionDateRange(entity, collection)); +} + +export function getEntitiesOutsideDateRange< + T extends Visit | Transportation | Lodging | Note | Checklist +>(entities: T[], collection: Collection): T[] { + return entities.filter((entity) => isEntityOutsideCollectionDateRange(entity, collection)); +} + export const VALID_TIMEZONES = [ 'Africa/Abidjan', 'Africa/Accra',