From 2f9a3f20ca4f634612ce1f9d487ab664a7d91bfa Mon Sep 17 00:00:00 2001 From: Sean Morley Date: Tue, 6 Jan 2026 16:24:56 -0500 Subject: [PATCH] feat: add functionality to change day and move items to trip-wide itinerary - Implemented changeDay function in ChecklistCard, LocationCard, LodgingCard, NoteCard, and TransportationCard components to allow users to change the scheduled day of items. - Added a button to move items to the global (trip-wide) itinerary in the aforementioned components, with appropriate dispatch events. - Enhanced CollectionItineraryPlanner to handle moving items to the global itinerary and added UI elements for unscheduled items. - Updated ItineraryDayPickModal to support the deletion of source visits when moving locations. - Added new translations for "Change Day" and "Move Trip Wide" in the English locale. --- .../server/adventures/views/itinerary_view.py | 128 ++++- .../lib/components/cards/ChecklistCard.svelte | 23 + .../lib/components/cards/LocationCard.svelte | 23 + .../lib/components/cards/LodgingCard.svelte | 23 + .../src/lib/components/cards/NoteCard.svelte | 22 + .../cards/TransportationCard.svelte | 30 +- .../CollectionItineraryPlanner.svelte | 464 ++++++++++++++---- .../collections/CollectionStats.svelte | 16 +- .../collections/ItineraryDayPickModal.svelte | 22 +- frontend/src/locales/en.json | 4 +- 10 files changed, 620 insertions(+), 135 deletions(-) diff --git a/backend/server/adventures/views/itinerary_view.py b/backend/server/adventures/views/itinerary_view.py index 9ca0b127..4e95d8ff 100644 --- a/backend/server/adventures/views/itinerary_view.py +++ b/backend/server/adventures/views/itinerary_view.py @@ -152,32 +152,91 @@ class ItineraryViewSet(viewsets.ModelViewSet): new_start = None new_end = None - # If we have valid bounds, check for any overlapping Visit for this location. - # Overlap condition: existing.start_date <= new_end AND existing.end_date >= new_start + # If we have valid bounds, update an existing Visit when provided, else create if none overlaps. + # This keeps linked data (attachments, comments) on the same Visit object. if new_start and new_end: - overlap_q = Q(start_date__lte=new_end) & Q(end_date__gte=new_start) - existing = Visit.objects.filter(location=content_object).filter(overlap_q) - if not existing.exists(): - Visit.objects.create( - location=content_object, - start_date=new_start, - end_date=new_end, - notes="Created from itinerary planning" - ) - # else: an overlapping visit already exists — skip creating a duplicate + source_visit_id = data.get('source_visit_id') + if source_visit_id: + try: + source_visit = Visit.objects.get(id=source_visit_id, location=content_object) + source_visit.start_date = new_start + source_visit.end_date = new_end + source_visit.save(update_fields=['start_date', 'end_date']) + except Visit.DoesNotExist: + # Fall back to create-or-skip logic below + pass + + if not data.get('source_visit_id'): + # Overlap condition: existing.start_date <= new_end AND existing.end_date >= new_start + overlap_q = Q(start_date__lte=new_end) & Q(end_date__gte=new_start) + existing = Visit.objects.filter(location=content_object).filter(overlap_q) + if not existing.exists(): + Visit.objects.create( + location=content_object, + start_date=new_start, + end_date=new_end, + notes="Created from itinerary planning" + ) + # else: an overlapping visit already exists — skip creating a duplicate else: - # For other item types, update their date field - date_field = None - if hasattr(content_object, 'date'): - date_field = 'date' - elif hasattr(content_object, 'start_date'): - date_field = 'start_date' - elif hasattr(content_object, 'check_in'): - date_field = 'check_in' - - if date_field: - setattr(content_object, date_field, clean_date) - content_object.save(update_fields=[date_field]) + # For other item types, update their date field and preserve duration + if content_type_val == 'transportation': + # For transportation: update date and end_date, preserving duration and times + if hasattr(content_object, 'date') and hasattr(content_object, 'end_date'): + old_date = content_object.date + old_end_date = content_object.end_date + + if old_date and old_end_date: + # Extract time from original start date + original_time = old_date.time() + # Create new_date with the new date but preserve the original time + new_date = datetime.datetime.combine(parse_date(clean_date), original_time) + # Duration = end_date - date + duration = old_end_date - old_date + # Apply same duration to new date + new_end_date = new_date + duration + else: + # No original end date, set to same as start date + new_date = datetime.datetime.combine(parse_date(clean_date), datetime.time.min) + new_end_date = new_date + + content_object.date = new_date + content_object.end_date = new_end_date + content_object.save(update_fields=['date', 'end_date']) + elif content_type_val == 'lodging': + # For lodging: update check_in and check_out, preserving duration and times + if hasattr(content_object, 'check_in') and hasattr(content_object, 'check_out'): + old_check_in = content_object.check_in + old_check_out = content_object.check_out + + if old_check_in and old_check_out: + # Extract time from original check_in + original_time = old_check_in.time() + # Create new_check_in with the new date but preserve the original time + new_check_in = datetime.datetime.combine(parse_date(clean_date), original_time) + # Duration = check_out - check_in + duration = old_check_out - old_check_in + # Apply same duration to new check_in + new_check_out = new_check_in + duration + else: + # No original check_out, set to same as check_in + new_check_in = datetime.datetime.combine(parse_date(clean_date), datetime.time.min) + new_check_out = new_check_in + + content_object.check_in = new_check_in + content_object.check_out = new_check_out + content_object.save(update_fields=['check_in', 'check_out']) + else: + # For note, checklist, etc. - just update the date field + date_field = None + if hasattr(content_object, 'date'): + date_field = 'date' + elif hasattr(content_object, 'start_date'): + date_field = 'start_date' + + if date_field: + setattr(content_object, date_field, clean_date) + content_object.save(update_fields=[date_field]) # Ensure order is unique for this collection+group combination (day or global) collection_id = data.get('collection') @@ -246,8 +305,27 @@ class ItineraryViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(data=data) serializer.is_valid(raise_exception=True) self.perform_create(serializer) + + # If we updated the item's date, include the updated object in response for frontend sync + response_data = serializer.data + if update_item_date and content_type_val and object_id: + if content_type_val == 'transportation': + try: + t = Transportation.objects.get(id=object_id) + from adventures.serializers import TransportationSerializer + response_data['updated_object'] = TransportationSerializer(t).data + except Transportation.DoesNotExist: + pass + elif content_type_val == 'lodging': + try: + l = Lodging.objects.get(id=object_id) + from adventures.serializers import LodgingSerializer + response_data['updated_object'] = LodgingSerializer(l).data + except Lodging.DoesNotExist: + pass + headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + return Response(response_data, status=status.HTTP_201_CREATED, headers=headers) @transaction.atomic def destroy(self, request, *args, **kwargs): diff --git a/frontend/src/lib/components/cards/ChecklistCard.svelte b/frontend/src/lib/components/cards/ChecklistCard.svelte index 8a385902..3aaf33aa 100644 --- a/frontend/src/lib/components/cards/ChecklistCard.svelte +++ b/frontend/src/lib/components/cards/ChecklistCard.svelte @@ -15,6 +15,7 @@ import CheckboxBlankCircleOutline from '~icons/mdi/checkbox-blank-circle-outline'; import CalendarRemove from '~icons/mdi/calendar-remove'; import Close from '~icons/mdi/close'; + import Globe from '~icons/mdi/globe'; import type { CollectionItineraryItem } from '$lib/types'; export let checklist: Checklist; @@ -48,6 +49,10 @@ dispatch('edit', checklist); } + function changeDay() { + dispatch('changeDay', { type: 'checklist', item: checklist, forcePicker: true }); + } + async function deleteChecklist() { const res = await fetch(`/api/checklists/${checklist.id}`, { method: 'DELETE' @@ -267,6 +272,24 @@ {#if itineraryItem && itineraryItem.id}
+ {#if !itineraryItem.is_global} +
  • + +
  • + {/if} +
  • + +
  • +
  • + {/if} +
  • + +
  • {#if itineraryItem && itineraryItem.id}
    + {#if !itineraryItem.is_global} +
  • + +
  • + {/if} +
  • + +
  • {#if itineraryItem && itineraryItem.id}
    + {#if !itineraryItem.is_global} +
  • + +
  • + {/if} +
  • + +
  • {#if itineraryItem && itineraryItem.id}
    + {#if !itineraryItem.is_global} +
  • + +
  • + {/if} +
  • + +
  • @@ -1392,6 +1635,7 @@ on:delete={handleItemDelete} itineraryItem={item} on:removeFromItinerary={handleRemoveItineraryItem} + on:moveToGlobal={(e) => moveItemToGlobal(e.detail.type, e.detail.id)} {user} {collection} compact={true} @@ -1405,6 +1649,7 @@ itineraryItem={item} on:removeFromItinerary={handleRemoveItineraryItem} on:edit={handleEditTransportation} + on:moveToGlobal={(e) => moveItemToGlobal(e.detail.type, e.detail.id)} /> {:else if objectType === 'lodging'} moveItemToGlobal(e.detail.type, e.detail.id)} /> {:else if objectType === 'note'} moveItemToGlobal(e.detail.type, e.detail.id)} /> {:else if objectType === 'checklist'} moveItemToGlobal(e.detail.type, e.detail.id)} /> {/if} {:else} @@ -1797,9 +2045,17 @@ on:delete={handleItemDelete} itineraryItem={item} on:removeFromItinerary={handleRemoveItineraryItem} + on:moveToGlobal={(e) => moveItemToGlobal(e.detail.type, e.detail.id)} {user} {collection} compact={true} + on:changeDay={(e) => + handleOpenDayPickerForItem( + e.detail.type, + e.detail.item, + e.detail.forcePicker, + day.date + )} /> {:else if objectType === 'transportation'} moveItemToGlobal(e.detail.type, e.detail.id)} + on:changeDay={(e) => + handleOpenDayPickerForItem( + e.detail.type, + e.detail.item, + e.detail.forcePicker + )} /> {:else if objectType === 'lodging'} moveItemToGlobal(e.detail.type, e.detail.id)} + on:changeDay={(e) => + handleOpenDayPickerForItem( + e.detail.type, + e.detail.item, + e.detail.forcePicker + )} /> {:else if objectType === 'note'} @@ -1831,6 +2101,13 @@ itineraryItem={item} on:removeFromItinerary={handleRemoveItineraryItem} on:edit={handleEditNote} + on:moveToGlobal={(e) => moveItemToGlobal(e.detail.type, e.detail.id)} + on:changeDay={(e) => + handleOpenDayPickerForItem( + e.detail.type, + e.detail.item, + e.detail.forcePicker + )} /> {:else if objectType === 'checklist'} @@ -1842,6 +2119,13 @@ itineraryItem={item} on:removeFromItinerary={handleRemoveItineraryItem} on:edit={handleEditChecklist} + on:moveToGlobal={(e) => moveItemToGlobal(e.detail.type, e.detail.id)} + on:changeDay={(e) => + handleOpenDayPickerForItem( + e.detail.type, + e.detail.item, + e.detail.forcePicker + )} /> {/if} diff --git a/frontend/src/lib/components/collections/CollectionStats.svelte b/frontend/src/lib/components/collections/CollectionStats.svelte index 07c8d87d..f9d114f8 100644 --- a/frontend/src/lib/components/collections/CollectionStats.svelte +++ b/frontend/src/lib/components/collections/CollectionStats.svelte @@ -12,7 +12,7 @@ // @ts-ignore import { DateTime } from 'luxon'; // lodging icons and helpers - import { LODGING_TYPES_ICONS } from '$lib'; + import { LODGING_TYPES_ICONS, getActivityIcon, SPORT_TYPE_CHOICES } from '$lib'; export let collection: Collection; export let user: User | null = null; @@ -270,6 +270,12 @@ return s.charAt(0).toUpperCase() + s.slice(1); } + function getSportLabel(key: string): string { + const found = (SPORT_TYPE_CHOICES as Array).find((a) => a.key === key); + if (found) return found.label; + return String(key).replace(/([a-z])([A-Z])/g, '$1 $2'); + } + $: distanceByTransportType = (() => { const types = new Map(); transportSegments.forEach((segment) => { @@ -566,9 +572,11 @@

    Sport Types

    {#each sportTypes as [sport, count]} - - {sport} ({count}) - +
    + {getActivityIcon(sport)} + {getSportLabel(sport)} + {count} +
    {/each}
    diff --git a/frontend/src/lib/components/collections/ItineraryDayPickModal.svelte b/frontend/src/lib/components/collections/ItineraryDayPickModal.svelte index 03ead218..de5025f6 100644 --- a/frontend/src/lib/components/collections/ItineraryDayPickModal.svelte +++ b/frontend/src/lib/components/collections/ItineraryDayPickModal.svelte @@ -8,11 +8,15 @@ export let isOpen: boolean = false; export let days: Array<{ date: string; displayDate: string; items: any[] }> = []; export let itemName: string = 'Item'; + export let scheduledDates: string[] = []; + // Optional: source visit info when moving a dated location const dispatch = createEventDispatcher(); + let deleteSourceVisit: boolean = false; + function handleDaySelect(dayDate: string, updateDate: boolean) { - dispatch('daySelected', { date: dayDate, updateDate }); + dispatch('daySelected', { date: dayDate, updateDate, deleteSourceVisit }); isOpen = false; } @@ -68,6 +72,7 @@ {@const weekday = DateTime.fromISO(day.date).toFormat('ccc')} {@const dayOfMonth = DateTime.fromISO(day.date).toFormat('d')} {@const monthAbbrev = DateTime.fromISO(day.date).toFormat('LLL')} + {@const isScheduled = scheduledDates?.includes(day.date)}
    Day {dayNumber} of {totalDays} + {#if isScheduled} + Already scheduled + {/if}
    {day.displayDate}
    @@ -97,19 +107,13 @@
    -
    diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index acc6db77..05b2228d 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -1104,7 +1104,9 @@ "item_not_found": "Item not found", "staying_overnight": "Staying overnight", "unscheduled_items": "Unscheduled Items", - "unscheduled_items_desc": "These items are linked to this trip but haven't been added to a specific day yet." + "unscheduled_items_desc": "These items are linked to this trip but haven't been added to a specific day yet.", + "change_day": "Change Day", + "move_to_trip_wide": "Move Trip Wide" }, "common": { "show_less": "Hide details",