From 8ea98795a9a442346bc2016223c9035c98278871 Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Tue, 16 Dec 2025 12:32:51 -0500 Subject: [PATCH] feat: implement itinerary planning feature with CollectionItineraryPlanner component and related updates --- backend/server/adventures/serializers.py | 28 +- .../adventures/views/collection_view.py | 15 +- frontend/package-lock.json | 61 +-- frontend/package.json | 1 + .../src/lib/components/ChecklistCard.svelte | 10 - .../src/lib/components/LocationCard.svelte | 16 - .../src/lib/components/LodgingCard.svelte | 17 +- frontend/src/lib/components/NoteCard.svelte | 12 - .../lib/components/TransportationCard.svelte | 21 +- .../CollectionItineraryPlanner.svelte | 406 ++++++++++++++++++ frontend/src/lib/types.ts | 14 + .../src/routes/collections/[id]/+page.svelte | 27 +- 12 files changed, 485 insertions(+), 143 deletions(-) create mode 100644 frontend/src/lib/components/locations/CollectionItineraryPlanner.svelte diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 93ef0e61..5d784122 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -713,34 +713,12 @@ class CollectionItineraryItemSerializer(CustomModelSerializer): read_only_fields = ['id', 'created_at', 'start_datetime', 'end_datetime', 'item', 'object_name'] def get_item(self, obj): - """Return serialized data for the linked item""" + """Return id and type for the linked item""" if not obj.item: return None - # Get the appropriate serializer based on the content type - from django.contrib.contenttypes.models import ContentType - - content_type = obj.content_type - item = obj.item - - # Map content types to their serializers - serializer_mapping = { - 'visit': VisitSerializer, - 'transportation': TransportationSerializer, - 'lodging': LodgingSerializer, - 'note': NoteSerializer, - 'checklist': ChecklistSerializer, - } - - model_name = content_type.model - serializer_class = serializer_mapping.get(model_name) - - if serializer_class: - return serializer_class(item, context=self.context).data - - # Fallback for unknown content types return { - 'id': str(item.id), - 'type': model_name, + 'id': str(obj.item.id), + 'type': obj.content_type.model, } \ No newline at end of file diff --git a/backend/server/adventures/views/collection_view.py b/backend/server/adventures/views/collection_view.py index cb4715d7..39e8bcc8 100644 --- a/backend/server/adventures/views/collection_view.py +++ b/backend/server/adventures/views/collection_view.py @@ -184,13 +184,18 @@ class CollectionViewSet(viewsets.ModelViewSet): return Response(serializer.data) - # get view to get all the itinerary items for the collection - @action(detail=True, methods=['get']) - def itinerary(self, request, pk=None): + def retrieve(self, request, pk=None): + """Retrieve a collection and include itinerary items in the response.""" collection = self.get_object() + serializer = self.get_serializer(collection) + data = serializer.data + + # Include itinerary items inline with collection details itinerary_items = CollectionItineraryItem.objects.filter(collection=collection) - serializer = CollectionItineraryItemSerializer(itinerary_items, many=True) - return Response(serializer.data) + itinerary_serializer = CollectionItineraryItemSerializer(itinerary_items, many=True) + data['itinerary'] = itinerary_serializer.data + + return Response(data) # this make the is_public field of the collection cascade to the locations @transaction.atomic diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 97c894d7..a0dc6193 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,48 +1,49 @@ { "name": "adventurelog-frontend", - "version": "0.10.0", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "adventurelog-frontend", - "version": "0.10.0", + "version": "0.11.0", "dependencies": { "@lukulent/svelte-umami": "^0.0.3", - "dompurify": "^3.2.4", - "emoji-picker-element": "^1.26.0", + "dompurify": "^3.2.5", + "emoji-picker-element": "^1.26.3", "gsap": "^3.12.7", "luxon": "^3.6.1", - "marked": "^15.0.4", + "marked": "^15.0.11", "psl": "^1.15.0", "qrcode": "^1.5.4", + "svelte-dnd-action": "^0.9.68", "svelte-i18n": "^4.0.1", - "svelte-maplibre": "^0.9.8" + "svelte-maplibre": "^0.9.14" }, "devDependencies": { - "@event-calendar/core": "^3.7.1", - "@event-calendar/day-grid": "^3.7.1", + "@event-calendar/core": "^3.12.0", + "@event-calendar/day-grid": "^3.12.0", "@event-calendar/interaction": "^3.12.0", - "@event-calendar/time-grid": "^3.7.1", - "@iconify-json/mdi": "^1.1.67", - "@sveltejs/adapter-node": "^5.2.0", - "@sveltejs/adapter-vercel": "^5.4.1", - "@sveltejs/kit": "^2.8.3", - "@sveltejs/vite-plugin-svelte": "^3.1.1", - "@tailwindcss/typography": "^0.5.13", - "@types/node": "^22.5.4", + "@event-calendar/time-grid": "^3.12.0", + "@iconify-json/mdi": "^1.2.3", + "@sveltejs/adapter-node": "^5.2.12", + "@sveltejs/adapter-vercel": "^5.7.0", + "@sveltejs/kit": "^2.20.7", + "@sveltejs/vite-plugin-svelte": "^3.1.2", + "@tailwindcss/typography": "^0.5.16", + "@types/node": "^22.15.2", "@types/qrcode": "^1.5.5", - "autoprefixer": "^10.4.19", - "daisyui": "^4.12.6", - "postcss": "^8.4.38", - "prettier": "^3.3.2", - "prettier-plugin-svelte": "^3.2.5", + "autoprefixer": "^10.4.21", + "daisyui": "^4.12.24", + "postcss": "^8.5.3", + "prettier": "^3.5.3", + "prettier-plugin-svelte": "^3.3.3", "svelte": "^4.2.19", - "svelte-check": "^3.8.1", - "tailwindcss": "^3.4.4", - "tslib": "^2.6.3", - "typescript": "^5.5.2", - "unplugin-icons": "^0.19.0", + "svelte-check": "^3.8.6", + "tailwindcss": "^3.4.17", + "tslib": "^2.8.1", + "typescript": "^5.8.3", + "unplugin-icons": "^0.19.3", "vite": "^5.4.19" } }, @@ -4725,6 +4726,14 @@ "svelte": "^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0" } }, + "node_modules/svelte-dnd-action": { + "version": "0.9.68", + "resolved": "https://registry.npmjs.org/svelte-dnd-action/-/svelte-dnd-action-0.9.68.tgz", + "integrity": "sha512-maFNIHwimGYbvIG8uOHsU9T/4+VKBIaAaFEGWYFIyo4f8qwUs0BIqwvBfHkaN+MXt8MBB9rByPTvF7fRx0eIjw==", + "peerDependencies": { + "svelte": ">=3.23.0 || ^5.0.0-next.0" + } + }, "node_modules/svelte-hmr": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.16.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4577bd7f..13a295f2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -47,6 +47,7 @@ "marked": "^15.0.11", "psl": "^1.15.0", "qrcode": "^1.5.4", + "svelte-dnd-action": "^0.9.68", "svelte-i18n": "^4.0.1", "svelte-maplibre": "^0.9.14" }, diff --git a/frontend/src/lib/components/ChecklistCard.svelte b/frontend/src/lib/components/ChecklistCard.svelte index 745dd8c0..c8b9d90f 100644 --- a/frontend/src/lib/components/ChecklistCard.svelte +++ b/frontend/src/lib/components/ChecklistCard.svelte @@ -13,7 +13,6 @@ import FileDocumentEdit from '~icons/mdi/file-document-edit'; import CheckCircle from '~icons/mdi/check-circle'; import CheckboxBlankCircleOutline from '~icons/mdi/checkbox-blank-circle-outline'; - import { isEntityOutsideCollectionDateRange } from '$lib/dateUtils'; export let checklist: Checklist; export let user: User | null = null; @@ -21,12 +20,6 @@ let isWarningModalOpen: boolean = false; - let outsideCollectionRange: boolean = false; - - $: { - outsideCollectionRange = isEntityOutsideCollectionDateRange(checklist, collection); - } - function editChecklist() { dispatch('edit', checklist); } @@ -66,9 +59,6 @@

