Skip to content

Commit 1b1d801

Browse files
committed
Refactor itinerary management and UI components
- Updated ItineraryViewSet to handle visit updates and creations more efficiently, preserving visit IDs when moving between days. - Enhanced ChecklistCard, LodgingCard, TransportationCard, and NoteCard to include a new "Change Day" option in the actions menu. - Improved user experience in CollectionItineraryPlanner by tracking specific itinerary items being moved and ensuring only the relevant entries are deleted. - Added new location sharing options in LodgingCard and TransportationCard for Apple Maps, Google Maps, and OpenStreetMap. - Updated translations in en.json for consistency and clarity. - Minor UI adjustments for better accessibility and usability across various components.
1 parent f315f85 commit 1b1d801

13 files changed

Lines changed: 423 additions & 157 deletions

File tree

backend/server/adventures/views/itinerary_view.py

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -152,32 +152,49 @@ def parse_bounds(val):
152152
new_start = None
153153
new_end = None
154154

155-
# If we have valid bounds, update an existing Visit when provided, else create if none overlaps.
156-
# This keeps linked data (attachments, comments) on the same Visit object.
155+
# Update existing visit or create new one
156+
# When moving between days, update the existing visit to preserve visit ID and data
157157
if new_start and new_end:
158158
source_visit_id = data.get('source_visit_id')
159+
160+
# If source visit provided, update it
159161
if source_visit_id:
160162
try:
161163
source_visit = Visit.objects.get(id=source_visit_id, location=content_object)
162164
source_visit.start_date = new_start
163165
source_visit.end_date = new_end
164166
source_visit.save(update_fields=['start_date', 'end_date'])
165167
except Visit.DoesNotExist:
166-
# Fall back to create-or-skip logic below
168+
# Fall back to create logic below
167169
pass
168-
169-
if not data.get('source_visit_id'):
170-
# Overlap condition: existing.start_date <= new_end AND existing.end_date >= new_start
171-
overlap_q = Q(start_date__lte=new_end) & Q(end_date__gte=new_start)
172-
existing = Visit.objects.filter(location=content_object).filter(overlap_q)
173-
if not existing.exists():
174-
Visit.objects.create(
175-
location=content_object,
176-
start_date=new_start,
177-
end_date=new_end,
178-
notes="Created from itinerary planning"
179-
)
180-
# else: an overlapping visit already exists — skip creating a duplicate
170+
171+
# If no source visit or update failed, check for overlapping visits
172+
if not source_visit_id:
173+
# Check for exact match to avoid duplicates
174+
exact_match = Visit.objects.filter(
175+
location=content_object,
176+
start_date=new_start,
177+
end_date=new_end
178+
).exists()
179+
180+
if not exact_match:
181+
# Check for any overlapping visits
182+
overlap_q = Q(start_date__lte=new_end) & Q(end_date__gte=new_start)
183+
existing = Visit.objects.filter(location=content_object).filter(overlap_q).first()
184+
185+
if existing:
186+
# Update existing overlapping visit
187+
existing.start_date = new_start
188+
existing.end_date = new_end
189+
existing.save(update_fields=['start_date', 'end_date'])
190+
else:
191+
# Create new visit
192+
Visit.objects.create(
193+
location=content_object,
194+
start_date=new_start,
195+
end_date=new_end,
196+
notes="Created from itinerary planning"
197+
)
181198
else:
182199
# For other item types, update their date field and preserve duration
183200
if content_type_val == 'transportation':
@@ -219,9 +236,9 @@ def parse_bounds(val):
219236
# Apply same duration to new check_in
220237
new_check_out = new_check_in + duration
221238
else:
222-
# No original check_out, set to same as check_in
239+
# No original dates: check_in at midnight on selected day, check_out at midnight next day
223240
new_check_in = datetime.datetime.combine(parse_date(clean_date), datetime.time.min)
224-
new_check_out = new_check_in
241+
new_check_out = new_check_in + datetime.timedelta(days=1)
225242

