From 6f923f0181edd3d7698ea98bdecc7e8508b334d9 Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Sat, 27 Dec 2025 16:21:44 -0500 Subject: [PATCH] feat: implement date validation for itinerary items and add day picker modal for scheduling --- backend/server/adventures/utils/itinerary.py | 22 ++ .../server/adventures/views/itinerary_view.py | 28 +++ .../server/adventures/views/location_view.py | 22 +- .../src/lib/components/LocationLink.svelte | 204 ++++++++++++++---- .../cards/TransportationCard.svelte | 55 ++--- .../CollectionItineraryPlanner.svelte | 107 ++++++++- .../collections/ItineraryDayPickModal.svelte | 134 ++++++++++++ .../TransportationDetails.svelte | 8 - frontend/src/lib/config.ts | 2 +- frontend/src/locales/en.json | 4 +- .../src/routes/collections/[id]/+page.svelte | 86 +++++++- 11 files changed, 589 insertions(+), 83 deletions(-) create mode 100644 frontend/src/lib/components/collections/ItineraryDayPickModal.svelte diff --git a/backend/server/adventures/utils/itinerary.py b/backend/server/adventures/utils/itinerary.py index deaf0bd0..4769ded3 100644 --- a/backend/server/adventures/utils/itinerary.py +++ b/backend/server/adventures/utils/itinerary.py @@ -1,5 +1,6 @@ from typing import List from django.db import transaction +from django.utils.dateparse import parse_date, parse_datetime from rest_framework.exceptions import ValidationError, PermissionDenied from adventures.models import CollectionItineraryItem @@ -75,6 +76,27 @@ def reorder_itinerary_items(user, items_data: List[dict]): new_date = item_data.get('date') new_order = item_data.get('order') if new_date is not None: + # validate date is within collection bounds (if collection has start/end) + parsed = None + try: + parsed = parse_date(str(new_date)) + except Exception: + parsed = None + if parsed is None: + try: + dt = parse_datetime(str(new_date)) + if dt: + parsed = dt.date() + except Exception: + parsed = None + + collection = item.collection + if parsed and collection: + if collection.start_date and parsed < collection.start_date: + raise ValidationError({"items": f"Item {item_id} date {parsed} is before collection start date {collection.start_date}."}) + if collection.end_date and parsed > collection.end_date: + raise ValidationError({"items": f"Item {item_id} date {parsed} is after collection end date {collection.end_date}."}) + item.date = new_date if new_order is not None: item.order = new_order diff --git a/backend/server/adventures/views/itinerary_view.py b/backend/server/adventures/views/itinerary_view.py index 28f8eb43..c4c81af9 100644 --- a/backend/server/adventures/views/itinerary_view.py +++ b/backend/server/adventures/views/itinerary_view.py @@ -179,6 +179,34 @@ class ItineraryViewSet(viewsets.ModelViewSet): item_date = data.get('date') item_order = data.get('order', 0) + # Validate that the itinerary date (if provided) falls within the + # collection's start_date/end_date range (if those bounds are set). + if collection_id and item_date: + # Try parse date or datetime-like values + parsed_date = None + try: + parsed_date = parse_date(str(item_date)) + except Exception: + parsed_date = None + if parsed_date is None: + try: + dt = parse_datetime(str(item_date)) + if dt: + parsed_date = dt.date() + except Exception: + parsed_date = None + + if parsed_date is not None: + try: + collection_obj = Collection.objects.get(id=collection_id) + except Collection.DoesNotExist: + return Response({'error': 'Collection not found'}, status=status.HTTP_404_NOT_FOUND) + + if collection_obj.start_date and parsed_date < collection_obj.start_date: + return Response({'error': 'Itinerary item date is before the collection start_date'}, status=status.HTTP_400_BAD_REQUEST) + if collection_obj.end_date and parsed_date > collection_obj.end_date: + return Response({'error': 'Itinerary item date is after the collection end_date'}, status=status.HTTP_400_BAD_REQUEST) + if collection_id and item_date: # Find the maximum order for this collection+date existing_max = CollectionItineraryItem.objects.filter( diff --git a/backend/server/adventures/views/location_view.py b/backend/server/adventures/views/location_view.py index b7de9227..226c5c2e 100644 --- a/backend/server/adventures/views/location_view.py +++ b/backend/server/adventures/views/location_view.py @@ -7,7 +7,8 @@ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response import requests -from adventures.models import Location, Category +from adventures.models import Location, Category, CollectionItineraryItem +from django.contrib.contenttypes.models import ContentType from adventures.permissions import IsOwnerOrSharedWithFullAccess from adventures.serializers import LocationSerializer, MapPinSerializer from adventures.utils import pagination @@ -277,6 +278,25 @@ class LocationViewSet(viewsets.ModelViewSet): raise PermissionDenied( f"You don't have permission to remove this location from one of the collections it's linked to.'" ) + else: + # If the removal is permitted, also remove any itinerary items + # in this collection that reference this Location instance. + try: + ct = ContentType.objects.get_for_model(instance.__class__) + # Try deleting by native PK type first, then by string. + qs = CollectionItineraryItem.objects.filter( + collection=collection, content_type=ct, object_id=instance.pk + ) + if qs.exists(): + qs.delete() + else: + CollectionItineraryItem.objects.filter( + collection=collection, content_type=ct, object_id=str(instance.pk) + ).delete() + except Exception: + # Don't raise on cleanup failures; deletion of itinerary items + # is best-effort and shouldn't block the update operation. + pass def _validate_collection_permissions(self, collections): """Validate permissions for all collections (used in create).""" diff --git a/frontend/src/lib/components/LocationLink.svelte b/frontend/src/lib/components/LocationLink.svelte index 5da50e99..d69ee800 100644 --- a/frontend/src/lib/components/LocationLink.svelte +++ b/frontend/src/lib/components/LocationLink.svelte @@ -1,5 +1,5 @@ + +{#if isOpen} + + + + +{/if} diff --git a/frontend/src/lib/components/transportation/TransportationDetails.svelte b/frontend/src/lib/components/transportation/TransportationDetails.svelte index 1678eaba..0648a2f7 100644 --- a/frontend/src/lib/components/transportation/TransportationDetails.svelte +++ b/frontend/src/lib/components/transportation/TransportationDetails.svelte @@ -595,10 +595,6 @@ maxlength="5" placeholder={airportMode ? 'JFK' : 'Code'} /> -