{checklist.name}

{$t('adventures.checklist')}
- {#if outsideCollectionRange} -
{$t('adventures.out_of_range')}
- {/if}
diff --git a/frontend/src/lib/components/LocationCard.svelte b/frontend/src/lib/components/LocationCard.svelte index b5ddf280..20c7fab8 100644 --- a/frontend/src/lib/components/LocationCard.svelte +++ b/frontend/src/lib/components/LocationCard.svelte @@ -25,7 +25,6 @@ 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; @@ -64,18 +63,6 @@ } } - let outsideCollectionRange: boolean = false; - - $: { - if (collection && collection.start_date && collection.end_date) { - outsideCollectionRange = adventure.visits.every((visit) => - isEntityOutsideCollectionDateRange(visit, collection) - ); - } else { - outsideCollectionRange = false; - } - } - // Creator avatar helpers $: creatorInitials = adventure.user?.first_name && adventure.user?.last_name @@ -219,9 +206,6 @@ {/if} - {#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 72735b7f..ae9f6bea 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, isEntityOutsideCollectionDateRange } from '$lib/dateUtils'; + import { formatDateInTimezone } from '$lib/dateUtils'; import { formatAllDayDate } from '$lib/dateUtils'; import { isAllDay } from '$lib'; import CardCarousel from './CardCarousel.svelte'; @@ -46,14 +46,6 @@ dispatch('edit', lodging); } - let outsideCollectionRange: boolean = false; - - $: { - if (collection) { - outsideCollectionRange = isEntityOutsideCollectionDateRange(lodging, collection); - } - } - async function deleteTransportation() { let res = await fetch(`/api/lodging/${lodging.id}`, { method: 'DELETE', @@ -110,13 +102,6 @@ - - {#if outsideCollectionRange} -
-
{$t('adventures.out_of_range')}
-
- {/if} - {#if lodging.type}
diff --git a/frontend/src/lib/components/NoteCard.svelte b/frontend/src/lib/components/NoteCard.svelte index c2da2cde..88f31c14 100644 --- a/frontend/src/lib/components/NoteCard.svelte +++ b/frontend/src/lib/components/NoteCard.svelte @@ -11,27 +11,18 @@ return marked(markdown); }; - import Launch from '~icons/mdi/launch'; import TrashCan from '~icons/mdi/trash-can'; import Calendar from '~icons/mdi/calendar'; import DeleteWarning from './DeleteWarning.svelte'; import DotsHorizontal from '~icons/mdi/dots-horizontal'; import FileDocumentEdit from '~icons/mdi/file-document-edit'; import LinkVariant from '~icons/mdi/link-variant'; - 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 outsideCollectionRange: boolean = false; - - $: { - if (collection) { - outsideCollectionRange = isEntityOutsideCollectionDateRange(note, collection); - } - } function editNote() { dispatch('edit', note); @@ -73,9 +64,6 @@

{note.name}

{$t('adventures.note')}
- {#if outsideCollectionRange} -
{$t('adventures.out_of_range')}
- {/if}
diff --git a/frontend/src/lib/components/TransportationCard.svelte b/frontend/src/lib/components/TransportationCard.svelte index 160a0d8f..1526528e 100644 --- a/frontend/src/lib/components/TransportationCard.svelte +++ b/frontend/src/lib/components/TransportationCard.svelte @@ -8,11 +8,7 @@ import DeleteWarning from './DeleteWarning.svelte'; // import ArrowDownThick from '~icons/mdi/arrow-down-thick'; import { TRANSPORTATION_TYPES_ICONS } from '$lib'; - import { - formatAllDayDate, - formatDateInTimezone, - isEntityOutsideCollectionDateRange - } from '$lib/dateUtils'; + import { formatAllDayDate, formatDateInTimezone } from '$lib/dateUtils'; import { isAllDay } from '$lib'; import CardCarousel from './CardCarousel.svelte'; @@ -52,14 +48,6 @@ dispatch('edit', transportation); } - let outsideCollectionRange: boolean = false; - - $: { - if (collection) { - outsideCollectionRange = isEntityOutsideCollectionDateRange(transportation, collection); - } - } - async function deleteTransportation() { let res = await fetch(`/api/transportations/${transportation.id}`, { method: 'DELETE', @@ -120,13 +108,6 @@ - - {#if outsideCollectionRange} -
-
{$t('adventures.out_of_range')}
-
- {/if} - {#if transportation.type}
diff --git a/frontend/src/lib/components/locations/CollectionItineraryPlanner.svelte b/frontend/src/lib/components/locations/CollectionItineraryPlanner.svelte new file mode 100644 index 00000000..e58a3221 --- /dev/null +++ b/frontend/src/lib/components/locations/CollectionItineraryPlanner.svelte @@ -0,0 +1,406 @@ + + +{#if days.length === 0 && unscheduledItems.length === 0} +
+
+ +

No Itinerary Yet

+

Start planning your trip by adding items to specific days.

+
+
+{:else} +
+ + {#each days as day, dayIndex} +
+
+ +
+ +

{day.displayDate}

+
+ {day.items.length} + {day.items.length === 1 ? 'item' : 'items'} +
+
+ + +
+ {#if day.items.length === 0} +
+
+ +

No plans for this day

+
+
+ {:else} +
handleDndConsider(dayIndex, e)} + on:finalize={(e) => handleDndFinalize(dayIndex, e)} + class="space-y-4" + > + {#each day.items as item, index (item.id)} + {@const objectType = item.item?.type || ''} + {@const resolvedObj = item.resolvedObject} + {@const multiDay = isMultiDay(item)} + {@const isDraggingShadow = item[SHADOW_ITEM_MARKER_PROPERTY_NAME]} + +
+ {#if resolvedObj} + +
+
+ + + +
+
+ + +
+
+ {index + 1} +
+
+ + + {#if multiDay && objectType === 'lodging'} +
+
+ + + + Overnight +
+
+ {/if} + + +
+ + {#if objectType === 'location'} + + {:else if objectType === 'transportation'} + + {:else if objectType === 'lodging'} + + {:else if objectType === 'note'} + + + {:else if objectType === 'checklist'} + + + {/if} +
+ {:else} + +
+ ⚠️ Item not found (ID: {item.object_id}) +
+ {/if} +
+ {/each} +
+ {/if} +
+
+
+ {/each} + + + {#if unscheduledItems.length > 0} +
+
+ +
+
+

Unscheduled Items

+
+ {unscheduledItems.length} + {unscheduledItems.length === 1 ? 'item' : 'items'} +
+
+ +

+ These items are linked to this trip but haven't been added to a specific day yet. +

+ + +
+ {#each unscheduledItems as { type, item }} +
+ +
+ +
+ + + {#if type === 'location'} + + {:else if type === 'transportation'} + + {:else if type === 'lodging'} + + {:else if type === 'note'} + + {:else if type === 'checklist'} + + {/if} +
+ {/each} +
+
+
+ {/if} +
+{/if} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 8198f502..6296146b 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -135,6 +135,7 @@ export type Collection = { is_archived?: boolean; shared_with: string[] | undefined; link?: string | null; + itinerary: CollectionItineraryItem[]; }; export type SlimCollection = { @@ -491,3 +492,16 @@ export type Pin = { is_visited?: boolean; category: Category | null; }; + +export type CollectionItineraryItem = { + id: string; + collection: string; // UUID of the collection + content_type: string; // Content type model name + object_id: string; // UUID of the referenced object + item: Visit | Transportation | Lodging | Note | Checklist; // The actual referenced object + date: string | null; // ISO 8601 date string + order: number; // Manual order within a day + created_at: string; // ISO 8601 date string + start_datetime: string | null; // Computed property - ISO 8601 date string + end_datetime: string | null; // Computed property - ISO 8601 date string +}; diff --git a/frontend/src/routes/collections/[id]/+page.svelte b/frontend/src/routes/collections/[id]/+page.svelte index 788d5740..0e21073d 100644 --- a/frontend/src/routes/collections/[id]/+page.svelte +++ b/frontend/src/routes/collections/[id]/+page.svelte @@ -14,6 +14,7 @@ import Calendar from '~icons/mdi/calendar'; import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte'; import CollectionAllItems from '$lib/components/CollectionAllItems.svelte'; + import CollectionItineraryPlanner from '$lib/components/locations/CollectionItineraryPlanner.svelte'; import { getBasemapUrl } from '$lib'; import FolderMultiple from '~icons/mdi/folder-multiple'; import FormatListBulleted from '~icons/mdi/format-list-bulleted'; @@ -36,10 +37,10 @@ let isImageModalOpen: boolean = false; // View state from URL params - type ViewType = 'all' | 'timeline' | 'map'; - let currentView: ViewType = 'timeline'; + type ViewType = 'all' | 'itinerary' | 'map'; + let currentView: ViewType = 'itinerary'; - // Determine if this is a folder view (no dates) or timeline view (has dates) + // Determine if this is a folder view (no dates) or itinerary view (has dates) $: isFolderView = !collection?.start_date && !collection?.end_date; // Gather all images from locations for the hero @@ -48,18 +49,18 @@ // Define available views based on collection type $: availableViews = { all: true, // Always available - timeline: !isFolderView, // Only for collections with dates + itinerary: !isFolderView, // Only for collections with dates map: collection?.locations?.some((l) => l.latitude && l.longitude) || false }; // Get default view based on available views let defaultView: ViewType; - $: defaultView = (availableViews.timeline ? 'timeline' : 'all') as ViewType; + $: defaultView = (availableViews.itinerary ? 'itinerary' : 'all') as ViewType; // Read view from URL params and validate it's available $: { const view = $page.url.searchParams.get('view') as ViewType; - if (view && ['all', 'timeline', 'map'].includes(view) && availableViews[view]) { + if (view && ['all', 'itinerary', 'map'].includes(view) && availableViews[view]) { currentView = view; } else { currentView = defaultView; @@ -277,14 +278,14 @@ All Items {/if} - {#if availableViews.timeline} + {#if availableViews.itinerary} {/if} {#if availableViews.map} @@ -320,9 +321,9 @@ {/if} - - {#if currentView === 'timeline' && collection.locations && collection.locations.length > 0} -

timeline goes here

+ + {#if currentView === 'itinerary'} + {/if}