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.
This commit is contained in:
Sean Morley
2026-01-06 16:24:56 -05:00
parent a46e42c545
commit 2f9a3f20ca
10 changed files with 620 additions and 135 deletions

View File

@@ -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):

View File

@@ -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 @@
</li>
{#if itineraryItem && itineraryItem.id}
<div class="divider my-1"></div>
{#if !itineraryItem.is_global}
<li>
<button
on:click={() =>
dispatch('moveToGlobal', { type: 'checklist', id: checklist.id })}
class="flex items-center gap-2"
>
<Globe class="w-4 h-4" />
{$t('itinerary.move_to_trip_wide') || 'Move to Trip-wide'}
</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()}

View File

@@ -27,6 +27,7 @@
import EyeOff from '~icons/mdi/eye-off';
import CollectionItineraryPlanner from '../collections/CollectionItineraryPlanner.svelte';
import CalendarRemove from '~icons/mdi/calendar-remove';
import Globe from '~icons/mdi/globe';
import { DEFAULT_CURRENCY, formatMoney, toMoneyValue } from '$lib/money';
export let type: string | null = null;
@@ -93,6 +94,10 @@
return stars;
}
function changeDay() {
dispatch('changeDay', { type: 'location', item: adventure, forcePicker: true });
}
async function deleteAdventure() {
let res = await fetch(`/api/locations/${adventure.id}`, {
method: 'DELETE'
@@ -373,6 +378,24 @@
{#if itineraryItem && itineraryItem.id}
<div class="divider my-1"></div>
{#if !itineraryItem.is_global}
<li>
<button
on:click={() =>
dispatch('moveToGlobal', { type: 'location', id: adventure.id })}
class=" flex items-center gap-2"
>
<Globe class="w-4 h-4" />
{$t('itinerary.move_to_trip_wide') || 'Move to Trip-wide'}
</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()}

View File

@@ -20,7 +20,9 @@
import DotsHorizontal from '~icons/mdi/dots-horizontal';
import CalendarRemove from '~icons/mdi/calendar-remove';
import Launch from '~icons/mdi/launch';
import Globe from '~icons/mdi/globe';
import { goto } from '$app/navigation';
import Calendar from '~icons/mdi/calendar';
import type { CollectionItineraryItem } from '$lib/types';
const dispatch = createEventDispatcher();
@@ -95,6 +97,10 @@
}
}
function changeDay() {
dispatch('changeDay', { type: 'lodging', item: lodging, forcePicker: true });
}
async function removeFromItinerary() {
let itineraryItemId = itineraryItem?.id;
let res = await fetch(`/api/itineraries/${itineraryItemId}`, {
@@ -193,6 +199,23 @@
</li>
{#if itineraryItem && itineraryItem.id}
<div class="divider my-1"></div>
{#if !itineraryItem.is_global}
<li>
<button
on:click={() => dispatch('moveToGlobal', { type: 'lodging', id: lodging.id })}
class="flex items-center gap-2"
>
<Globe class="w-4 h-4" />
{$t('itinerary.move_to_trip_wide') || 'Move to Trip-wide'}
</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()}

View File

@@ -20,6 +20,7 @@
import CalendarRemove from '~icons/mdi/calendar-remove';
import Launch from '~icons/mdi/launch';
import Close from '~icons/mdi/close';
import Globe from '~icons/mdi/globe';
import type { CollectionItineraryItem } from '$lib/types';
export let note: Note;
@@ -53,6 +54,10 @@
}
}
function changeDay() {
dispatch('changeDay', { type: 'note', item: note, forcePicker: true });
}
async function removeFromItinerary() {
let itineraryItemId = itineraryItem?.id;
let res = await fetch(`/api/itineraries/${itineraryItemId}`, {
@@ -189,6 +194,23 @@
</li>
{#if itineraryItem && itineraryItem.id}
<div class="divider my-1"></div>
{#if !itineraryItem.is_global}
<li>
<button
on:click={() => dispatch('moveToGlobal', { type: 'note', id: note.id })}
class="text-info flex items-center gap-2"
>
<Globe class="w-4 h-4 text-info" />
{$t('itinerary.move_to_trip_wide') || 'Move to Trip-wide'}
</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()}

View File

@@ -17,9 +17,11 @@
import EyeOff from '~icons/mdi/eye-off';
import Star from '~icons/mdi/star';
import StarOutline from '~icons/mdi/star-outline';
import Calendar from '~icons/mdi/calendar';
import DotsHorizontal from '~icons/mdi/dots-horizontal';
import CalendarRemove from '~icons/mdi/calendar-remove';
import Launch from '~icons/mdi/launch';
import Globe from '~icons/mdi/globe';
import { goto } from '$app/navigation';
import type { CollectionItineraryItem } from '$lib/types';
@@ -72,6 +74,10 @@
return parts.join(' ');
};
function changeDay() {
dispatch('changeDay', { type: 'transportation', item: transportation, forcePicker: true });
}
let travelDurationLabel: string | null = null;
$: travelDurationLabel = formatTravelDuration(transportation?.travel_duration_minutes ?? null);
@@ -226,6 +232,24 @@
</li>
{#if itineraryItem && itineraryItem.id}
<div class="divider my-1"></div>
{#if !itineraryItem.is_global}
<li>
<button
on:click={() =>
dispatch('moveToGlobal', { type: 'transportation', id: transportation.id })}
class=" flex items-center gap-2"
>
<Globe class="w-4 h-4 " />
{$t('itinerary.move_to_trip_wide') || 'Move to Trip-wide'}
</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()}
@@ -369,12 +393,6 @@
</div>
</div>
{/if}
{#if travelDurationLabel}
<div class="flex items-center gap-2 text-xs text-base-content/70">
<span class="badge badge-ghost badge-xs">⏱️ {travelDurationLabel}</span>
</div>
{/if}
</div>
{/if}
{/if}

View File

@@ -31,6 +31,8 @@
import ItineraryLinkModal from '$lib/components/collections/ItineraryLinkModal.svelte';
import ItineraryDayPickModal from '$lib/components/collections/ItineraryDayPickModal.svelte';
import { t } from 'svelte-i18n';
import { addToast } from '$lib/toasts';
import Globe from '~icons/mdi/globe';
export let collection: Collection;
export let user: any;
@@ -156,6 +158,118 @@
pendingAddDate = null;
}
/**
* Move an item to the global (trip-wide) itinerary.
* Removes all dated entries for this item and adds it to the global view instead.
*/
async function moveItemToGlobal(objectType: string, objectId: string) {
if (!collection.id) return;
try {
// Remove all dated itinerary entries for this item
const entriesToRemove = (collection.itinerary || [])
.filter((it) => it.object_id === objectId && it.date && !it.is_global)
.map((it) => it.id);
// Delete the dated entries
for (const entryId of entriesToRemove) {
await fetch(`/api/itineraries/${entryId}`, { method: 'DELETE' });
}
// Add as global if not already there
const alreadyGlobal = (collection.itinerary || []).some(
(it) => it.object_id === objectId && it.is_global
);
if (!alreadyGlobal) {
const order = globalItems.length;
const res = await fetch('/api/itineraries/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
collection: collection.id,
content_type: objectType,
object_id: objectId,
is_global: true,
order
})
});
if (!res.ok) throw new Error('Failed to add to global itinerary');
const created = await res.json();
collection.itinerary = [...(collection.itinerary || []), created];
}
// Remove dated entries from local state
collection.itinerary = (collection.itinerary || []).filter(
(it) => !entriesToRemove.includes(it.id)
);
// Refresh reactive variables
days = groupItemsByDay(collection);
globalItems = (collection.itinerary || [])
.filter((it) => it.is_global)
.map((it) => resolveItineraryItem(it, collection))
.sort((a, b) => a.order - b.order);
addToast('success', `Moved to trip-wide items`);
} catch (error) {
console.error('Error moving item to global:', error);
addToast('error', 'Failed to move item to trip-wide');
}
}
/**
* Add an unscheduled item directly to the global (trip-wide) itinerary.
*/
async function addUnscheduledItemToGlobal(type: string, itemId: string) {
if (!collection.id) return;
try {
// Check if already in global view
const alreadyGlobal = (collection.itinerary || []).some(
(it) => it.object_id === itemId && it.is_global
);
if (alreadyGlobal) {
addToast('info', 'Item already in trip-wide view');
return;
}
const order = globalItems.length;
const res = await fetch('/api/itineraries/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
collection: collection.id,
content_type: type,
object_id: itemId,
is_global: true,
order
})
});
if (!res.ok) throw new Error('Failed to add to global itinerary');
const created = await res.json();
collection.itinerary = [...(collection.itinerary || []), created];
// Remove from unscheduled by moving it to itinerary
unscheduledItems = unscheduledItems.filter((item) => !(item.item.id === itemId));
// Refresh global items
globalItems = (collection.itinerary || [])
.filter((it) => it.is_global)
.map((it) => resolveItineraryItem(it, collection))
.sort((a, b) => a.order - b.order);
addToast('success', `Added to trip-wide items`);
} catch (error) {
console.error('Error adding item to global:', error);
addToast('error', 'Failed to add item to trip-wide');
}
}
function handleItemDelete(event: CustomEvent<CollectionItineraryItem | string | number>) {
const payload = event.detail;
@@ -239,6 +353,8 @@
// Day picker modal state for unscheduled items
let isDayPickModalOpen = false;
let dayPickItemToAdd: { type: string; item: any } | null = null;
let dayPickScheduledDates: string[] = [];
let dayPickSourceVisit: { id: string; start_date: string } | null = null;
// When opening a "create new item" modal we store the target date here
let pendingAddDate: string | null = null;
@@ -835,16 +951,31 @@
}
}
// Handle opening the day picker modal for an unscheduled item
function handleOpenDayPickerForItem(type: string, item: any) {
// Handle opening the day picker modal for an item (scheduled or unscheduled)
// currentItineraryDate: the date of the itinerary entry being moved (if any)
function handleOpenDayPickerForItem(
type: string,
item: any,
forcePicker: boolean = false,
currentItineraryDate: string | null = null
) {
// Check if the item already has a date, and if so, add it directly
let itemDate: string | null = null;
// Track all itinerary dates this item is already scheduled on (non-global)
const scheduledDates = (collection.itinerary || [])
.filter((it) => it.object_id === item.id && it.date && !it.is_global)
.map((it) => it.date as string);
if (type === 'location') {
// For locations, check if there's a visit with a start_date
const firstVisit = item.visits?.[0];
// For locations, prefer the visit matching the current itinerary date
let matchedVisit = null;
if (currentItineraryDate) {
matchedVisit = item.visits?.find((v) => v.start_date?.startsWith(currentItineraryDate));
}
const firstVisit = matchedVisit || item.visits?.[0];
if (firstVisit?.start_date) {
itemDate = firstVisit.start_date.split('T')[0]; // Extract date only (YYYY-MM-DD)
dayPickSourceVisit = { id: firstVisit.id, start_date: firstVisit.start_date };
}
} else if (type === 'transportation') {
if (item.date) {
@@ -864,6 +995,25 @@
}
}
// If caller explicitly wants the picker, bypass auto-add
if (forcePicker) {
dayPickItemToAdd = { type, item };
dayPickScheduledDates = scheduledDates;
// Capture source visit for locations (match itinerary date first)
dayPickSourceVisit = null;
if (type === 'location') {
const matchedVisit = currentItineraryDate
? item.visits?.find((v) => v.start_date?.startsWith(currentItineraryDate))
: null;
const firstVisit = matchedVisit || item.visits?.[0];
if (firstVisit?.start_date) {
dayPickSourceVisit = { id: firstVisit.id, start_date: firstVisit.start_date };
}
}
isDayPickModalOpen = true;
return;
}
// If we found a date, add it directly to that date
// Helper: check if a date is within collection start/end bounds (if set)
function isDateWithinCollectionRange(dateISO: string | null) {
@@ -889,33 +1039,120 @@
// If the item's date is outside the collection range, prompt the day picker
if (!isDateWithinCollectionRange(itemDate)) {
dayPickItemToAdd = { type, item };
dayPickScheduledDates = scheduledDates;
dayPickSourceVisit = null;
if (type === 'location') {
// Prefer the visit that matches itemDate if present
const source = item.visits?.find((v) => v.start_date?.startsWith(itemDate));
const useVisit = source || item.visits?.[0];
if (useVisit?.start_date) {
dayPickSourceVisit = { id: useVisit.id, start_date: useVisit.start_date };
}
}
isDayPickModalOpen = true;
return;
}
// We have a valid date; ensure dayPickSourceVisit is aligned for locations with multiple visits
if (type === 'location' && !dayPickSourceVisit) {
const source = item.visits?.find((v) => v.start_date?.startsWith(itemDate));
if (source?.start_date) {
dayPickSourceVisit = { id: source.id, start_date: source.start_date };
}
}
addItineraryItemForObject(type, item.id, itemDate, false);
} else {
// Otherwise, show the day picker modal
dayPickItemToAdd = { type, item };
dayPickScheduledDates = scheduledDates;
dayPickSourceVisit = null;
if (type === 'location') {
const matchedVisit = currentItineraryDate
? item.visits?.find((v) => v.start_date?.startsWith(currentItineraryDate))
: item.visits?.[0];
if (matchedVisit?.start_date) {
dayPickSourceVisit = { id: matchedVisit.id, start_date: matchedVisit.start_date };
}
}
isDayPickModalOpen = true;
}
}
// Handle day selection from the day picker modal
async function handleDaySelected(event: CustomEvent<{ date: string; updateDate: boolean }>) {
const { date: selectedDate, updateDate } = event.detail;
async function handleDaySelected(
event: CustomEvent<{ date: string; updateDate: boolean; deleteSourceVisit?: boolean }>
) {
const { date: selectedDate, updateDate, deleteSourceVisit } = event.detail;
if (!dayPickItemToAdd) return;
const { type, item } = dayPickItemToAdd;
const objectType = type; // 'location', 'transportation', 'lodging', 'note', 'checklist'
const objectId = item.id;
// Add the item to the selected day
await addItineraryItemForObject(objectType, objectId, selectedDate, updateDate);
// Identify existing dated itinerary entries for this object
const existingDatedItems = (collection.itinerary || []).filter(
(it) => it.object_id === objectId && it.date && !it.is_global
);
// Reset state
dayPickItemToAdd = null;
isDayPickModalOpen = false;
// Avoid duplicate add if already scheduled for the selected date
const alreadyScheduledForSelectedDate = existingDatedItems.some(
(it) => it.date === selectedDate
);
try {
if (!alreadyScheduledForSelectedDate) {
// Add the item to the selected day
await addItineraryItemForObject(objectType, objectId, selectedDate, updateDate);
}
// Optionally delete the source visit (for locations) — skip if we're updating it
if (deleteSourceVisit && objectType === 'location' && dayPickSourceVisit?.id && !updateDate) {
try {
await fetch(`/api/visits/${dayPickSourceVisit.id}/`, { method: 'DELETE' });
// Update local state: remove visit from the location
if (collection.locations) {
collection.locations = collection.locations.map((loc) => {
if (loc.id === objectId) {
return {
...loc,
visits: (loc.visits || []).filter((v) => v.id !== dayPickSourceVisit?.id)
};
}
return loc;
});
}
} catch (e) {
console.error('Failed to delete source visit', dayPickSourceVisit.id, e);
}
}
// 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) {
try {
await fetch(`/api/itineraries/${oldItem.id}`, { method: 'DELETE' });
} catch (e) {
console.error('Failed to remove old itinerary item', oldItem.id, 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;
isDayPickModalOpen = false;
}
}
// Add an itinerary item locally and attempt to persist to backend
@@ -953,7 +1190,12 @@
object_id: objectId,
date: dateISO,
order,
update_item_date: updateItemDate
update_item_date: updateItemDate,
// Prefer updating an existing Visit when moving a location
source_visit_id:
objectType === 'location' && updateItemDate && dayPickSourceVisit?.id
? dayPickSourceVisit.id
: undefined
})
});
@@ -966,89 +1208,58 @@
collection.itinerary = collection.itinerary.map((it) => (it.id === tempId ? created : it));
pendingAddDate = null;
// If we updated the item's date, update local state directly
if (updateItemDate) {
// If we updated the item's date, sync the updated object from server response
if (updateItemDate && created.updated_object) {
if (objectType === 'transportation') {
if (collection.transportations) {
collection.transportations = collection.transportations.map((t) =>
t.id === objectId ? { ...t, ...created.updated_object } : t
);
}
} else if (objectType === 'lodging') {
if (collection.lodging) {
collection.lodging = collection.lodging.map((l) =>
l.id === objectId ? { ...l, ...created.updated_object } : l
);
}
}
} 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, create a new visit locally
// For locations, update existing source visit when available; otherwise append a new visit
const sourceId = dayPickSourceVisit?.id;
if (collection.locations) {
collection.locations = collection.locations.map((loc) => {
if (loc.id === objectId) {
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]
};
}
return loc;
});
}
} else if (objectType === 'transportation') {
if (collection.transportations) {
collection.transportations = collection.transportations.map((t) => {
if (t.id === objectId) {
// If end_date exists and is before the new start date, set it to next day
let newEndDate = t.end_date;
if (newEndDate) {
const endDate = DateTime.fromISO(newEndDate);
const startDate = DateTime.fromISO(isoDate);
if (endDate < startDate) {
// Check if original end_date has a time component (not all-day)
const hasTime = !newEndDate.includes('T00:00:00');
if (hasTime && t.end_timezone) {
// Set to 9am in the end timezone
newEndDate = DateTime.fromISO(nextDayISO, { zone: t.end_timezone })
.set({ hour: 9, minute: 0, second: 0 })
.toISO();
} else {
// All-day event, keep at UTC 0
newEndDate = `${nextDayISO}T00:00:00`;
}
}
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 };
}
return { ...t, date: isoDate, end_date: newEndDate };
}
return t;
});
}
} else if (objectType === 'lodging') {
if (collection.lodging) {
collection.lodging = collection.lodging.map((l) => {
if (l.id === objectId) {
// If check_out exists and is before the new check_in, set it to next day
let newCheckOut = l.check_out;
if (newCheckOut) {
const checkOut = DateTime.fromISO(newCheckOut);
const checkIn = DateTime.fromISO(isoDate);
if (checkOut < checkIn) {
// Check if original check_out has a time component (not all-day)
const hasTime = !newCheckOut.includes('T00:00:00');
if (hasTime && l.timezone) {
// Set to 9am in the lodging timezone
newCheckOut = DateTime.fromISO(nextDayISO, { zone: l.timezone })
.set({ hour: 9, minute: 0, second: 0 })
.toISO();
} else {
// All-day event, keep at UTC 0
newCheckOut = `${nextDayISO}T00:00:00`;
}
}
}
return { ...l, check_in: isoDate, check_out: newCheckOut };
}
return l;
// 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] };
});
}
} else if (objectType === 'note') {
@@ -1241,10 +1452,14 @@
isOpen={isDayPickModalOpen}
{days}
itemName={dayPickItemToAdd?.item?.name || `${$t('checklist.item')}`}
scheduledDates={dayPickScheduledDates}
sourceVisitDate={dayPickSourceVisit ? dayPickSourceVisit.start_date.split('T')[0] : null}
on:daySelected={handleDaySelected}
on:close={() => {
isDayPickModalOpen = false;
dayPickItemToAdd = null;
dayPickScheduledDates = [];
dayPickSourceVisit = null;
}}
/>
{/if}
@@ -1314,12 +1529,40 @@
<Plus class="w-5 h-5" />
</button>
<ul
class="dropdown-content menu p-2 shadow bg-base-300 rounded-box w-56"
class="dropdown-content menu p-2 shadow bg-base-300 rounded-box w-72"
role="menu"
>
<li class="menu-title">{$t('itinerary.link_existing_item')}</li>
{#if unscheduledItems.length > 0}
<li class="menu-title">
<span>{$t('itinerary.add_to_trip_wide') || 'Add Unscheduled Items'}</span>
</li>
{#each unscheduledItems as unscheduledItem (unscheduledItem.item.id)}
<li>
<button
type="button"
on:click={() => {
addUnscheduledItemToGlobal(
unscheduledItem.type,
unscheduledItem.item.id
);
}}
class="text-xs py-1"
>
<span class="truncate">{unscheduledItem.item.name}</span>
<span class="badge badge-xs badge-ghost">
{unscheduledItem.type}
</span>
</button>
</li>
{/each}
<li class="divider my-1"></li>
{/if}
<li class="text-xs opacity-70 px-2 py-1 select-none">
Add items below (Unscheduled) to trip-wide
{#if unscheduledItems.length === 0}
All items are scheduled or in trip-wide view
{:else}
Or drag items from unscheduled below
{/if}
</li>
</ul>
</div>
@@ -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'}
<LodgingCard
@@ -1415,6 +1660,7 @@
on:delete={handleItemDelete}
on:removeFromItinerary={handleRemoveItineraryItem}
on:edit={handleEditLodging}
on:moveToGlobal={(e) => moveItemToGlobal(e.detail.type, e.detail.id)}
/>
{:else if objectType === 'note'}
<NoteCard
@@ -1425,6 +1671,7 @@
itineraryItem={item}
on:removeFromItinerary={handleRemoveItineraryItem}
on:edit={handleEditNote}
on:moveToGlobal={(e) => moveItemToGlobal(e.detail.type, e.detail.id)}
/>
{:else if objectType === 'checklist'}
<ChecklistCard
@@ -1435,6 +1682,7 @@
itineraryItem={item}
on:removeFromItinerary={handleRemoveItineraryItem}
on:edit={handleEditChecklist}
on:moveToGlobal={(e) => 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'}
<TransportationCard
@@ -1810,6 +2066,13 @@
itineraryItem={item}
on:removeFromItinerary={handleRemoveItineraryItem}
on:edit={handleEditTransportation}
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 === 'lodging'}
<LodgingCard
@@ -1820,6 +2083,13 @@
on:delete={handleItemDelete}
on:removeFromItinerary={handleRemoveItineraryItem}
on:edit={handleEditLodging}
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 === 'note'}
<!-- @ts-ignore - TypeScript can't narrow union type properly -->
@@ -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'}
<!-- @ts-ignore - TypeScript can't narrow union type properly -->
@@ -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}
</div>

View File

@@ -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<any>).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<string, number>();
transportSegments.forEach((segment) => {
@@ -566,9 +572,11 @@
<h4 class="font-semibold mb-2">Sport Types</h4>
<div class="flex flex-wrap gap-2">
{#each sportTypes as [sport, count]}
<span class="badge badge-lg badge-primary badge-outline">
{sport} ({count})
</span>
<div class="badge badge-lg badge-primary badge-outline p-3 flex items-center gap-2">
<span class="text-lg">{getActivityIcon(sport)}</span>
<span class="font-medium">{getSportLabel(sport)}</span>
<span class="badge badge-xs badge-primary ml-2">{count}</span>
</div>
{/each}
</div>
</div>

View File

@@ -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)}
<div
class="card bg-base-100 border border-base-300 shadow-sm hover:border-primary/60 hover:shadow-md transition-all"
@@ -86,6 +91,11 @@
<div class="flex items-center gap-2">
<span class="badge badge-primary badge-sm">Day {dayNumber}</span>
<span class="text-xs opacity-60">of {totalDays}</span>
{#if isScheduled}
<span class="badge badge-neutral badge-outline badge-sm"
>Already scheduled</span
>
{/if}
</div>
<div class="font-semibold text-base mt-1">{day.displayDate}</div>
<div class="text-sm opacity-70 flex items-center gap-2 mt-1">
@@ -97,19 +107,13 @@
</div>
<div class="flex gap-2">
<button
type="button"
class="btn btn-outline btn-sm flex-1"
on:click={() => handleDaySelect(day.date, false)}
>
Add as is
</button>
<button
type="button"
class="btn btn-primary btn-sm flex-1"
disabled={isScheduled}
on:click={() => handleDaySelect(day.date, true)}
>
Add & Update Date
{isScheduled ? 'Already scheduled' : 'Update to this day'}
</button>
</div>
</div>

View File

@@ -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",