- {$t('transportation.autofill_code_hint') || - 'Auto-filled from airport search; you can override'} -

diff --git a/frontend/src/lib/config.ts b/frontend/src/lib/config.ts index a25803f3..ede8b9e2 100644 --- a/frontend/src/lib/config.ts +++ b/frontend/src/lib/config.ts @@ -1,4 +1,4 @@ -export let appVersion = 'v0.12.0-pre-dev-122625'; +export let appVersion = 'v0.12.0-pre-dev-122725'; export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.11.0'; export let appTitle = 'AdventureLog'; export let copyrightYear = '2023-2025'; diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 63b63771..38445fc4 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -842,7 +842,9 @@ "enter_link": "Enter link", "enter_flight_number": "Enter flight number", "enter_from_location": "Enter from location", - "enter_to_location": "Enter to location" + "enter_to_location": "Enter to location", + "arrival_code": "Arrival Code", + "departure_code": "Departure Code" }, "lodging": { "new_lodging": "New Lodging", diff --git a/frontend/src/routes/collections/[id]/+page.svelte b/frontend/src/routes/collections/[id]/+page.svelte index d93d0268..bf4116c8 100644 --- a/frontend/src/routes/collections/[id]/+page.svelte +++ b/frontend/src/routes/collections/[id]/+page.svelte @@ -2,7 +2,7 @@ import type { Collection, ContentImage, Location } from '$lib/types'; import { onMount } from 'svelte'; import type { PageData } from './$types'; - import { goto } from '$app/navigation'; + import { goto, invalidateAll } from '$app/navigation'; import { page } from '$app/stores'; import Lost from '$lib/assets/undraw_lost.svg'; import { DefaultMarker, MapLibre, Popup } from 'svelte-maplibre'; @@ -16,12 +16,15 @@ import CollectionAllItems from '$lib/components/collections/CollectionAllItems.svelte'; import CollectionItineraryPlanner from '$lib/components/collections/CollectionItineraryPlanner.svelte'; import CollectionRecommendationView from '$lib/components/CollectionRecommendationView.svelte'; + import LocationLink from '$lib/components/LocationLink.svelte'; import { getBasemapUrl } from '$lib'; import FolderMultiple from '~icons/mdi/folder-multiple'; import FormatListBulleted from '~icons/mdi/format-list-bulleted'; import Timeline from '~icons/mdi/timeline'; import Map from '~icons/mdi/map'; import Lightbulb from '~icons/mdi/lightbulb'; + import Plus from '~icons/mdi/plus'; + import { addToast } from '$lib/toasts'; const renderMarkdown = (markdown: string) => { return marked(markdown) as string; @@ -37,6 +40,7 @@ let heroImages: ContentImage[] = []; let modalInitialIndex: number = 0; let isImageModalOpen: boolean = false; + let isLocationLinkModalOpen: boolean = false; // View state from URL params type ViewType = 'all' | 'itinerary' | 'map' | 'recommendations'; @@ -141,6 +145,64 @@ url.searchParams.set('view', view); goto(url.toString(), { replaceState: true, noScroll: true }); } + + function openLocationLinkModal() { + isLocationLinkModalOpen = true; + } + + function closeLocationLinkModal() { + isLocationLinkModalOpen = false; + } + + async function handleLocationAdded(event: CustomEvent) { + // Link the location to this collection + const location = event.detail; + + try { + const response = await fetch(`/api/locations/${location.id}/`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + collections: [...(location.collections || []), collection.id] + }) + }); + + if (response.ok) { + // Keep modal open so user can link more locations. + // Update local collection state so UI reflects the new link immediately. + try { + if (!collection.locations) collection.locations = []; + // Avoid duplicates + const exists = collection.locations.some((l) => String(l.id) === String(location.id)); + if (!exists) { + collection.locations = [...collection.locations, location]; + } + } catch (e) { + // if collection shape is unexpected, ignore and continue + console.warn('Unable to update local collection.locations', e); + } + + // Show success message but do NOT close the modal or reload the page + addToast( + 'success', + $t('adventures.collection_link_location_success') || 'Location added successfully' + ); + } else { + addToast( + 'error', + $t('adventures.collection_link_location_error') || 'Failed to add location' + ); + } + } catch (error) { + console.error('Error linking location:', error); + addToast( + 'error', + $t('adventures.collection_link_location_error') || 'Failed to add location' + ); + } + } {#if notFound} @@ -167,6 +229,15 @@ /> {/if} +{#if isLocationLinkModalOpen && collection} + +{/if} + {#if !collection && !notFound}
@@ -606,6 +677,19 @@
{/if} + +{#if collection && canModifyCollection} +
+ +
+{/if} + {collection && collection.name ? `${collection.name}` : 'Collection'}