mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2026-05-18 11:47:04 -04:00
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.
This commit is contained in:
@@ -152,10 +152,12 @@ class ItineraryViewSet(viewsets.ModelViewSet):
|
||||
new_start = None
|
||||
new_end = None
|
||||
|
||||
# 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.
|
||||
# Update existing visit or create new one
|
||||
# When moving between days, update the existing visit to preserve visit ID and data
|
||||
if new_start and new_end:
|
||||
source_visit_id = data.get('source_visit_id')
|
||||
|
||||
# If source visit provided, update it
|
||||
if source_visit_id:
|
||||
try:
|
||||
source_visit = Visit.objects.get(id=source_visit_id, location=content_object)
|
||||
@@ -163,21 +165,36 @@ class ItineraryViewSet(viewsets.ModelViewSet):
|
||||
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
|
||||
# Fall back to create 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
|
||||
|
||||
# If no source visit or update failed, check for overlapping visits
|
||||
if not source_visit_id:
|
||||
# Check for exact match to avoid duplicates
|
||||
exact_match = Visit.objects.filter(
|
||||
location=content_object,
|
||||
start_date=new_start,
|
||||
end_date=new_end
|
||||
).exists()
|
||||
|
||||
if not exact_match:
|
||||
# Check for any overlapping visits
|
||||
overlap_q = Q(start_date__lte=new_end) & Q(end_date__gte=new_start)
|
||||
existing = Visit.objects.filter(location=content_object).filter(overlap_q).first()
|
||||
|
||||
if existing:
|
||||
# Update existing overlapping visit
|
||||
existing.start_date = new_start
|
||||
existing.end_date = new_end
|
||||
existing.save(update_fields=['start_date', 'end_date'])
|
||||
else:
|
||||
# Create new visit
|
||||
Visit.objects.create(
|
||||
location=content_object,
|
||||
start_date=new_start,
|
||||
end_date=new_end,
|
||||
notes="Created from itinerary planning"
|
||||
)
|
||||
else:
|
||||
# For other item types, update their date field and preserve duration
|
||||
if content_type_val == 'transportation':
|
||||
@@ -219,9 +236,9 @@ class ItineraryViewSet(viewsets.ModelViewSet):
|
||||
# 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
|
||||
# No original dates: check_in at midnight on selected day, check_out at midnight next day
|
||||
new_check_in = datetime.datetime.combine(parse_date(clean_date), datetime.time.min)
|
||||
new_check_out = new_check_in
|
||||
new_check_out = new_check_in + datetime.timedelta(days=1)
|
||||
|
||||
content_object.check_in = new_check_in
|
||||
content_object.check_out = new_check_out
|
||||
@@ -334,12 +351,16 @@ class ItineraryViewSet(viewsets.ModelViewSet):
|
||||
|
||||
When removing a location from the itinerary, any PLANNED visits (future visits) at
|
||||
that location on the same date as the itinerary item should also be removed.
|
||||
|
||||
If preserve_visits=true query parameter is provided, visits will NOT be deleted.
|
||||
This is useful when moving items to global/trip context where we want to keep the visits.
|
||||
"""
|
||||
instance = self.get_object()
|
||||
preserve_visits = request.query_params.get('preserve_visits', 'false').lower() == 'true'
|
||||
|
||||
# Check if this is a location type itinerary item
|
||||
location_ct = ContentType.objects.get_for_model(Location)
|
||||
if instance.content_type == location_ct and instance.object_id:
|
||||
if instance.content_type == location_ct and instance.object_id and not preserve_visits:
|
||||
try:
|
||||
location = Location.objects.get(id=instance.object_id)
|
||||
itinerary_date = instance.date
|
||||
@@ -349,14 +370,11 @@ class ItineraryViewSet(viewsets.ModelViewSet):
|
||||
if isinstance(itinerary_date, str):
|
||||
itinerary_date = parse_date(itinerary_date)
|
||||
|
||||
# Find visits at this location on this date that are in the future (planned visits)
|
||||
# A visit is considered "planned" if its start_date is in the future
|
||||
now = timezone.now()
|
||||
|
||||
# Find and delete visits at this location on this date
|
||||
# When removing from itinerary, we remove the associated visit
|
||||
visits_to_delete = Visit.objects.filter(
|
||||
location=location,
|
||||
start_date__date=itinerary_date,
|
||||
start_date__gt=now # Only delete future/planned visits
|
||||
start_date__date=itinerary_date
|
||||
)
|
||||
|
||||
deleted_count = visits_to_delete.count()
|
||||
|
||||
@@ -283,20 +283,24 @@
|
||||
{$t('itinerary.move_to_trip_context') || 'Move to Trip Context'}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button on:click={() => changeDay()} class=" flex items-center gap-2">
|
||||
<Calendar class="w-4 h-4 text" />
|
||||
{$t('itinerary.change_day')}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
<li>
|
||||
<button on:click={() => changeDay()} class=" flex items-center gap-2">
|
||||
<Calendar class="w-4 h-4 text" />
|
||||
{$t('itinerary.change_day')}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
on:click={() => removeFromItinerary()}
|
||||
class="text-error flex items-center gap-2"
|
||||
>
|
||||
<CalendarRemove class="w-4 h-4 text-error" />
|
||||
{$t('itinerary.remove_from_itinerary')}
|
||||
{#if itineraryItem.is_global}
|
||||
{$t('itinerary.remove_from_trip_context') || 'Remove from Trip Context'}
|
||||
{:else}
|
||||
{$t('itinerary.remove_from_itinerary')}
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Location, Collection, User } from '$lib/types';
|
||||
const dispatch = createEventDispatcher();
|
||||
@@ -40,6 +40,31 @@
|
||||
let isCollectionModalOpen: boolean = false;
|
||||
let isWarningModalOpen: boolean = false;
|
||||
let copied: boolean = false;
|
||||
let isActionsMenuOpen: boolean = false;
|
||||
let actionsMenuRef: HTMLDivElement | null = null;
|
||||
const ACTIONS_CLOSE_EVENT = 'location-card-close-actions';
|
||||
const handleCloseEvent = () => (isActionsMenuOpen = false);
|
||||
|
||||
function handleDocumentClick(event: MouseEvent) {
|
||||
if (!isActionsMenuOpen) return;
|
||||
const target = event.target as Node | null;
|
||||
if (actionsMenuRef && target && !actionsMenuRef.contains(target)) {
|
||||
isActionsMenuOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeAllLocationMenus() {
|
||||
window.dispatchEvent(new CustomEvent(ACTIONS_CLOSE_EVENT));
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener('click', handleDocumentClick);
|
||||
window.addEventListener(ACTIONS_CLOSE_EVENT, handleCloseEvent);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleDocumentClick);
|
||||
window.removeEventListener(ACTIONS_CLOSE_EVENT, handleCloseEvent);
|
||||
};
|
||||
});
|
||||
|
||||
async function copyLink() {
|
||||
try {
|
||||
@@ -326,15 +351,39 @@
|
||||
</button>
|
||||
{#if !readOnly}
|
||||
{#if (adventure.user && adventure.user.uuid == user?.uuid) || (collection && user && collection.shared_with?.includes(user.uuid)) || (collection && user && collection.user == user.uuid)}
|
||||
<details class="dropdown dropdown-end relative z-50">
|
||||
<summary class="btn btn-square btn-sm p-1 text-base-content">
|
||||
<div
|
||||
class="dropdown dropdown-end relative z-50"
|
||||
class:dropdown-open={isActionsMenuOpen}
|
||||
bind:this={actionsMenuRef}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-square btn-sm p-1 text-base-content"
|
||||
aria-haspopup="menu"
|
||||
aria-label={$t('adventures.location_actions') || 'Location actions'}
|
||||
on:click|stopPropagation={() => {
|
||||
if (isActionsMenuOpen) {
|
||||
isActionsMenuOpen = false;
|
||||
return;
|
||||
}
|
||||
closeAllLocationMenus();
|
||||
isActionsMenuOpen = true;
|
||||
}}
|
||||
>
|
||||
<DotsHorizontal class="w-5 h-5" />
|
||||
</summary>
|
||||
</button>
|
||||
<ul
|
||||
tabindex="-1"
|
||||
class="dropdown-content menu bg-base-100 rounded-box z-[9999] w-52 p-2 shadow-lg border border-base-300"
|
||||
>
|
||||
<li>
|
||||
<button on:click={editAdventure} class="flex items-center gap-2">
|
||||
<button
|
||||
on:click={() => {
|
||||
isActionsMenuOpen = false;
|
||||
editAdventure();
|
||||
}}
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<FileDocumentEdit class="w-4 h-4" />
|
||||
{$t('adventures.edit_location')}
|
||||
</button>
|
||||
@@ -342,7 +391,10 @@
|
||||
{#if user?.uuid == adventure.user?.uuid}
|
||||
<li>
|
||||
<button
|
||||
on:click={() => (isCollectionModalOpen = true)}
|
||||
on:click={() => {
|
||||
isActionsMenuOpen = false;
|
||||
isCollectionModalOpen = true;
|
||||
}}
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
@@ -352,8 +404,10 @@
|
||||
{:else if collection && user && collection.user == user.uuid}
|
||||
<li>
|
||||
<button
|
||||
on:click={() =>
|
||||
removeFromCollection(new CustomEvent('unlink', { detail: collection.id }))}
|
||||
on:click={() => {
|
||||
isActionsMenuOpen = false;
|
||||
removeFromCollection(new CustomEvent('unlink', { detail: collection.id }));
|
||||
}}
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<LinkVariantRemove class="w-4 h-4" />
|
||||
@@ -364,7 +418,13 @@
|
||||
|
||||
{#if adventure.is_public}
|
||||
<li>
|
||||
<button on:click={copyLink} class="flex items-center gap-2">
|
||||
<button
|
||||
on:click={() => {
|
||||
isActionsMenuOpen = false;
|
||||
copyLink();
|
||||
}}
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
{#if copied}
|
||||
<Check class="w-4 h-4 text-success" />
|
||||
<span>{$t('adventures.link_copied')}</span>
|
||||
@@ -381,30 +441,55 @@
|
||||
{#if !itineraryItem.is_global}
|
||||
<li>
|
||||
<button
|
||||
on:click={() =>
|
||||
dispatch('moveToGlobal', { type: 'location', id: adventure.id })}
|
||||
on:click={() => {
|
||||
isActionsMenuOpen = false;
|
||||
dispatch('moveToGlobal', { type: 'location', id: adventure.id });
|
||||
}}
|
||||
class=" flex items-center gap-2"
|
||||
>
|
||||
<Globe class="w-4 h-4" />
|
||||
{$t('itinerary.move_to_trip_context') || 'Move to Trip Context'}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
on:click={() => {
|
||||
isActionsMenuOpen = false;
|
||||
changeDay();
|
||||
}}
|
||||
class=" flex items-center gap-2"
|
||||
>
|
||||
<Calendar class="w-4 h-4" />
|
||||
{$t('itinerary.change_day')}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
on:click={() => {
|
||||
isActionsMenuOpen = false;
|
||||
removeFromItinerary();
|
||||
}}
|
||||
class="text-error flex items-center gap-2"
|
||||
>
|
||||
<CalendarRemove class="w-4 h-4 text-error" />
|
||||
{$t('itinerary.remove_from_itinerary')}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
{#if itineraryItem.is_global}
|
||||
<li>
|
||||
<button
|
||||
on:click={() => {
|
||||
isActionsMenuOpen = false;
|
||||
removeFromItinerary();
|
||||
}}
|
||||
class="text-error flex items-center gap-2"
|
||||
>
|
||||
<CalendarRemove class="w-4 h-4 text-error" />
|
||||
{$t('itinerary.remove_from_trip_context')}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
<li>
|
||||
<button on:click={() => changeDay()} class=" flex items-center gap-2">
|
||||
<Calendar class="w-4 h-4" />
|
||||
{$t('itinerary.change_day')}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
on:click={() => removeFromItinerary()}
|
||||
class="text-error flex items-center gap-2"
|
||||
>
|
||||
<CalendarRemove class="w-4 h-4 text-error" />
|
||||
{$t('itinerary.remove_from_itinerary')}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
|
||||
{#if user.uuid == adventure.user?.uuid}
|
||||
@@ -414,7 +499,10 @@
|
||||
id="delete_adventure"
|
||||
data-umami-event="Delete Adventure"
|
||||
class="text-error flex items-center gap-2"
|
||||
on:click={() => (isWarningModalOpen = true)}
|
||||
on:click={() => {
|
||||
isActionsMenuOpen = false;
|
||||
isWarningModalOpen = true;
|
||||
}}
|
||||
>
|
||||
<TrashCan class="w-4 h-4" />
|
||||
{$t('adventures.delete')}
|
||||
@@ -422,7 +510,7 @@
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</details>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -209,20 +209,24 @@
|
||||
{$t('itinerary.move_to_trip_context') || 'Move to Trip Context'}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button on:click={() => changeDay()} class=" flex items-center gap-2">
|
||||
<Calendar class="w-4 h-4 text" />
|
||||
{$t('itinerary.change_day')}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
<li>
|
||||
<button on:click={() => changeDay()} class=" flex items-center gap-2">
|
||||
<Calendar class="w-4 h-4 text" />
|
||||
{$t('itinerary.change_day')}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
on:click={() => removeFromItinerary()}
|
||||
class="text-error flex items-center gap-2"
|
||||
>
|
||||
<CalendarRemove class="w-4 h-4 text-error" />
|
||||
{$t('itinerary.remove_from_itinerary')}
|
||||
{#if itineraryItem.is_global}
|
||||
{$t('itinerary.remove_from_trip_context') || 'Remove from Trip Context'}
|
||||
{:else}
|
||||
{$t('itinerary.remove_from_itinerary')}
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
type="button"
|
||||
class="btn btn-circle btn-ghost btn-sm"
|
||||
on:click={() => (isDetailsOpen = false)}
|
||||
aria-label={$t('adventures.close')}
|
||||
aria-label={$t('about.close')}
|
||||
>
|
||||
<Close class="w-4 h-4" />
|
||||
</button>
|
||||
@@ -198,26 +198,30 @@
|
||||
<li>
|
||||
<button
|
||||
on:click={() => dispatch('moveToGlobal', { type: 'note', id: note.id })}
|
||||
class="text-info flex items-center gap-2"
|
||||
class=" flex items-center gap-2"
|
||||
>
|
||||
<Globe class="w-4 h-4 text-info" />
|
||||
{$t('itinerary.move_to_trip_context') || 'Move to Trip Context'}
|
||||
<Globe class="w-4 h-4 " />
|
||||
{$t('itinerary.move_to_trip_context')}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button on:click={() => changeDay()} class=" flex items-center gap-2">
|
||||
<CalendarRemove class="w-4 h-4 text" />
|
||||
{$t('itinerary.change_day')}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
<li>
|
||||
<button on:click={() => changeDay()} class=" flex items-center gap-2">
|
||||
<CalendarRemove class="w-4 h-4 text" />
|
||||
{$t('itinerary.change_day')}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
on:click={() => removeFromItinerary()}
|
||||
class="text-error flex items-center gap-2"
|
||||
>
|
||||
<CalendarRemove class="w-4 h-4 text-error" />
|
||||
{$t('itinerary.remove_from_itinerary')}
|
||||
{#if itineraryItem.is_global}
|
||||
{$t('itinerary.remove_from_trip_context') || 'Remove from Trip Context'}
|
||||
{:else}
|
||||
{$t('itinerary.remove_from_itinerary')}
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
|
||||
@@ -243,20 +243,24 @@
|
||||
{$t('itinerary.move_to_trip_context') || 'Move to Trip Context'}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button on:click={() => changeDay()} class=" flex items-center gap-2">
|
||||
<Calendar class="w-4 h-4 text" />
|
||||
{$t('itinerary.change_day')}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
<li>
|
||||
<button on:click={() => changeDay()} class=" flex items-center gap-2">
|
||||
<Calendar class="w-4 h-4 text" />
|
||||
{$t('itinerary.change_day')}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
on:click={() => removeFromItinerary()}
|
||||
class="text-error flex items-center gap-2"
|
||||
>
|
||||
<CalendarRemove class="w-4 h-4 text-error" />
|
||||
{$t('itinerary.remove_from_itinerary')}
|
||||
{#if itineraryItem.is_global}
|
||||
{$t('itinerary.remove_from_trip_context') || 'Remove from Trip Context'}
|
||||
{:else}
|
||||
{$t('itinerary.remove_from_itinerary')}
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
|
||||
@@ -174,9 +174,9 @@
|
||||
.filter((it) => it.object_id === objectId && it.date && !it.is_global)
|
||||
.map((it) => it.id);
|
||||
|
||||
// Delete the dated entries
|
||||
// Delete the dated entries, but preserve visits
|
||||
for (const entryId of entriesToRemove) {
|
||||
await fetch(`/api/itineraries/${entryId}`, { method: 'DELETE' });
|
||||
await fetch(`/api/itineraries/${entryId}?preserve_visits=true`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// Add as global if not already there
|
||||
@@ -358,6 +358,7 @@
|
||||
let dayPickItemToAdd: { type: string; item: any } | null = null;
|
||||
let dayPickScheduledDates: string[] = [];
|
||||
let dayPickSourceVisit: { id: string; start_date: string } | null = null;
|
||||
let dayPickSourceItineraryItemId: string | null = null; // Track which specific itinerary item is being moved
|
||||
|
||||
// When opening a "create new item" modal we store the target date here
|
||||
let pendingAddDate: string | null = null;
|
||||
@@ -1064,6 +1065,16 @@
|
||||
.filter((it) => it.object_id === item.id && it.date && !it.is_global)
|
||||
.map((it) => it.date as string);
|
||||
|
||||
// If moving from a specific itinerary date, track which itinerary item it is
|
||||
if (currentItineraryDate) {
|
||||
const sourceItineraryItem = (collection.itinerary || []).find(
|
||||
(it) => it.object_id === item.id && it.date === currentItineraryDate && !it.is_global
|
||||
);
|
||||
dayPickSourceItineraryItemId = sourceItineraryItem?.id || null;
|
||||
} else {
|
||||
dayPickSourceItineraryItemId = null;
|
||||
}
|
||||
|
||||
if (type === 'location') {
|
||||
// For locations, prefer the visit matching the current itinerary date
|
||||
let matchedVisit = null;
|
||||
@@ -1225,34 +1236,38 @@
|
||||
}
|
||||
}
|
||||
|
||||
// If we are moving the item (user chose to update the underlying date or an existing dated entry exists),
|
||||
// remove old dated itinerary entries for this object on other dates.
|
||||
// This ensures the item is moved rather than duplicated across days.
|
||||
const toRemove = existingDatedItems.filter((it) => it.date !== selectedDate);
|
||||
for (const oldItem of toRemove) {
|
||||
// Only remove the specific itinerary entry being moved, not all occurrences
|
||||
if (updateDate && dayPickSourceItineraryItemId) {
|
||||
// Only delete the specific itinerary item that was being moved
|
||||
try {
|
||||
await fetch(`/api/itineraries/${oldItem.id}`, { method: 'DELETE' });
|
||||
await fetch(`/api/itineraries/${dayPickSourceItineraryItemId}`, { method: 'DELETE' });
|
||||
// Update local state to reflect removal
|
||||
collection.itinerary = (collection.itinerary || []).filter(
|
||||
(it) => it.id !== dayPickSourceItineraryItemId
|
||||
);
|
||||
days = groupItemsByDay(collection);
|
||||
} catch (e) {
|
||||
console.error('Failed to remove old itinerary item', oldItem.id, e);
|
||||
console.error('Failed to remove source itinerary item', dayPickSourceItineraryItemId, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (toRemove.length > 0) {
|
||||
// Update local state to reflect removals
|
||||
collection.itinerary = (collection.itinerary || []).filter(
|
||||
(it) => !toRemove.some((r) => r.id === it.id)
|
||||
);
|
||||
days = groupItemsByDay(collection);
|
||||
}
|
||||
} finally {
|
||||
// Reset state regardless of success/failure
|
||||
dayPickItemToAdd = null;
|
||||
dayPickScheduledDates = [];
|
||||
dayPickSourceVisit = null;
|
||||
dayPickSourceItineraryItemId = null;
|
||||
isDayPickModalOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: validate UUID format to avoid sending temporary IDs to backend
|
||||
function isValidUUID(id: string | undefined | null): boolean {
|
||||
if (!id) return false;
|
||||
const uuidRegex =
|
||||
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/;
|
||||
return uuidRegex.test(id);
|
||||
}
|
||||
|
||||
// Add an itinerary item locally and attempt to persist to backend
|
||||
async function addItineraryItemForObject(
|
||||
objectType: string,
|
||||
@@ -1289,10 +1304,10 @@
|
||||
date: dateISO,
|
||||
order,
|
||||
update_item_date: updateItemDate,
|
||||
// Prefer updating an existing Visit when moving a location
|
||||
// Pass source visit ID so backend can update the existing visit
|
||||
source_visit_id:
|
||||
objectType === 'location' && updateItemDate && dayPickSourceVisit?.id
|
||||
? dayPickSourceVisit.id
|
||||
objectType === 'location' && updateItemDate && isValidUUID(dayPickSourceVisit?.id)
|
||||
? dayPickSourceVisit!.id
|
||||
: undefined
|
||||
})
|
||||
});
|
||||
@@ -1324,42 +1339,66 @@
|
||||
} else if (updateItemDate) {
|
||||
// Fallback: if server didn't return updated_object, do manual update for other types
|
||||
const isoDate = `${dateISO}T00:00:00`;
|
||||
const nextDayISO = DateTime.fromISO(dateISO).plus({ days: 1 }).toISODate();
|
||||
|
||||
if (objectType === 'location') {
|
||||
// For locations, update existing source visit when available; otherwise append a new visit
|
||||
const sourceId = dayPickSourceVisit?.id;
|
||||
// Shift the existing visit dates locally to match the new itinerary date
|
||||
if (collection.locations) {
|
||||
collection.locations = collection.locations.map((loc) => {
|
||||
if (loc.id !== objectId) return loc;
|
||||
const visits = [...(loc.visits || [])];
|
||||
if (sourceId) {
|
||||
const idx = visits.findIndex((v) => v.id === sourceId);
|
||||
if (idx !== -1) {
|
||||
visits[idx] = {
|
||||
...visits[idx],
|
||||
start_date: `${dateISO}T00:00:00`,
|
||||
end_date: `${dateISO}T23:59:59`
|
||||
};
|
||||
return { ...loc, visits };
|
||||
}
|
||||
|
||||
const visits = loc.visits || [];
|
||||
// Prefer matching by visit id (source_visit) then by old date
|
||||
const sourceId = dayPickSourceVisit?.id;
|
||||
const oldDate = dayPickSourceVisit?.start_date
|
||||
? dayPickSourceVisit.start_date.split('T')[0]
|
||||
: null;
|
||||
let idx = sourceId ? visits.findIndex((v) => v.id === sourceId) : -1;
|
||||
if (idx === -1 && oldDate) {
|
||||
idx = visits.findIndex((v) => v.start_date?.startsWith(oldDate));
|
||||
}
|
||||
|
||||
// No source visit to update; append a new one
|
||||
const newVisit = {
|
||||
id: `temp-visit-${Date.now()}`,
|
||||
location: objectId,
|
||||
start_date: `${dateISO}T00:00:00`,
|
||||
end_date: `${dateISO}T23:59:59`,
|
||||
notes: $t('itinerary.visit_created_via_itinerary'),
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
images: [],
|
||||
attachments: []
|
||||
};
|
||||
return { ...loc, visits: [...(loc.visits || []), newVisit] };
|
||||
if (idx === -1) return loc;
|
||||
|
||||
const v = visits[idx];
|
||||
const startDT = v.start_date ? DateTime.fromISO(v.start_date) : null;
|
||||
const endDT = v.end_date ? DateTime.fromISO(v.end_date) : null;
|
||||
const baseStart = DateTime.fromISO(dateISO);
|
||||
const newStart = startDT
|
||||
? baseStart
|
||||
.set({
|
||||
second: startDT.second,
|
||||
minute: startDT.minute,
|
||||
hour: startDT.hour,
|
||||
millisecond: startDT.millisecond
|
||||
})
|
||||
.toISO()
|
||||
: `${dateISO}T00:00:00`;
|
||||
const newEnd = endDT
|
||||
? DateTime.fromISO(dateISO)
|
||||
.set({
|
||||
second: endDT.second,
|
||||
minute: endDT.minute,
|
||||
hour: endDT.hour,
|
||||
millisecond: endDT.millisecond
|
||||
})
|
||||
.toISO()
|
||||
: `${dateISO}T23:59:59`;
|
||||
|
||||
const nextVisits = [...visits];
|
||||
nextVisits[idx] = { ...v, start_date: newStart, end_date: newEnd };
|
||||
return { ...loc, visits: nextVisits };
|
||||
});
|
||||
}
|
||||
} else if (objectType === 'lodging') {
|
||||
if (collection.lodging) {
|
||||
// Set check_in to selected day, check_out to next day
|
||||
const checkOutDate = DateTime.fromISO(dateISO).plus({ days: 1 }).toISODate();
|
||||
collection.lodging = collection.lodging.map((l) =>
|
||||
l.id === objectId
|
||||
? { ...l, check_in: `${dateISO}T00:00:00`, check_out: `${checkOutDate}T00:00:00` }
|
||||
: l
|
||||
);
|
||||
}
|
||||
} else if (objectType === 'note') {
|
||||
if (collection.notes) {
|
||||
collection.notes = collection.notes.map((n) =>
|
||||
@@ -1558,6 +1597,7 @@
|
||||
dayPickItemToAdd = null;
|
||||
dayPickScheduledDates = [];
|
||||
dayPickSourceVisit = null;
|
||||
dayPickSourceItineraryItemId = null;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
@@ -2129,7 +2169,8 @@
|
||||
handleOpenDayPickerForItem(
|
||||
e.detail.type,
|
||||
e.detail.item,
|
||||
e.detail.forcePicker
|
||||
e.detail.forcePicker,
|
||||
day.date
|
||||
)}
|
||||
/>
|
||||
{:else if objectType === 'lodging'}
|
||||
@@ -2146,7 +2187,8 @@
|
||||
handleOpenDayPickerForItem(
|
||||
e.detail.type,
|
||||
e.detail.item,
|
||||
e.detail.forcePicker
|
||||
e.detail.forcePicker,
|
||||
day.date
|
||||
)}
|
||||
/>
|
||||
{:else if objectType === 'note'}
|
||||
@@ -2164,7 +2206,8 @@
|
||||
handleOpenDayPickerForItem(
|
||||
e.detail.type,
|
||||
e.detail.item,
|
||||
e.detail.forcePicker
|
||||
e.detail.forcePicker,
|
||||
day.date
|
||||
)}
|
||||
/>
|
||||
{:else if objectType === 'checklist'}
|
||||
@@ -2182,7 +2225,8 @@
|
||||
handleOpenDayPickerForItem(
|
||||
e.detail.type,
|
||||
e.detail.item,
|
||||
e.detail.forcePicker
|
||||
e.detail.forcePicker,
|
||||
day.date
|
||||
)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
disabled={isScheduled}
|
||||
on:click={() => handleDaySelect(day.date, true)}
|
||||
>
|
||||
{isScheduled ? 'Already scheduled' : 'Update to this day'}
|
||||
{isScheduled ? 'Already scheduled' : 'Move to this day'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import LodgingCard from '$lib/components/cards/LodgingCard.svelte';
|
||||
import NoteCard from '$lib/components/cards/NoteCard.svelte';
|
||||
import ChecklistCard from '$lib/components/cards/ChecklistCard.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { date, t } from 'svelte-i18n';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
@@ -254,6 +254,7 @@
|
||||
<CalendarBlank class="w-5 h-5 text-primary" />
|
||||
Already added on this day
|
||||
</h4>
|
||||
<p class="text-sm opacity-60 mb-4">These items are already scheduled for this day.</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||
{#each groupedItems.scheduledOnThisDay as { type, item, dates }}
|
||||
<div class="card bg-base-100 border border-base-300 shadow-sm">
|
||||
@@ -282,10 +283,8 @@
|
||||
<ChecklistCard checklist={item} {user} {collection} readOnly={true} />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-xs opacity-70 mb-2">Already on: {(dates || []).join(', ')}</div>
|
||||
<div class="text-2xs opacity-50">
|
||||
Use "Items on other days" or "Add as is" to duplicate onto this date.
|
||||
</div>
|
||||
<div class="text-xs opacity-70 mb-2">Already on this day</div>
|
||||
<button class="btn btn-xs btn-disabled w-full" disabled> Already Added </button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -301,8 +300,8 @@
|
||||
<div class="mb-4">
|
||||
<h4 class="text-lg font-semibold mb-3 opacity-70">Already added on other days</h4>
|
||||
<p class="text-sm opacity-60 mb-4">
|
||||
These items are already in your itinerary on a different date — you can add them again
|
||||
to this day or update their date.
|
||||
These items are scheduled on different dates. Adding them here will update their date or
|
||||
add them as-is.
|
||||
</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||
{#each groupedItems.scheduledOtherDays as { type, item, dates }}
|
||||
@@ -334,13 +333,13 @@
|
||||
</div>
|
||||
<div class="text-xs opacity-70 mb-2">On: {(dates || []).join(', ')}</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
<!-- <button
|
||||
class="btn btn-outline btn-xs flex-1"
|
||||
on:click={() => handleAddItem(type, item.id, false)}>Add to This Day</button
|
||||
>
|
||||
on:click={() => handleAddItem(type, item.id, false)}>Add (Keep Date)</button
|
||||
> -->
|
||||
<button
|
||||
class="btn btn-primary btn-xs flex-1"
|
||||
on:click={() => handleAddItem(type, item.id, true)}>Add & Update Date</button
|
||||
on:click={() => handleAddItem(type, item.id, true)}>Add Here</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -387,17 +386,17 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
<!-- <button
|
||||
class="btn btn-outline btn-xs flex-1"
|
||||
on:click={() => handleAddItem(type, item.id, false)}
|
||||
>
|
||||
Add as is
|
||||
</button>
|
||||
</button> -->
|
||||
<button
|
||||
class="btn btn-primary btn-xs flex-1"
|
||||
on:click={() => handleAddItem(type, item.id, true)}
|
||||
>
|
||||
Add & Update Date
|
||||
Add Here
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export let appVersion = 'v0.12.0-pre-dev-010726';
|
||||
export let appVersion = 'v0.12.0-pre-dev-010826';
|
||||
export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.11.0';
|
||||
export let appTitle = 'AdventureLog';
|
||||
export let copyrightYear = '2023-2026';
|
||||
|
||||
@@ -1196,7 +1196,8 @@
|
||||
"item_singular": "item",
|
||||
"item_plural": "items",
|
||||
"already_scheduled": "Already scheduled",
|
||||
"update_to_this_day": "Update to this day"
|
||||
"update_to_this_day": "Update to this day",
|
||||
"remove_from_trip_context": "Remove from Context"
|
||||
},
|
||||
"common": {
|
||||
"show_less": "Hide details",
|
||||
|
||||
@@ -417,10 +417,38 @@
|
||||
</MapLibre>
|
||||
</div>
|
||||
{#if lodging.location}
|
||||
<p class="mt-4 text-base-content/70 flex items-center gap-2">
|
||||
<MapMarker class="w-5 h-5" />
|
||||
{lodging.location}
|
||||
</p>
|
||||
<div class="rounded-lg p-3 mb-3 bg-gradient-to-br from-primary/10 to-secondary/10">
|
||||
<p class="flex items-center gap-2 text-sm mb-2">
|
||||
<MapMarker class="w-4 h-4" />
|
||||
{lodging.location}
|
||||
</p>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<a
|
||||
class="btn btn-sm btn-outline hover:btn-neutral"
|
||||
href={`https://maps.apple.com/?q=${encodeURIComponent(lodging.location)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
🍎 Apple
|
||||
</a>
|
||||
<a
|
||||
class="btn btn-sm btn-outline hover:btn-accent"
|
||||
href={`https://maps.google.com/?q=${encodeURIComponent(lodging.location)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
🌍 Google
|
||||
</a>
|
||||
<a
|
||||
class="btn btn-sm btn-outline hover:btn-primary"
|
||||
href={`https://www.openstreetmap.org/search?query=${encodeURIComponent(lodging.location)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
🗺️ OSM
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -629,6 +629,78 @@
|
||||
<MapMarker class="w-5 h-5" />
|
||||
{getRouteLabel()}
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
|
||||
{#if transportation.from_location}
|
||||
<div class="rounded-lg p-3 bg-gradient-to-br from-primary/10 to-secondary/10">
|
||||
<p class="flex items-center gap-2 text-sm mb-2">
|
||||
<MapMarker class="w-4 h-4" />
|
||||
{transportation.from_location}
|
||||
</p>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<a
|
||||
class="btn btn-sm btn-outline hover:btn-neutral"
|
||||
href={`https://maps.apple.com/?q=${encodeURIComponent(transportation.from_location)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
🍎 Apple
|
||||
</a>
|
||||
<a
|
||||
class="btn btn-sm btn-outline hover:btn-accent"
|
||||
href={`https://maps.google.com/?q=${encodeURIComponent(transportation.from_location)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
🌍 Google
|
||||
</a>
|
||||
<a
|
||||
class="btn btn-sm btn-outline hover:btn-primary"
|
||||
href={`https://www.openstreetmap.org/search?query=${encodeURIComponent(transportation.from_location)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
🗺️ OSM
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if transportation.to_location}
|
||||
<div class="rounded-lg p-3 bg-gradient-to-br from-primary/10 to-secondary/10">
|
||||
<p class="flex items-center gap-2 text-sm mb-2">
|
||||
<MapMarker class="w-4 h-4" />
|
||||
{transportation.to_location}
|
||||
</p>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<a
|
||||
class="btn btn-sm btn-outline hover:btn-neutral"
|
||||
href={`https://maps.apple.com/?q=${encodeURIComponent(transportation.to_location)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
🍎 Apple
|
||||
</a>
|
||||
<a
|
||||
class="btn btn-sm btn-outline hover:btn-accent"
|
||||
href={`https://maps.google.com/?q=${encodeURIComponent(transportation.to_location)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
🌍 Google
|
||||
</a>
|
||||
<a
|
||||
class="btn btn-sm btn-outline hover:btn-primary"
|
||||
href={`https://www.openstreetmap.org/search?query=${encodeURIComponent(transportation.to_location)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
🗺️ OSM
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user