226243
content_object.check_in = new_check_in
227244
content_object.check_out = new_check_out
@@ -334,12 +351,16 @@ def destroy(self, request, *args, **kwargs):
334351
335352
When removing a location from the itinerary, any PLANNED visits (future visits) at
336353
that location on the same date as the itinerary item should also be removed.
354+
355+
If preserve_visits=true query parameter is provided, visits will NOT be deleted.
356+
This is useful when moving items to global/trip context where we want to keep the visits.
337357
"""
338358
instance = self.get_object()
359+
preserve_visits = request.query_params.get('preserve_visits', 'false').lower() == 'true'
339360

340361
# Check if this is a location type itinerary item
341362
location_ct = ContentType.objects.get_for_model(Location)
342-
if instance.content_type == location_ct and instance.object_id:
363+
if instance.content_type == location_ct and instance.object_id and not preserve_visits:
343364
try:
344365
location = Location.objects.get(id=instance.object_id)
345366
itinerary_date = instance.date
@@ -349,14 +370,11 @@ def destroy(self, request, *args, **kwargs):
349370
if isinstance(itinerary_date, str):
350371
itinerary_date = parse_date(itinerary_date)
351372

352-
# Find visits at this location on this date that are in the future (planned visits)
353-
# A visit is considered "planned" if its start_date is in the future
354-
now = timezone.now()
355-
373+
# Find and delete visits at this location on this date
374+
# When removing from itinerary, we remove the associated visit
356375
visits_to_delete = Visit.objects.filter(
357376
location=location,
358-
start_date__date=itinerary_date,
359-
start_date__gt=now # Only delete future/planned visits
377+
start_date__date=itinerary_date
360378
)
361379

362380
deleted_count = visits_to_delete.count()

frontend/src/lib/components/cards/ChecklistCard.svelte

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -283,20 +283,24 @@
283283
{$t('itinerary.move_to_trip_context') || 'Move to Trip Context'}
284284
</button>
285285
</li>
286+
<li>
287+
<button on:click={() => changeDay()} class=" flex items-center gap-2">
288+
<Calendar class="w-4 h-4 text" />
289+
{$t('itinerary.change_day')}
290+
</button>
291+
</li>
286292
{/if}
287-
<li>
288-
<button on:click={() => changeDay()} class=" flex items-center gap-2">
289-
<Calendar class="w-4 h-4 text" />
290-
{$t('itinerary.change_day')}
291-
</button>
292-
</li>
293293
<li>
294294
<button
295295
on:click={() => removeFromItinerary()}
296296
class="text-error flex items-center gap-2"
297297
>
298298
<CalendarRemove class="w-4 h-4 text-error" />
299-
{$t('itinerary.remove_from_itinerary')}
299+
{#if itineraryItem.is_global}
300+
{$t('itinerary.remove_from_trip_context') || 'Remove from Trip Context'}
301+
{:else}
302+
{$t('itinerary.remove_from_itinerary')}
303+
{/if}
300304
</button>
301305
</li>
302306
{/if}

frontend/src/lib/components/cards/LocationCard.svelte

Lines changed: 116 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts">
2-
import { createEventDispatcher } from 'svelte';
2+
import { createEventDispatcher, onMount } from 'svelte';
33
import { goto } from '$app/navigation';
44
import type { Location, Collection, User } from '$lib/types';
55
const dispatch = createEventDispatcher();
@@ -40,6 +40,31 @@
4040
let isCollectionModalOpen: boolean = false;
4141
let isWarningModalOpen: boolean = false;
4242
let copied: boolean = false;
43+
let isActionsMenuOpen: boolean = false;
44+
let actionsMenuRef: HTMLDivElement | null = null;
45+
const ACTIONS_CLOSE_EVENT = 'location-card-close-actions';
46+
const handleCloseEvent = () => (isActionsMenuOpen = false);
47+
48+
function handleDocumentClick(event: MouseEvent) {
49+
if (!isActionsMenuOpen) return;
50+
const target = event.target as Node | null;
51+
if (actionsMenuRef && target && !actionsMenuRef.contains(target)) {
52+
isActionsMenuOpen = false;
53+
}
54+
}
55+
56+
function closeAllLocationMenus() {
57+
window.dispatchEvent(new CustomEvent(ACTIONS_CLOSE_EVENT));
58+
}
59+
60+
onMount(() => {
61+
document.addEventListener('click', handleDocumentClick);
62+
window.addEventListener(ACTIONS_CLOSE_EVENT, handleCloseEvent);
63+
return () => {
64+
document.removeEventListener('click', handleDocumentClick);
65+
window.removeEventListener(ACTIONS_CLOSE_EVENT, handleCloseEvent);
66+
};
67+
});
4368
4469
async function copyLink() {
4570
try {
@@ -326,23 +351,50 @@
326351
</button>
327352
{#if !readOnly}
328353
{#if (adventure.user && adventure.user.uuid == user?.uuid) || (collection && user && collection.shared_with?.includes(user.uuid)) || (collection && user && collection.user == user.uuid)}
329-
<details class="dropdown dropdown-end relative z-50">
330-
<summary class="btn btn-square btn-sm p-1 text-base-content">
354+
<div
355+
class="dropdown dropdown-end relative z-50"
356+
class:dropdown-open={isActionsMenuOpen}
357+
bind:this={actionsMenuRef}
358+
>
359+
<button
360+
type="button"
361+
class="btn btn-square btn-sm p-1 text-base-content"
362+
aria-haspopup="menu"
363+
aria-label={$t('adventures.location_actions') || 'Location actions'}
364+
on:click|stopPropagation={() => {
365+
if (isActionsMenuOpen) {
366+
isActionsMenuOpen = false;
367+
return;
368+
}
369+
closeAllLocationMenus();
370+
isActionsMenuOpen = true;
371+
}}
372+
>
331373
<DotsHorizontal class="w-5 h-5" />
332-
</summary>
374+
</button>
333375
<ul
376+
tabindex="-1"
334377
class="dropdown-content menu bg-base-100 rounded-box z-[9999] w-52 p-2 shadow-lg border border-base-300"
335378
>
336379
<li>
337-
<button on:click={editAdventure} class="flex items-center gap-2">
380+
<button
381+
on:click={() => {
382+
isActionsMenuOpen = false;
383+
editAdventure();
384+
}}
385+
class="flex items-center gap-2"
386+
>
338387
<FileDocumentEdit class="w-4 h-4" />
339388
{$t('adventures.edit_location')}
340389
</button>
341390
</li>
342391
{#if user?.uuid == adventure.user?.uuid}
343392
<li>
344393
<button
345-
on:click={() => (isCollectionModalOpen = true)}
394+
on:click={() => {
395+
isActionsMenuOpen = false;
396+
isCollectionModalOpen = true;
397+
}}
346398
class="flex items-center gap-2"
347399
>
348400
<Plus class="w-4 h-4" />
@@ -352,8 +404,10 @@
352404
{:else if collection && user && collection.user == user.uuid}
353405
<li>
354406
<button
355-
on:click={() =>
356-
removeFromCollection(new CustomEvent('unlink', { detail: collection.id }))}
407+
on:click={() => {
408+
isActionsMenuOpen = false;
409+
removeFromCollection(new CustomEvent('unlink', { detail: collection.id }));
410+
}}
357411
class="flex items-center gap-2"
358412
>
359413
<LinkVariantRemove class="w-4 h-4" />
@@ -364,7 +418,13 @@
364418

365419
{#if adventure.is_public}
366420
<li>
367-
<button on:click={copyLink} class="flex items-center gap-2">
421+
<button
422+
on:click={() => {
423+
isActionsMenuOpen = false;
424+
copyLink();
425+
}}
426+
class="flex items-center gap-2"
427+
>
368428
{#if copied}
369429
<Check class="w-4 h-4 text-success" />
370430
<span>{$t('adventures.link_copied')}</span>
@@ -381,30 +441,55 @@
381441
{#if !itineraryItem.is_global}
382442
<li>
383443
<button
384-
on:click={() =>
385-
dispatch('moveToGlobal', { type: 'location', id: adventure.id })}
444+
on:click={() => {
445+
isActionsMenuOpen = false;
446+
dispatch('moveToGlobal', { type: 'location', id: adventure.id });
447+
}}
386448
class=" flex items-center gap-2"
387449
>
388450
<Globe class="w-4 h-4" />
389451
{$t('itinerary.move_to_trip_context') || 'Move to Trip Context'}
390452
</button>
391453
</li>
454+
<li>
455+
<button
456+
on:click={() => {
457+
isActionsMenuOpen = false;
458+
changeDay();
459+
}}
460+
class=" flex items-center gap-2"
461+
>
462+
<Calendar class="w-4 h-4" />
463+
{$t('itinerary.change_day')}
464+
</button>
465+
</li>
466+
<li>
467+
<button
468+
on:click={() => {
469+
isActionsMenuOpen = false;
470+
removeFromItinerary();
471+
}}
472+
class="text-error flex items-center gap-2"
473+
>
474+
<CalendarRemove class="w-4 h-4 text-error" />
475+
{$t('itinerary.remove_from_itinerary')}
476+
</button>
477+
</li>
478+
{/if}
479+
{#if itineraryItem.is_global}
480+
<li>
481+
<button
482+
on:click={() => {
483+
isActionsMenuOpen = false;
484+
removeFromItinerary();
485+
}}
486+
class="text-error flex items-center gap-2"
487+
>
488+
<CalendarRemove class="w-4 h-4 text-error" />
489+
{$t('itinerary.remove_from_trip_context')}
490+
</button>
491+
</li>
392492
{/if}
393-
<li>
394-
<button on:click={() => changeDay()} class=" flex items-center gap-2">
395-
<Calendar class="w-4 h-4" />
396-
{$t('itinerary.change_day')}
397-
</button>
398-
</li>
399-
<li>
400-
<button
401-
on:click={() => removeFromItinerary()}
402-
class="text-error flex items-center gap-2"
403-
>
404-
<CalendarRemove class="w-4 h-4 text-error" />
405-
{$t('itinerary.remove_from_itinerary')}
406-
</button>
407-
</li>
408493
{/if}
409494

410495
{#if user.uuid == adventure.user?.uuid}
@@ -414,15 +499,18 @@
414499
id="delete_adventure"
415500
data-umami-event="Delete Adventure"
416501
class="text-error flex items-center gap-2"
417-
on:click={() => (isWarningModalOpen = true)}
502+
on:click={() => {
503+
isActionsMenuOpen = false;
504+
isWarningModalOpen = true;
505+
}}
418506
>
419507
<TrashCan class="w-4 h-4" />
420508
{$t('adventures.delete')}
421509
</button>
422510
</li>
423511
{/if}
424512
</ul>
425-
</details>
513+
</div>
426514
{/if}
427515
{/if}
428516
</div>

frontend/src/lib/components/cards/LodgingCard.svelte

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -209,20 +209,24 @@
209209
{$t('itinerary.move_to_trip_context') || 'Move to Trip Context'}
210210
</button>
211211
</li>
212+
<li>
213+
<button on:click={() => changeDay()} class=" flex items-center gap-2">
214+
<Calendar class="w-4 h-4 text" />
215+
{$t('itinerary.change_day')}
216+
</button>
217+
</li>
212218
{/if}
213-
<li>
214-
<button on:click={() => changeDay()} class=" flex items-center gap-2">
215-
<Calendar class="w-4 h-4 text" />
216-
{$t('itinerary.change_day')}
217-
</button>
218-
</li>
219219
<li>
220220
<button
221221
on:click={() => removeFromItinerary()}
222222
class="text-error flex items-center gap-2"
223223
>
224224
<CalendarRemove class="w-4 h-4 text-error" />
225-
{$t('itinerary.remove_from_itinerary')}
225+
{#if itineraryItem.is_global}
226+
{$t('itinerary.remove_from_trip_context') || 'Remove from Trip Context'}
227+
{:else}
228+
{$t('itinerary.remove_from_itinerary')}
229+
{/if}
226230
</button>
227231
</li>
228232
{/if}

0 commit comments

Comments
 (0)