Files
voyage/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte

3615 lines
119 KiB
Svelte

<script lang="ts">
// @ts-nocheck
import type {
Collection,
CollectionItineraryItem,
CollectionItineraryDay,
Location,
Transportation,
Lodging,
Note,
Checklist
} from '$lib/types';
// @ts-ignore
import { DateTime } from 'luxon';
import { dndzone, TRIGGERS, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import CalendarBlank from '~icons/mdi/calendar-blank';
import Bed from '~icons/mdi/bed';
import Info from '~icons/mdi/information';
import Plus from '~icons/mdi/plus';
import LocationCard from '$lib/components/cards/LocationCard.svelte';
import TransportationCard from '$lib/components/cards/TransportationCard.svelte';
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 NewLocationModal from '$lib/components/locations/LocationModal.svelte';
import LodgingModal from '../lodging/LodgingModal.svelte';
import TransportationModal from '../transportation/TransportationModal.svelte';
import NoteModal from '$lib/components/NoteModal.svelte';
import ChecklistModal from '$lib/components/ChecklistModal.svelte';
import ItineraryLinkModal from '$lib/components/collections/ItineraryLinkModal.svelte';
import ItineraryDayPickModal from '$lib/components/collections/ItineraryDayPickModal.svelte';
import Car from '~icons/mdi/car';
import Walk from '~icons/mdi/walk';
import LocationMarker from '~icons/mdi/map-marker';
import { t } from 'svelte-i18n';
import { addToast } from '$lib/toasts';
import Globe from '~icons/mdi/globe';
import { TRANSPORTATION_TYPES_ICONS } from '$lib';
export let collection: Collection;
export let user: any;
// Whether the current user can modify this collection (owner or shared user)
export let canModify: boolean = false;
const flipDurationMs = 200;
// Extended itinerary item with resolved object
type ResolvedItineraryItem = CollectionItineraryItem & {
resolvedObject: Location | Transportation | Lodging | Note | Checklist | null;
};
// Group itinerary items by day
type DayGroup = {
date: string;
displayDate: string;
items: ResolvedItineraryItem[];
preTimelineLodging: ResolvedItineraryItem | null; // Checkout-side lodging shown before timeline
postTimelineLodging: ResolvedItineraryItem | null; // Checkin-side lodging shown after timeline
overnightLodging: Lodging[]; // Lodging where guest is staying overnight (not check-in day)
globalDatedItems: ResolvedItineraryItem[]; // Trip-wide items that still carry a date
dayMetadata: CollectionItineraryDay | null; // Day name and description
};
type DayTemperature = {
available: boolean;
temperature_c: number | null;
};
$: days = groupItemsByDay(collection);
$: unscheduledItems = getUnscheduledItems(collection);
// Trip-wide (global) itinerary items
$: globalItems = (collection.itinerary || [])
.filter((it) => it.is_global)
.map((it) => resolveItineraryItem(it, collection))
.sort((a, b) => a.order - b.order);
// Auto-generate state
let isAutoGenerating = false;
// Saving state for itinerary reorders. When true, disable drag interactions.
let isSavingOrder = false;
// Which day (ISO date string) is currently being saved. Used to show per-day spinner.
let savingDay: string | null = null;
let dayTemperatures: Record<string, DayTemperature> = {};
let activeTemperatureFetchVersion = 0;
// Check if auto-generate is available (only for users with modify permission)
$: canAutoGenerate =
canModify && collection.itinerary?.length === 0 && hasDatedRecords(collection);
function hasDatedRecords(collection: Collection): boolean {
// Check if collection has any dated records
const hasVisits =
collection.locations?.some((loc) => loc.visits?.some((v) => v.start_date)) || false;
const hasLodging = collection.lodging?.some((l) => l.check_in) || false;
const hasTransportation = collection.transportations?.some((t) => t.date) || false;
const hasNotes = collection.notes?.some((n) => n.date) || false;
const hasChecklists = collection.checklists?.some((c) => c.date) || false;
return hasVisits || hasLodging || hasTransportation || hasNotes || hasChecklists;
}
async function handleAutoGenerate() {
if (!canAutoGenerate || isAutoGenerating) return;
isAutoGenerating = true;
try {
const response = await fetch('/api/itineraries/auto-generate/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
collection_id: collection.id
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.error || 'Failed to auto-generate itinerary');
}
const data = await response.json();
// Refresh the page to load the updated itinerary
window.location.reload();
} catch (error) {
console.error('Auto-generate error:', error);
alert(error.message || 'Failed to auto-generate itinerary');
isAutoGenerating = false;
}
}
function handleRemoveItineraryItem(event: CustomEvent<CollectionItineraryItem>) {
const itemToRemove = event.detail;
collection.itinerary = collection.itinerary?.filter((it) => it.id !== itemToRemove.id);
days = groupItemsByDay(collection);
}
let locationToEdit: Location | null = null;
let isLocationModalOpen: boolean = false;
function handleEditLocation(event: CustomEvent<Location>) {
locationToEdit = event.detail;
isLocationModalOpen = true;
}
function handleDuplicateLocation(event: CustomEvent<Location>) {
const duplicated = event.detail;
if (!duplicated || !duplicated.id) return;
const collectionId = collection?.id ? String(collection.id) : null;
if (collectionId) {
const existingCollections = Array.isArray((duplicated as any).collections)
? (duplicated as any).collections.map((id: string) => String(id))
: [];
if (!existingCollections.includes(collectionId)) {
(duplicated as any).collections = [...existingCollections, collectionId];
}
}
collection = {
...collection,
locations: [
duplicated,
...(collection.locations || []).filter((loc) => String(loc.id) !== String(duplicated.id))
]
};
days = groupItemsByDay(collection);
unscheduledItems = getUnscheduledItems(collection);
}
let lodgingToEdit: Lodging | null = null;
let isLodgingModalOpen: boolean = false;
function handleEditLodging(event: CustomEvent<Lodging>) {
lodgingToEdit = event.detail;
isLodgingModalOpen = true;
}
let transportationToEdit: Transportation | null = null;
let isTransportationModalOpen: boolean = false;
function handleEditTransportation(event: CustomEvent<Transportation>) {
transportationToEdit = event.detail;
isTransportationModalOpen = true;
}
function handleEditNote(event: CustomEvent<Note>) {
noteToEdit = event.detail;
isNoteModalOpen = true;
pendingAddDate = null;
}
function handleEditChecklist(event: CustomEvent<Checklist>) {
checklistToEdit = event.detail;
isChecklistModalOpen = true;
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, but preserve visits
for (const entryId of entriesToRemove) {
await fetch(`/api/itineraries/${entryId}?preserve_visits=true`, { 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', $t('itinerary.moved_to_trip_context'));
} catch (error) {
console.error('Error moving item to context:', error);
addToast('error', $t('itinerary.failed_to_move_to_trip_context'));
}
}
/**
* 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', $t('itinerary.item_already_in_trip_context'));
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', $t('itinerary.added_to_trip_context'));
} catch (error) {
console.error('Error adding item to global:', error);
addToast('error', $t('itinerary.failed_to_add_to_trip_context'));
}
}
function handleItemDelete(event: CustomEvent<CollectionItineraryItem | string | number>) {
const payload = event.detail;
// Support both cases:
// 1) Card components dispatch a primitive id (string/number) when deleting the underlying object
// 2) Some callers may dispatch a full itinerary item object
if (typeof payload === 'string' || typeof payload === 'number') {
const objectId = payload;
// Remove any itinerary entries that reference this object
collection.itinerary = collection.itinerary?.filter(
(it) => String(it.object_id) !== String(objectId)
);
// Remove the object from all possible collections (location/transportation/lodging/note/checklist)
if (collection.locations) {
collection.locations = collection.locations.filter(
(loc) => String(loc.id) !== String(objectId)
);
}
if (collection.transportations) {
collection.transportations = collection.transportations.filter(
(t) => String(t.id) !== String(objectId)
);
}
if (collection.lodging) {
collection.lodging = collection.lodging.filter((l) => String(l.id) !== String(objectId));
}
if (collection.notes) {
collection.notes = collection.notes.filter((n) => String(n.id) !== String(objectId));
}
if (collection.checklists) {
collection.checklists = collection.checklists.filter(
(c) => String(c.id) !== String(objectId)
);
}
// Re-group days and return
days = groupItemsByDay(collection);
return;
}
// Otherwise expect a full itinerary-like object
const itemToDelete = payload as CollectionItineraryItem;
collection.itinerary = collection.itinerary?.filter((it) => it.id !== itemToDelete.id);
// Also remove the associated object from the collection
const objectType = itemToDelete.item?.type || '';
if (objectType === 'location') {
collection.locations = collection.locations?.filter(
(loc) => loc.id !== itemToDelete.object_id
);
} else if (objectType === 'transportation') {
collection.transportations = collection.transportations?.filter(
(t) => t.id !== itemToDelete.object_id
);
} else if (objectType === 'lodging') {
collection.lodging = collection.lodging?.filter((l) => l.id !== itemToDelete.object_id);
} else if (objectType === 'note') {
collection.notes = collection.notes?.filter((n) => n.id !== itemToDelete.object_id);
} else if (objectType === 'checklist') {
collection.checklists = collection.checklists?.filter((c) => c.id !== itemToDelete.object_id);
}
days = groupItemsByDay(collection);
}
let locationBeingUpdated: Location | null = null;
let lodgingBeingUpdated: Lodging | null = null;
let transportationBeingUpdated: Transportation | null = null;
let isNoteModalOpen = false;
let isChecklistModalOpen = false;
let isItineraryLinkModalOpen = false;
let noteToEdit: Note | null = null;
let checklistToEdit: Checklist | null = null;
// Store the target date and display date for the link modal
let linkModalTargetDate: string = '';
let linkModalDisplayDate: string = '';
// 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;
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;
let pendingLodgingAddDate: string | null = null;
// Track if we've already added this location to the itinerary
let addedToItinerary: Set<string> = new Set();
function normalizeDateOnly(value: string | null | undefined): string | null {
if (!value) return null;
return value.includes('T') ? value.split('T')[0] : value;
}
function getTransportationIcon(type: string | null | undefined) {
if (type && type in TRANSPORTATION_TYPES_ICONS) {
return TRANSPORTATION_TYPES_ICONS[type as keyof typeof TRANSPORTATION_TYPES_ICONS];
}
return '🚗';
}
function formatTransportationDistance(distanceKm: number | null | undefined): string | null {
if (distanceKm === null || distanceKm === undefined || Number.isNaN(distanceKm)) return null;
if (distanceKm < 10) return `${distanceKm.toFixed(1)} km`;
return `${Math.round(distanceKm)} km`;
}
const WALKING_SPEED_KMH = 5;
const DRIVING_SPEED_KMH = 60;
const WALKING_THRESHOLD_MINUTES = 20;
const ROUTE_METRICS_BATCH_SIZE = 50;
function toRadians(degrees: number): number {
return (degrees * Math.PI) / 180;
}
function normalizeCoordinate(value: number | string | null | undefined): number | null {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function haversineDistanceKm(from: Location, to: Location): number | null {
const fromLatitude = normalizeCoordinate(from.latitude);
const fromLongitude = normalizeCoordinate(from.longitude);
const toLatitude = normalizeCoordinate(to.latitude);
const toLongitude = normalizeCoordinate(to.longitude);
if (
fromLatitude === null ||
fromLongitude === null ||
toLatitude === null ||
toLongitude === null
) {
return null;
}
const earthRadiusKm = 6371;
const latDelta = toRadians(toLatitude - fromLatitude);
const lonDelta = toRadians(toLongitude - fromLongitude);
const fromLat = toRadians(fromLatitude);
const toLat = toRadians(toLatitude);
const a =
Math.sin(latDelta / 2) * Math.sin(latDelta / 2) +
Math.cos(fromLat) * Math.cos(toLat) * Math.sin(lonDelta / 2) * Math.sin(lonDelta / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const distanceKm = earthRadiusKm * c;
return Number.isFinite(distanceKm) ? distanceKm : null;
}
function haversineDistanceBetweenCoordinates(
from: { latitude: number; longitude: number },
to: { latitude: number; longitude: number }
): number {
const earthRadiusKm = 6371;
const latDelta = toRadians(to.latitude - from.latitude);
const lonDelta = toRadians(to.longitude - from.longitude);
const fromLat = toRadians(from.latitude);
const toLat = toRadians(to.latitude);
const a =
Math.sin(latDelta / 2) * Math.sin(latDelta / 2) +
Math.cos(fromLat) * Math.cos(toLat) * Math.sin(lonDelta / 2) * Math.sin(lonDelta / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return earthRadiusKm * c;
}
function formatTravelDuration(minutes: number): string {
const totalMinutes = Math.max(0, Math.round(minutes));
const hours = Math.floor(totalMinutes / 60);
const remainingMinutes = totalMinutes % 60;
if (hours === 0) return `${remainingMinutes}m`;
if (remainingMinutes === 0) return `${hours}h`;
return `${hours}h ${remainingMinutes}m`;
}
type LocationConnector = {
distanceLabel: string;
durationLabel: string;
mode: 'walking' | 'driving';
unavailable?: boolean;
};
type ConnectorPair = {
key: string;
from: {
latitude: number;
longitude: number;
};
to: {
latitude: number;
longitude: number;
};
};
type ConnectableItemType = 'location' | 'lodging' | 'transportation';
type TransportationCoordinateRole = 'origin' | 'destination';
type RouteMetricResult = {
distance_label?: string;
duration_label?: string;
mode?: 'walking' | 'driving';
distance_km?: number;
duration_minutes?: number;
};
let connectorMetricsMap: Record<string, LocationConnector> = {};
let activeConnectorFetchVersion = 0;
function isConnectableItemType(type: string): type is ConnectableItemType {
return type === 'location' || type === 'lodging' || type === 'transportation';
}
function getCoordinatesFromItineraryItem(
item: ResolvedItineraryItem | null,
transportationRole: TransportationCoordinateRole = 'origin'
): { latitude: number; longitude: number } | null {
if (!item) return null;
const itemType = item.item?.type || '';
if (!isConnectableItemType(itemType)) return null;
if (itemType === 'transportation') {
const transportation = item.resolvedObject as Transportation | null;
if (!transportation) return null;
const latitude = normalizeCoordinate(
transportationRole === 'origin'
? transportation.origin_latitude
: transportation.destination_latitude
);
const longitude = normalizeCoordinate(
transportationRole === 'origin'
? transportation.origin_longitude
: transportation.destination_longitude
);
if (latitude === null || longitude === null) return null;
return { latitude, longitude };
}
const resolvedObj = item.resolvedObject as Location | Lodging | null;
if (!resolvedObj) return null;
const latitude = normalizeCoordinate(resolvedObj.latitude);
const longitude = normalizeCoordinate(resolvedObj.longitude);
if (latitude === null || longitude === null) return null;
return { latitude, longitude };
}
function getFirstConnectableItem(items: ResolvedItineraryItem[]): ResolvedItineraryItem | null {
for (const item of items) {
if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) continue;
if (isConnectableItemType(item.item?.type || '')) return item;
}
return null;
}
function getDayWeatherAnchor(day: DayGroup): { latitude: number; longitude: number } | null {
for (const item of day.items) {
if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) continue;
const coordinates = getCoordinatesFromItineraryItem(item);
if (coordinates) return coordinates;
}
const boundaryCandidates = [day.preTimelineLodging, day.postTimelineLodging];
for (const boundary of boundaryCandidates) {
const coordinates = getCoordinatesFromItineraryItem(boundary);
if (coordinates) return coordinates;
}
return null;
}
async function loadDayTemperatures(dayGroups: DayGroup[], fetchVersion: number) {
if (dayGroups.length === 0) {
if (fetchVersion === activeTemperatureFetchVersion) {
dayTemperatures = {};
}
return;
}
const payloadDays = dayGroups
.map((day) => {
const anchor = getDayWeatherAnchor(day);
if (!anchor) return null;
return {
date: day.date,
latitude: anchor.latitude,
longitude: anchor.longitude
};
})
.filter((entry): entry is { date: string; latitude: number; longitude: number } => !!entry);
if (payloadDays.length === 0) {
if (fetchVersion === activeTemperatureFetchVersion) {
dayTemperatures = {};
}
return;
}
try {
const response = await fetch('/api/weather/daily-temperatures/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ days: payloadDays })
});
if (!response.ok) throw new Error('Failed to load day temperatures');
const data = await response.json();
if (fetchVersion !== activeTemperatureFetchVersion) return;
const nextMap: Record<string, DayTemperature> = {};
for (const result of data?.results || []) {
if (!result?.date) continue;
nextMap[result.date] = {
available: !!result.available,
temperature_c:
typeof result.temperature_c === 'number' ? result.temperature_c : null
};
}
dayTemperatures = nextMap;
} catch (error) {
if (fetchVersion !== activeTemperatureFetchVersion) return;
console.error('Failed to fetch day temperatures:', error);
dayTemperatures = {};
}
}
function getLastConnectableItem(items: ResolvedItineraryItem[]): ResolvedItineraryItem | null {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) continue;
if (isConnectableItemType(item.item?.type || '')) return item;
}
return null;
}
function getResolvedScheduledLodgingItem(
collection: Collection,
lodging: Lodging
): ResolvedItineraryItem | null {
const sourceItineraryItem = collection.itinerary?.find((item) => {
const objectType = item.item?.type || '';
return objectType === 'lodging' && item.object_id === lodging.id;
});
if (!sourceItineraryItem) return null;
return {
...sourceItineraryItem,
resolvedObject: lodging
};
}
function getDirectionalBoundaryLodging(
collection: Collection,
dateISO: string
): {
preTimelineLodging: ResolvedItineraryItem | null;
postTimelineLodging: ResolvedItineraryItem | null;
} {
const targetDate = DateTime.fromISO(dateISO).startOf('day');
let preTimelineLodging: ResolvedItineraryItem | null = null;
let postTimelineLodging: ResolvedItineraryItem | null = null;
for (const lodging of collection.lodging || []) {
if (!lodging.check_in || !lodging.check_out) continue;
const checkIn = DateTime.fromISO(lodging.check_in.split('T')[0]).startOf('day');
const checkOut = DateTime.fromISO(lodging.check_out.split('T')[0]).startOf('day');
const isPreTimelineContext = targetDate > checkIn && targetDate <= checkOut;
const isPostTimelineContext = targetDate >= checkIn && targetDate < checkOut;
if (!isPreTimelineContext && !isPostTimelineContext) continue;
const resolvedLodgingItem = getResolvedScheduledLodgingItem(collection, lodging);
if (!resolvedLodgingItem) continue;
if (!preTimelineLodging && isPreTimelineContext) {
preTimelineLodging = resolvedLodgingItem;
}
if (!postTimelineLodging && isPostTimelineContext) {
postTimelineLodging = resolvedLodgingItem;
}
if (preTimelineLodging && postTimelineLodging) break;
}
return { preTimelineLodging, postTimelineLodging };
}
function getDayTimelineItems(day: DayGroup): ResolvedItineraryItem[] {
const boundaryIds = new Set(
[day.preTimelineLodging?.id, day.postTimelineLodging?.id].filter(
(id): id is string => !!id
)
);
if (boundaryIds.size === 0) return day.items;
return day.items.filter((item) => !boundaryIds.has(item.id));
}
function shouldShowOvernightSummary(day: DayGroup): boolean {
return (
day.overnightLodging.length > 0 &&
!day.preTimelineLodging?.resolvedObject &&
!day.postTimelineLodging?.resolvedObject
);
}
function reinsertBoundaryLodgingItems(
day: DayGroup,
timelineItems: ResolvedItineraryItem[]
): ResolvedItineraryItem[] {
const boundaryCandidates = [day.preTimelineLodging, day.postTimelineLodging].filter(
(item, index, list): item is ResolvedItineraryItem =>
!!item && list.findIndex((candidate) => candidate?.id === item.id) === index
);
if (boundaryCandidates.length === 0) return timelineItems;
let restoredItems = [...timelineItems];
for (const boundaryItem of boundaryCandidates) {
const existedOnThisDay = day.items.some((item) => item.id === boundaryItem.id);
if (!existedOnThisDay) continue;
if (restoredItems.some((item) => item.id === boundaryItem.id)) continue;
const previousBoundaryIndex = day.items.findIndex((item) => item.id === boundaryItem.id);
const insertIndex =
previousBoundaryIndex >= 0
? Math.min(previousBoundaryIndex, restoredItems.length)
: restoredItems.length;
restoredItems = [
...restoredItems.slice(0, insertIndex),
boundaryItem,
...restoredItems.slice(insertIndex)
];
}
return restoredItems;
}
function getLocationConnectorKey(
currentItem: ResolvedItineraryItem,
nextItem: ResolvedItineraryItem | null
): string | null {
if (!nextItem) return null;
if (!currentItem?.id || !nextItem?.id) return null;
return `${currentItem.id}:${nextItem.id}`;
}
function getConnectorPair(
currentItem: ResolvedItineraryItem,
nextItem: ResolvedItineraryItem | null
): ConnectorPair | null {
if (!nextItem) return null;
const currentType = currentItem.item?.type || '';
const nextType = nextItem.item?.type || '';
if (!isConnectableItemType(currentType) || !isConnectableItemType(nextType)) return null;
const fromCoordinates = getCoordinatesFromItineraryItem(
currentItem,
currentType === 'transportation' ? 'destination' : 'origin'
);
const toCoordinates = getCoordinatesFromItineraryItem(
nextItem,
'origin'
);
if (!fromCoordinates || !toCoordinates) return null;
const key = getLocationConnectorKey(currentItem, nextItem);
if (!key) return null;
return {
key,
from: fromCoordinates,
to: toCoordinates
};
}
function findNextConnectableItem(
items: ResolvedItineraryItem[],
currentIndex: number
): ResolvedItineraryItem | null {
for (let index = currentIndex + 1; index < items.length; index += 1) {
const candidate = items[index];
if (candidate?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) {
continue;
}
if (isConnectableItemType(candidate?.item?.type || '')) {
return candidate;
}
}
return null;
}
function getConnectorPairs(dayGroups: DayGroup[]): ConnectorPair[] {
const pairs: ConnectorPair[] = [];
const seenKeys = new Set<string>();
function pushPair(pair: ConnectorPair | null) {
if (!pair || seenKeys.has(pair.key)) return;
seenKeys.add(pair.key);
pairs.push(pair);
}
for (const dayGroup of dayGroups) {
const dayTimelineItems = getDayTimelineItems(dayGroup);
const firstConnectableItem = getFirstConnectableItem(dayGroup.items);
const lastConnectableItem = getLastConnectableItem(dayGroup.items);
if (dayGroup.preTimelineLodging && firstConnectableItem) {
pushPair(getConnectorPair(dayGroup.preTimelineLodging, firstConnectableItem));
}
for (let index = 0; index < dayTimelineItems.length - 1; index += 1) {
const currentItem = dayTimelineItems[index];
if (currentItem?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) {
continue;
}
const nextConnectableItem = findNextConnectableItem(dayTimelineItems, index);
pushPair(getConnectorPair(currentItem, nextConnectableItem));
}
if (dayGroup.postTimelineLodging && lastConnectableItem) {
pushPair(getConnectorPair(lastConnectableItem, dayGroup.postTimelineLodging));
}
}
return pairs;
}
function chunkConnectorPairs(pairs: ConnectorPair[], chunkSize: number): ConnectorPair[][] {
const chunks: ConnectorPair[][] = [];
for (let index = 0; index < pairs.length; index += chunkSize) {
chunks.push(pairs.slice(index, index + chunkSize));
}
return chunks;
}
function formatDistanceLabel(distanceKm: number): string {
if (distanceKm < 10) return `${distanceKm.toFixed(1)} km`;
return `${Math.round(distanceKm)} km`;
}
function normalizeRouteMetricResult(result: RouteMetricResult | null): LocationConnector | null {
if (!result || (result.mode !== 'walking' && result.mode !== 'driving')) return null;
let distanceLabel = result.distance_label;
if (
!distanceLabel &&
typeof result.distance_km === 'number' &&
Number.isFinite(result.distance_km)
) {
distanceLabel = formatDistanceLabel(Math.max(0, result.distance_km));
}
let durationLabel = result.duration_label;
if (
!durationLabel &&
typeof result.duration_minutes === 'number' &&
Number.isFinite(result.duration_minutes)
) {
durationLabel = formatTravelDuration(Math.max(0, result.duration_minutes));
}
if (!distanceLabel || !durationLabel) return null;
return {
distanceLabel,
durationLabel,
mode: result.mode
};
}
async function fetchRouteMetricChunk(
chunk: ConnectorPair[]
): Promise<Record<string, LocationConnector>> {
const response = await fetch('/api/route-metrics/query/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
pairs: chunk.map((pair) => ({
from: pair.from,
to: pair.to
}))
})
});
if (!response.ok) {
throw new Error(`Route metrics request failed (${response.status})`);
}
const payload = await response.json();
const results = Array.isArray(payload?.results) ? payload.results : [];
const normalizedChunk: Record<string, LocationConnector> = {};
for (let index = 0; index < chunk.length; index += 1) {
const connector = normalizeRouteMetricResult(results[index] || null);
if (!connector) continue;
normalizedChunk[chunk[index].key] = connector;
}
return normalizedChunk;
}
async function loadConnectorMetrics(connectorPairs: ConnectorPair[], fetchVersion: number) {
if (connectorPairs.length === 0) {
connectorMetricsMap = {};
return;
}
try {
const chunks = chunkConnectorPairs(connectorPairs, ROUTE_METRICS_BATCH_SIZE);
const responses = await Promise.all(chunks.map((chunk) => fetchRouteMetricChunk(chunk)));
if (fetchVersion !== activeConnectorFetchVersion) return;
const mergedMap: Record<string, LocationConnector> = {};
for (const chunkMap of responses) {
Object.assign(mergedMap, chunkMap);
}
connectorMetricsMap = mergedMap;
} catch (error) {
if (fetchVersion !== activeConnectorFetchVersion) return;
console.error('Failed to fetch connector route metrics:', error);
if (connectorPairs.length === 0) {
connectorMetricsMap = {};
}
}
}
$: {
const connectorPairs = getConnectorPairs(days);
activeConnectorFetchVersion += 1;
const fetchVersion = activeConnectorFetchVersion;
loadConnectorMetrics(connectorPairs, fetchVersion);
}
$: {
const daySnapshot = days
.map((day) => `${day.date}:${day.items.map((item) => item.id).join(',')}`)
.join('|');
daySnapshot;
activeTemperatureFetchVersion += 1;
const fetchVersion = activeTemperatureFetchVersion;
loadDayTemperatures(days, fetchVersion);
}
function formatDayTemperature(day: DayGroup): string {
const temperature = dayTemperatures[day.date];
if (!temperature?.available || temperature.temperature_c === null) {
return getI18nText('itinerary.temperature_unavailable', 'Temperature unavailable');
}
const rounded = Math.round(temperature.temperature_c);
return `${rounded}°C`;
}
function optimizeDayOrder(dayIndex: number) {
if (!canModify || isSavingOrder) return;
const day = days[dayIndex];
if (!day) return;
const sortableItems = day.items.filter((item) => {
if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) return false;
return !!getCoordinatesFromItineraryItem(item);
});
const nonSortableItems = day.items.filter((item) => {
if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) return true;
return !getCoordinatesFromItineraryItem(item);
});
if (sortableItems.length < 2) {
addToast('info', getI18nText('itinerary.optimize_not_enough_items', 'Not enough stops to optimize'));
return;
}
const remaining = [...sortableItems];
const sorted: ResolvedItineraryItem[] = [];
const firstItem = remaining.shift();
if (!firstItem) return;
sorted.push(firstItem);
while (remaining.length > 0) {
const last = sorted[sorted.length - 1];
const lastCoords = getCoordinatesFromItineraryItem(last);
if (!lastCoords) break;
let nearestIndex = 0;
let nearestDistance = Number.POSITIVE_INFINITY;
for (let index = 0; index < remaining.length; index += 1) {
const candidate = remaining[index];
const candidateCoords = getCoordinatesFromItineraryItem(candidate);
if (!candidateCoords) continue;
const distance = haversineDistanceBetweenCoordinates(lastCoords, candidateCoords);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestIndex = index;
}
}
sorted.push(remaining.splice(nearestIndex, 1)[0]);
}
days[dayIndex].items = [...sorted, ...nonSortableItems];
days = [...days];
isSavingOrder = true;
savingDay = day.date;
saveReorderedItems()
.then((saved) => {
if (saved) {
addToast('success', getI18nText('itinerary.optimize_success', 'Day optimized'));
return;
}
addToast('error', getI18nText('itinerary.optimize_failed', 'Failed to optimize day'));
})
.catch(() => {
addToast('error', getI18nText('itinerary.optimize_failed', 'Failed to optimize day'));
})
.finally(() => {
isSavingOrder = false;
savingDay = null;
});
}
function getFallbackLocationConnector(
currentItem: ResolvedItineraryItem,
nextItem: ResolvedItineraryItem | null
): LocationConnector | null {
if (!nextItem) return null;
const currentType = currentItem.item?.type || '';
const nextType = nextItem.item?.type || '';
if (!isConnectableItemType(currentType) || !isConnectableItemType(nextType)) return null;
const unavailableConnector: LocationConnector = {
distanceLabel: '',
durationLabel: getI18nText('itinerary.route_unavailable', 'Route unavailable'),
mode: 'walking',
unavailable: true
};
const fromCoordinates = getCoordinatesFromItineraryItem(
currentItem,
currentType === 'transportation' ? 'destination' : 'origin'
);
const toCoordinates = getCoordinatesFromItineraryItem(
nextItem,
'origin'
);
if (!fromCoordinates || !toCoordinates) return unavailableConnector;
const distanceKm = haversineDistanceKm(
{ latitude: fromCoordinates.latitude, longitude: fromCoordinates.longitude } as Location,
{ latitude: toCoordinates.latitude, longitude: toCoordinates.longitude } as Location
);
if (distanceKm === null) return unavailableConnector;
const walkingMinutes = (distanceKm / WALKING_SPEED_KMH) * 60;
const drivingMinutes = (distanceKm / DRIVING_SPEED_KMH) * 60;
const useDriving = walkingMinutes > WALKING_THRESHOLD_MINUTES;
return {
distanceLabel: formatTransportationDistance(distanceKm) || `${distanceKm.toFixed(1)} km`,
durationLabel: formatTravelDuration(useDriving ? drivingMinutes : walkingMinutes),
mode: useDriving ? 'driving' : 'walking'
};
}
function getLocationConnector(
currentItem: ResolvedItineraryItem,
nextItem: ResolvedItineraryItem | null
): LocationConnector | null {
const key = getLocationConnectorKey(currentItem, nextItem);
if (key && connectorMetricsMap[key]) {
return connectorMetricsMap[key];
}
return getFallbackLocationConnector(currentItem, nextItem);
}
function buildDirectionsUrl(
currentItem: ResolvedItineraryItem,
nextItem: ResolvedItineraryItem | null,
mode: 'walking' | 'driving' = 'walking'
): string | null {
if (!nextItem) return null;
const currentType = currentItem.item?.type || '';
const nextType = nextItem.item?.type || '';
if (!isConnectableItemType(currentType) || !isConnectableItemType(nextType)) return null;
const fromCoordinates = getCoordinatesFromItineraryItem(
currentItem,
currentType === 'transportation' ? 'destination' : 'origin'
);
const toCoordinates = getCoordinatesFromItineraryItem(
nextItem,
'origin'
);
if (!fromCoordinates || !toCoordinates) return null;
const fromLatitude = fromCoordinates.latitude;
const fromLongitude = fromCoordinates.longitude;
const toLatitude = toCoordinates.latitude;
const toLongitude = toCoordinates.longitude;
if (
fromLatitude === null ||
fromLongitude === null ||
toLatitude === null ||
toLongitude === null
) {
return null;
}
const engine = mode === 'driving' ? 'car' : 'foot';
const route = `${fromLatitude},${fromLongitude};${toLatitude},${toLongitude}`;
return `https://www.openstreetmap.org/directions?engine=fossgis_osrm_${engine}&route=${encodeURIComponent(route)}`;
}
function getI18nText(key: string, fallback: string): string {
const translated = $t(key);
return translated && translated !== key ? translated : fallback;
}
function upsertNote(note: Note) {
const notes = collection.notes ? [...collection.notes] : [];
const idx = notes.findIndex((n) => n.id === note.id);
if (idx >= 0) {
notes[idx] = note;
} else {
notes.push(note);
}
collection = { ...collection, notes };
}
function upsertChecklist(checklist: Checklist) {
const checklists = collection.checklists ? [...collection.checklists] : [];
const idx = checklists.findIndex((c) => c.id === checklist.id);
if (idx >= 0) {
checklists[idx] = checklist;
} else {
checklists.push(checklist);
}
collection = { ...collection, checklists };
}
async function handleNoteUpsert(note: Note) {
// Get the old note to compare dates
const oldNote = collection.notes?.find((n) => n.id === note.id);
const oldDate = oldNote ? normalizeDateOnly(oldNote.date) : null;
const newDate = normalizeDateOnly(note.date);
upsertNote(note);
const targetDate = newDate || pendingAddDate;
try {
// If the date changed, remove old itinerary items for this note on the old date
if (oldDate && newDate && oldDate !== newDate) {
// Remove itinerary items from the old date
const itemsToRemove =
collection.itinerary?.filter(
(it) => it.item?.type === 'note' && it.object_id === note.id && it.date === oldDate
) || [];
for (const item of itemsToRemove) {
await fetch(`/api/itinerary/${item.id}`, { method: 'DELETE' });
}
collection.itinerary =
collection.itinerary?.filter(
(it) => !(it.item?.type === 'note' && it.object_id === note.id && it.date === oldDate)
) || [];
}
const isAlreadyScheduled = collection.itinerary?.some(
(it) => it.item?.type === 'note' && it.object_id === note.id && it.date === targetDate
);
if (targetDate && !isAlreadyScheduled) {
await addItineraryItemForObject('note', note.id, targetDate, !newDate && !!pendingAddDate);
}
} finally {
pendingAddDate = null;
isNoteModalOpen = false;
}
}
async function handleChecklistUpsert(checklist: Checklist) {
// Get the old checklist to compare dates
const oldChecklist = collection.checklists?.find((c) => c.id === checklist.id);
const oldDate = oldChecklist ? normalizeDateOnly(oldChecklist.date) : null;
const newDate = normalizeDateOnly(checklist.date);
upsertChecklist(checklist);
const targetDate = newDate || pendingAddDate;
try {
// If the date changed, remove old itinerary items for this checklist on the old date
if (oldDate && newDate && oldDate !== newDate) {
// Remove itinerary items from the old date
const itemsToRemove =
collection.itinerary?.filter(
(it) =>
it.item?.type === 'checklist' && it.object_id === checklist.id && it.date === oldDate
) || [];
for (const item of itemsToRemove) {
await fetch(`/api/itinerary/${item.id}`, { method: 'DELETE' });
}
collection.itinerary =
collection.itinerary?.filter(
(it) =>
!(
it.item?.type === 'checklist' &&
it.object_id === checklist.id &&
it.date === oldDate
)
) || [];
}
const isAlreadyScheduled = collection.itinerary?.some(
(it) =>
it.item?.type === 'checklist' && it.object_id === checklist.id && it.date === targetDate
);
if (targetDate && !isAlreadyScheduled) {
await addItineraryItemForObject(
'checklist',
checklist.id,
targetDate,
!newDate && !!pendingAddDate
);
}
} finally {
pendingAddDate = null;
isChecklistModalOpen = false;
}
}
// Sync the
// with the collection.locations array
$: if (locationBeingUpdated && locationBeingUpdated.id && collection) {
// Make a shallow copy of locations (ensure array exists)
const locs = collection.locations ? [...collection.locations] : [];
const index = locs.findIndex((loc) => loc.id === locationBeingUpdated.id);
if (index !== -1) {
// Ensure visits are properly synced and replace the item immutably
locs[index] = {
...locs[index],
...locationBeingUpdated,
visits: locationBeingUpdated.visits || locs[index].visits || []
};
} else {
// Prepend new/updated location
locs.unshift({ ...locationBeingUpdated });
}
// Assign back to collection immutably to trigger reactivity
collection = { ...collection, locations: locs };
}
// If a new location was just created and we have a pending add-date,
// attach it to that date in the itinerary.
$: if (
locationBeingUpdated?.id &&
pendingAddDate &&
!addedToItinerary.has(locationBeingUpdated.id)
) {
addItineraryItemForObject('location', locationBeingUpdated.id, pendingAddDate);
// Mark this location as added to prevent duplicates
addedToItinerary.add(locationBeingUpdated.id);
addedToItinerary = addedToItinerary; // trigger reactivity
}
// Sync the lodgingBeingUpdated with the collection.lodging array
$: if (lodgingBeingUpdated && lodgingBeingUpdated.id && collection) {
// Make a shallow copy of lodging (ensure array exists)
const lodgings = collection.lodging ? [...collection.lodging] : [];
const index = lodgings.findIndex((lodge) => lodge.id === lodgingBeingUpdated.id);
if (index !== -1) {
// Replace the item immutably
lodgings[index] = {
...lodgings[index],
...lodgingBeingUpdated
};
} else {
// Prepend new/updated lodging
lodgings.unshift({ ...lodgingBeingUpdated });
}
// Assign back to collection immutably to trigger reactivity
collection = { ...collection, lodging: lodgings };
}
// If a new lodging was just created and we have a pending add-date,
// attach it to that date in the itinerary.
$: {
const targetPendingDate = pendingLodgingAddDate || pendingAddDate;
if (
lodgingBeingUpdated?.id &&
targetPendingDate &&
!addedToItinerary.has(lodgingBeingUpdated.id)
) {
// Normalize check_in to date-only (YYYY-MM-DD) if present
const lodgingCheckInDate = lodgingBeingUpdated.check_in
? String(lodgingBeingUpdated.check_in).split('T')[0]
: null;
const targetDate = lodgingCheckInDate || targetPendingDate;
addItineraryItemForObject('lodging', lodgingBeingUpdated.id, targetDate);
// Mark this lodging as added to prevent duplicates
addedToItinerary.add(lodgingBeingUpdated.id);
addedToItinerary = addedToItinerary; // trigger reactivity
pendingAddDate = null;
pendingLodgingAddDate = null;
}
}
// Sync the transportationBeingUpdated with the collection.transportations array
$: if (transportationBeingUpdated && transportationBeingUpdated.id && collection) {
// Make a shallow copy of transportations (ensure array exists)
const transports = collection.transportations ? [...collection.transportations] : [];
const index = transports.findIndex((t) => t.id === transportationBeingUpdated.id);
if (index !== -1) {
// Replace the item immutably
transports[index] = {
...transports[index],
...transportationBeingUpdated
};
} else {
// Prepend new/updated transportation
transports.unshift({ ...transportationBeingUpdated });
}
// Assign back to collection immutably to trigger reactivity
collection = { ...collection, transportations: transports };
}
// If a new transportation was just created and we have a pending add-date,
// attach it to that date in the itinerary.
$: if (
transportationBeingUpdated?.id &&
pendingAddDate &&
!addedToItinerary.has(transportationBeingUpdated.id)
) {
addItineraryItemForObject('transportation', transportationBeingUpdated.id, pendingAddDate);
// Mark this transportation as added to prevent duplicates
addedToItinerary.add(transportationBeingUpdated.id);
addedToItinerary = addedToItinerary; // trigger reactivity
}
/**
* Get lodging items where the guest is staying overnight on a given date
* (i.e., the date is between check_in and check_out, but NOT the check_in date itself)
*/
function getOvernightLodgingForDate(collection: Collection, dateISO: string): Lodging[] {
if (!collection.lodging) return [];
const targetDate = DateTime.fromISO(dateISO).startOf('day');
// Helper: only include lodging that has been added to the itinerary
function isLodgingScheduled(lodgingId: any): boolean {
return !!collection.itinerary?.some((it) => {
const objectType = it.item?.type || '';
return objectType === 'lodging' && it.object_id === lodgingId;
});
}
return collection.lodging.filter((lodging) => {
// Only consider lodging entries that have both check-in and check-out
if (!lodging.check_in || !lodging.check_out) return false;
// Skip lodgings that are not scheduled in the itinerary
if (!isLodgingScheduled(lodging.id)) return false;
// Extract just the date portion (YYYY-MM-DD) to avoid timezone shifts
const checkInDateStr = lodging.check_in.split('T')[0];
const checkOutDateStr = lodging.check_out.split('T')[0];
const checkIn = DateTime.fromISO(checkInDateStr).startOf('day');
const checkOut = DateTime.fromISO(checkOutDateStr).startOf('day');
// The guest is staying overnight if the target date is between
// check-in (inclusive) and check-out (exclusive). This includes the
// check-in night as requested.
return targetDate >= checkIn && targetDate < checkOut;
});
}
function getGlobalItemsByDate(collection: Collection): Map<string, ResolvedItineraryItem[]> {
const grouped = new Map<string, ResolvedItineraryItem[]>();
// Filter for global items only (no date filter - extract from resolved object)
// Determine collection date range for filtering visits
let collectionStart: DateTime | null = null;
let collectionEnd: DateTime | null = null;
if (collection.start_date)
collectionStart = DateTime.fromISO(collection.start_date).startOf('day');
if (collection.end_date) collectionEnd = DateTime.fromISO(collection.end_date).startOf('day');
collection.itinerary
?.filter((item) => item.is_global)
.forEach((item) => {
const resolved = resolveItineraryItem(item, collection);
const objectType = resolved.item?.type || '';
const datesToAdd = new Set<string>();
// Helper to clamp dates to collection range and dedupe
function addDateIfInRange(date: DateTime) {
if (collectionStart && date < collectionStart) return;
if (collectionEnd && date > collectionEnd) return;
const iso = date.toISODate();
if (iso) datesToAdd.add(iso);
}
// Extract date(s) from the resolved object based on its type
if (objectType === 'location') {
const location = resolved.resolvedObject as Location | null;
if (location?.visits && location.visits.length > 0) {
location.visits.forEach((visit) => {
if (!visit.start_date) return;
const start = DateTime.fromISO(visit.start_date.split('T')[0]).startOf('day');
const end = visit.end_date
? DateTime.fromISO(visit.end_date.split('T')[0]).startOf('day')
: start;
let cursor = start;
// If end is before start, treat as single day
const last = end < start ? start : end;
while (cursor <= last) {
addDateIfInRange(cursor);
cursor = cursor.plus({ days: 1 });
}
});
}
} else if (objectType === 'transportation') {
const transport = resolved.resolvedObject as Transportation | null;
if (transport?.date) {
addDateIfInRange(DateTime.fromISO(transport.date.split('T')[0]).startOf('day'));
}
} else if (objectType === 'lodging') {
const lodging = resolved.resolvedObject as Lodging | null;
if (lodging?.check_in) {
const start = DateTime.fromISO(lodging.check_in.split('T')[0]).startOf('day');
const end = lodging.check_out
? DateTime.fromISO(lodging.check_out.split('T')[0]).startOf('day').minus({ days: 1 })
: start;
const last = end < start ? start : end;
let cursor = start;
while (cursor <= last) {
addDateIfInRange(cursor);
cursor = cursor.plus({ days: 1 });
}
}
} else if (objectType === 'note') {
const note = resolved.resolvedObject as Note | null;
if (note?.date) {
addDateIfInRange(DateTime.fromISO(note.date.split('T')[0]).startOf('day'));
}
} else if (objectType === 'checklist') {
const checklist = resolved.resolvedObject as Checklist | null;
if (checklist?.date) {
addDateIfInRange(DateTime.fromISO(checklist.date.split('T')[0]).startOf('day'));
}
}
// Add the item to each applicable date
datesToAdd.forEach((dateISO) => {
if (!grouped.has(dateISO)) grouped.set(dateISO, []);
grouped.get(dateISO)!.push(resolved);
});
});
// Sort items within each date group by order
grouped.forEach((items) => {
items.sort((a, b) => a.order - b.order);
});
return grouped;
}
function resolveItineraryItem(
item: CollectionItineraryItem,
collection: Collection
): ResolvedItineraryItem {
let resolvedObject = null;
// Resolve based on item.type which tells us the object type
const objectType = item.item?.type || '';
if (objectType === 'location') {
// Find location by ID
resolvedObject = collection.locations?.find((loc) => loc.id === item.object_id) || null;
} else if (objectType === 'transportation') {
resolvedObject = collection.transportations?.find((t) => t.id === item.object_id) || null;
} else if (objectType === 'lodging') {
resolvedObject = collection.lodging?.find((l) => l.id === item.object_id) || null;
} else if (objectType === 'note') {
resolvedObject = collection.notes?.find((n) => n.id === item.object_id) || null;
} else if (objectType === 'checklist') {
resolvedObject = collection.checklists?.find((c) => c.id === item.object_id) || null;
}
return {
...item,
resolvedObject
};
}
function groupItemsByDay(collection: Collection): DayGroup[] {
const globalByDate = getGlobalItemsByDate(collection);
// Build a map of date -> resolved items from existing itinerary entries
const grouped = new Map<string, ResolvedItineraryItem[]>();
collection.itinerary?.forEach((item) => {
if (item.date) {
if (!grouped.has(item.date)) grouped.set(item.date, []);
const resolved = resolveItineraryItem(item, collection);
grouped.get(item.date)!.push(resolved);
}
});
// Determine a date range to display. Prefer explicit collection start/end if present,
// otherwise use min/max dates found in itinerary items. If no dates at all, return []
let startDateISO: string | null = null;
let endDateISO: string | null = null;
if (collection.start_date && collection.end_date) {
startDateISO = collection.start_date;
endDateISO = collection.end_date;
} else {
// derive from itinerary dates if available
const dates = Array.from(grouped.keys()).sort();
if (dates.length > 0) {
startDateISO = dates[0];
endDateISO = dates[dates.length - 1];
}
}
if (!startDateISO || !endDateISO) return [];
const start = DateTime.fromISO(startDateISO).startOf('day');
const end = DateTime.fromISO(endDateISO).startOf('day');
const days: DayGroup[] = [];
for (let dt = start; dt <= end; dt = dt.plus({ days: 1 })) {
const iso = dt.toISODate();
const items = (grouped.get(iso) || []).sort((a, b) => a.order - b.order);
const overnightLodging = getOvernightLodgingForDate(collection, iso);
const { preTimelineLodging, postTimelineLodging } = getDirectionalBoundaryLodging(
collection,
iso
);
const globalDatedItems = globalByDate.get(iso) || [];
// Find day metadata for this date
const dayMetadata = collection.itinerary_days?.find((d) => d.date === iso) || null;
days.push({
date: iso,
displayDate: dt.toFormat('cccc, LLLL d, yyyy'),
items,
preTimelineLodging,
postTimelineLodging,
overnightLodging,
globalDatedItems,
dayMetadata
});
}
return days;
}
function getUnscheduledItems(collection: Collection): any[] {
// Get all items that are linked to collection but not in itinerary
const scheduledIds = new Set(collection.itinerary?.map((item) => item.object_id) || []);
const unscheduled: any[] = [];
// Check locations
collection.locations?.forEach((location) => {
if (!scheduledIds.has(location.id)) {
unscheduled.push({ type: 'location', item: location });
}
});
// Check transportation
collection.transportations?.forEach((transport) => {
if (!scheduledIds.has(transport.id)) {
unscheduled.push({ type: 'transportation', item: transport });
}
});
// Check lodging
collection.lodging?.forEach((lodge) => {
if (!scheduledIds.has(lodge.id)) {
unscheduled.push({ type: 'lodging', item: lodge });
}
});
// Check notes
collection.notes?.forEach((note) => {
if (!scheduledIds.has(note.id)) {
unscheduled.push({ type: 'note', item: note });
}
});
// Check checklists
collection.checklists?.forEach((checklist) => {
if (!scheduledIds.has(checklist.id)) {
unscheduled.push({ type: 'checklist', item: checklist });
}
});
return unscheduled;
}
function isMultiDay(item: ResolvedItineraryItem): boolean {
if (item.start_datetime && item.end_datetime) {
const start = DateTime.fromISO(item.start_datetime);
const end = DateTime.fromISO(item.end_datetime);
return !start.hasSame(end, 'day');
}
return false;
}
function handleDndConsider(dayIndex: number, e: CustomEvent) {
const { items: newItems } = e.detail;
const day = days[dayIndex];
if (!day) return;
// Update the local state immediately for smooth drag feedback
days[dayIndex].items = reinsertBoundaryLodgingItems(day, newItems);
days = [...days];
}
function handleDndConsiderGlobal(e: CustomEvent) {
const { items: newItems } = e.detail;
globalItems = newItems;
}
async function handleDndFinalizeGlobal(e: CustomEvent) {
const { items: newItems, info } = e.detail;
globalItems = newItems;
if (
info.trigger === TRIGGERS.DROPPED_INTO_ZONE ||
info.trigger === TRIGGERS.DROPPED_INTO_ANOTHER
) {
if (!isSavingOrder) {
isSavingOrder = true;
try {
await saveReorderedItems();
} finally {
isSavingOrder = false;
}
}
}
}
async function handleDndFinalize(dayIndex: number, e: CustomEvent) {
const { items: newItems, info } = e.detail;
const day = days[dayIndex];
if (!day) return;
// Update local state
days[dayIndex].items = reinsertBoundaryLodgingItems(day, newItems);
days = [...days];
// Save to backend if item was actually moved (not just considered)
if (
info.trigger === TRIGGERS.DROPPED_INTO_ZONE ||
info.trigger === TRIGGERS.DROPPED_INTO_ANOTHER
) {
// Prevent further dragging while we persist the new order
if (!isSavingOrder) {
isSavingOrder = true;
// mark this day as saving so we can show a spinner on that day's header
savingDay = days[dayIndex]?.date || null;
try {
await saveReorderedItems();
} finally {
isSavingOrder = false;
savingDay = null;
}
}
}
}
async function saveReorderedItems(): Promise<boolean> {
try {
// Collect all items across all days with their new positions
const dayUpdates = days.flatMap((day) =>
day.items
.filter((item) => item.id && !item[SHADOW_ITEM_MARKER_PROPERTY_NAME])
.map((item, index) => ({
id: item.id,
date: day.date,
order: index
}))
);
const globalUpdates = globalItems
.filter((item) => item.id && !item[SHADOW_ITEM_MARKER_PROPERTY_NAME])
.map((item, index) => ({ id: item.id, is_global: true, date: null, order: index }));
const itemsToUpdate = [...dayUpdates, ...globalUpdates];
if (itemsToUpdate.length === 0) {
return true;
}
const response = await fetch('/api/itineraries/reorder/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
items: itemsToUpdate
})
});
if (!response.ok) {
throw new Error('Failed to save item order');
}
// Optionally show success feedback
// console.log('Itinerary order saved successfully');
// Make sure to sync the collection.itinerary with the new order
const updatedItinerary = collection.itinerary?.map((it) => {
const updatedItem = itemsToUpdate.find((upd) => upd.id === it.id);
if (updatedItem) {
return {
...it,
date: updatedItem.date,
is_global: updatedItem.is_global ?? it.is_global,
order: updatedItem.order
};
}
return it;
});
collection.itinerary = updatedItinerary;
// Rebuild canonical local state immediately after persisting reorder
days = groupItemsByDay(collection);
globalItems = (collection.itinerary || [])
.filter((it) => it.is_global)
.map((it) => resolveItineraryItem(it, collection))
.sort((a, b) => a.order - b.order);
return true;
} catch (error) {
console.error('Error saving itinerary order:', error);
// Optionally show error notification to user
alert('Failed to save itinerary order. Please try again.');
return false;
}
}
// Add a trip-wide (global) itinerary item
async function addGlobalItineraryItemForObject(objectType: string, objectId: string) {
const tempId = `temp-global-${Date.now()}`;
const order = globalItems.length;
const newIt = {
id: tempId,
collection: collection.id,
content_type: objectType,
object_id: objectId,
item: { id: objectId, type: objectType },
date: null,
is_global: true,
order,
created_at: new Date().toISOString()
};
collection.itinerary = [...(collection.itinerary || []), newIt];
// trigger reactive globals and days
days = groupItemsByDay(collection);
globalItems = (collection.itinerary || [])
.filter((it) => it.is_global)
.map((it) => resolveItineraryItem(it, collection))
.sort((a, b) => a.order - b.order);
try {
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) {
const j = await res.json().catch(() => ({}));
throw new Error(j.detail || 'Failed to add global itinerary item');
}
const created = await res.json();
collection.itinerary = collection.itinerary.map((it) => (it.id === tempId ? created : it));
// refresh
days = groupItemsByDay(collection);
globalItems = (collection.itinerary || [])
.filter((it) => it.is_global)
.map((it) => resolveItineraryItem(it, collection))
.sort((a, b) => a.order - b.order);
} catch (err) {
console.error('Error creating global itinerary item:', err);
alert('Failed to add item to trip-wide itinerary.');
collection.itinerary = collection.itinerary.filter((it) => it.id !== tempId);
days = groupItemsByDay(collection);
globalItems = (collection.itinerary || [])
.filter((it) => it.is_global)
.map((it) => resolveItineraryItem(it, collection))
.sort((a, b) => a.order - b.order);
}
}
// 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 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;
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) {
itemDate = item.date.split('T')[0]; // Extract date only (YYYY-MM-DD)
}
} else if (type === 'lodging') {
if (item.check_in) {
itemDate = item.check_in.split('T')[0]; // Extract date only (YYYY-MM-DD)
}
} else if (type === 'note') {
if (item.date) {
itemDate = item.date.split('T')[0]; // Extract date only (YYYY-MM-DD)
}
} else if (type === 'checklist') {
if (item.date) {
itemDate = item.date.split('T')[0]; // Extract date only (YYYY-MM-DD)
}
}
// 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) {
if (!dateISO) return false;
if (!collection) return true; // no collection context -> allow
try {
const d = DateTime.fromISO(dateISO).startOf('day');
if (collection.start_date) {
const s = DateTime.fromISO(collection.start_date).startOf('day');
if (d < s) return false;
}
if (collection.end_date) {
const e = DateTime.fromISO(collection.end_date).startOf('day');
if (d > e) return false;
}
return true;
} catch (err) {
return false;
}
}
if (itemDate) {
// 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; 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;
// Identify existing dated itinerary entries for this object
const existingDatedItems = (collection.itinerary || []).filter(
(it) => it.object_id === objectId && it.date && !it.is_global
);
// 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);
}
}
// 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/${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 source itinerary item', dayPickSourceItineraryItemId, e);
}
}
} 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,
objectId: string,
dateISO: string,
updateItemDate: boolean = false
) {
const tempId = `temp-${Date.now()}`;
const day = days.find((d) => d.date === dateISO);
const order = day ? day.items.length : 0;
const newIt = {
id: tempId,
collection: collection.id,
content_type: objectType,
object_id: objectId,
item: { id: objectId, type: objectType },
date: dateISO,
order,
created_at: new Date().toISOString()
};
collection.itinerary = [...(collection.itinerary || []), newIt];
days = groupItemsByDay(collection);
try {
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,
date: dateISO,
order,
update_item_date: updateItemDate,
// Pass source visit ID so backend can update the existing visit
source_visit_id:
objectType === 'location' && updateItemDate && isValidUUID(dayPickSourceVisit?.id)
? dayPickSourceVisit!.id
: undefined
})
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.detail || 'Failed to add itinerary item');
}
const created = await res.json();
collection.itinerary = collection.itinerary.map((it) => (it.id === tempId ? created : it));
pendingAddDate = null;
// 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`;
if (objectType === 'location') {
// 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 || [];
// 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));
}
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) =>
n.id === objectId ? { ...n, date: isoDate } : n
);
}
} else if (objectType === 'checklist') {
if (collection.checklists) {
collection.checklists = collection.checklists.map((c) =>
c.id === objectId ? { ...c, date: isoDate } : c
);
}
}
}
days = groupItemsByDay(collection);
} catch (err) {
console.error('Error creating itinerary item:', err);
alert('Failed to add item to itinerary.');
collection.itinerary = collection.itinerary.filter((it) => it.id !== tempId);
days = groupItemsByDay(collection);
}
}
// Save or update day metadata (name and description)
async function saveDayMetadata(date: string, name: string | null, description: string | null) {
if (!canModify) return;
try {
// Find existing day metadata for this date
const existing = collection.itinerary_days?.find((d) => d.date === date);
if (existing) {
// Update existing day metadata
const response = await fetch(`/api/itinerary-days/${existing.id}/`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: name || null,
description: description || null
})
});
if (!response.ok) throw new Error('Failed to update day metadata');
const updated = await response.json();
// Update collection.itinerary_days immutably
collection.itinerary_days = collection.itinerary_days?.map((d) =>
d.id === existing.id ? updated : d
);
} else {
// Create new day metadata
const response = await fetch('/api/itinerary-days/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
collection: collection.id,
date,
name: name || null,
description: description || null
})
});
if (!response.ok) throw new Error('Failed to create day metadata');
const newDay = await response.json();
// Add to collection.itinerary_days immutably
collection.itinerary_days = [...(collection.itinerary_days || []), newDay];
}
// Trigger reactivity by reassigning collection
collection = { ...collection };
days = groupItemsByDay(collection);
} catch (err) {
console.error('Error saving day metadata:', err);
}
}
</script>
{#if isLocationModalOpen}
<NewLocationModal
on:close={() => {
isLocationModalOpen = false;
locationToEdit = null;
locationBeingUpdated = null;
pendingAddDate = null;
addedToItinerary.clear();
addedToItinerary = addedToItinerary;
}}
{user}
{locationToEdit}
bind:location={locationBeingUpdated}
{collection}
initialVisitDate={pendingAddDate}
/>
{/if}
{#if isLodgingModalOpen}
<LodgingModal
on:close={() => {
isLodgingModalOpen = false;
lodgingToEdit = null;
lodgingBeingUpdated = null;
pendingAddDate = null;
pendingLodgingAddDate = null;
addedToItinerary.clear();
addedToItinerary = addedToItinerary;
}}
{user}
{lodgingToEdit}
bind:lodging={lodgingBeingUpdated}
{collection}
initialVisitDate={pendingLodgingAddDate || pendingAddDate}
/>
{/if}
{#if isTransportationModalOpen}
<TransportationModal
on:close={() => {
isTransportationModalOpen = false;
transportationToEdit = null;
transportationBeingUpdated = null;
pendingAddDate = null;
addedToItinerary.clear();
addedToItinerary = addedToItinerary;
}}
{user}
{transportationToEdit}
bind:transportation={transportationBeingUpdated}
{collection}
initialVisitDate={pendingAddDate}
/>
{/if}
{#if isNoteModalOpen}
<NoteModal
on:close={() => {
pendingAddDate = null;
noteToEdit = null;
isNoteModalOpen = false;
}}
{collection}
{user}
note={noteToEdit}
on:create={(e) => void handleNoteUpsert(e.detail)}
on:save={(e) => void handleNoteUpsert(e.detail)}
initialVisitDate={pendingAddDate}
/>
{/if}
{#if isChecklistModalOpen}
<ChecklistModal
on:close={() => {
pendingAddDate = null;
checklistToEdit = null;
isChecklistModalOpen = false;
}}
{collection}
{user}
checklist={checklistToEdit}
on:create={(e) => void handleChecklistUpsert(e.detail)}
on:save={(e) => void handleChecklistUpsert(e.detail)}
initialVisitDate={pendingAddDate}
/>
{/if}
{#if isItineraryLinkModalOpen}
<ItineraryLinkModal
{collection}
{user}
targetDate={linkModalTargetDate}
displayDate={linkModalDisplayDate}
on:close={() => (isItineraryLinkModalOpen = false)}
on:addItem={(e) => {
const { type, itemId, updateDate } = e.detail;
addItineraryItemForObject(type, itemId, linkModalTargetDate, updateDate);
}}
/>
{/if}
{#if isDayPickModalOpen}
<ItineraryDayPickModal
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;
dayPickSourceItineraryItemId = null;
}}
/>
{/if}
{#if canAutoGenerate}
<div class="alert alert-info shadow-lg mb-6">
<div class="flex-1 flex items-center gap-3 min-w-0">
<Info class="w-6 h-6 stroke-current flex-shrink-0" />
<div class="min-w-0">
<div class="flex items-baseline gap-3">
<h3 class="font-bold truncate">{$t('itinerary.auto_generate_itinerary')}</h3>
</div>
<div class="text-sm opacity-90 truncate">
{$t('itinerary.auto_generate_itinerary_desc')}
</div>
</div>
</div>
<div class="flex-none ml-3">
<button
class="btn btn-sm btn-primary"
disabled={isAutoGenerating}
on:click={handleAutoGenerate}
>
{#if isAutoGenerating}
<span class="loading loading-spinner loading-sm"></span>
{$t('itinerary.generating')}...
{:else}
{$t('itinerary.auto_generate')}
{/if}
</button>
</div>
</div>
{/if}
{#if days.length === 0 && unscheduledItems.length === 0}
<div class="card bg-base-200 shadow-xl">
<div class="card-body text-center py-12">
<CalendarBlank class="w-16 h-16 mx-auto mb-4 opacity-50" />
<h3 class="text-2xl font-bold mb-2">{$t('itinerary.no_itinerary_yet')}</h3>
<p class="opacity-70">{$t('itinerary.start_planning')}</p>
</div>
</div>
{:else}
<div class="space-y-6">
<!-- Trip-wide (Global) Items -->
{#if globalItems.length > 0 || canModify}
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<div class="flex items-center gap-3 mb-4 pb-4 border-b border-base-300">
<div
class="w-6 h-6 rounded-full bg-primary/20 flex items-center justify-center text-primary"
>
<CalendarBlank class="w-4 h-4" />
</div>
<h3 class="text-xl font-bold">
{$t('itinerary.trip_context') || 'Trip Context'}
</h3>
<!-- Info bubble explaining trip context -->
<div class="ml-2 tooltip tooltip-right" data-tip={$t('itinerary.trip_context_info')}>
<button
type="button"
class="btn btn-ghost btn-sm btn-square p-1"
aria-label="Trip context info"
>
<Info class="w-4 h-4" />
</button>
</div>
</div>
{#if globalItems.length === 0}
<div
class="card bg-base-100 shadow-sm border border-dashed border-base-300 p-4 text-center"
>
<div class="card-body p-2">
<CalendarBlank class="w-8 h-8 mx-auto mb-2 opacity-40" />
<p class="opacity-70">
{$t('itinerary.no_trip_context_items')}
</p>
</div>
</div>
{:else}
<div
use:dndzone={{
items: globalItems,
flipDurationMs,
dropTargetStyle: { outline: 'none', border: 'none' },
dragDisabled: isSavingOrder || !canModify,
dropFromOthersDisabled: true
}}
on:consider={handleDndConsiderGlobal}
on:finalize={handleDndFinalizeGlobal}
class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3"
>
{#each globalItems as item (item.id)}
{@const objectType = item.item?.type || ''}
{@const resolvedObj = item.resolvedObject}
<div
class="group relative transition-all duration-200 pointer-events-auto h-full"
animate:flip={{ duration: flipDurationMs }}
>
{#if resolvedObj}
{#if canModify}
<div
class="absolute left-2 top-2 z-20 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
title={$t('itinerary.drag_to_reorder')}
>
<div
class="itinerary-drag-handle btn btn-circle btn-xs btn-ghost bg-base-100/80 backdrop-blur-sm shadow-sm hover:bg-base-200 cursor-grab active:cursor-grabbing"
aria-label={$t('itinerary.drag_to_reorder')}
role="button"
tabindex="0"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 8h16M4 16h16"
/></svg
>
</div>
</div>
{/if}
{#if objectType === 'location'}
<LocationCard
adventure={resolvedObj}
on:edit={handleEditLocation}
on:delete={handleItemDelete}
on:duplicate={handleDuplicateLocation}
itineraryItem={item}
on:removeFromItinerary={handleRemoveItineraryItem}
on:moveToGlobal={(e) => moveItemToGlobal(e.detail.type, e.detail.id)}
{user}
{collection}
compact={true}
showImage={false}
/>
{:else if objectType === 'transportation'}
<TransportationCard
transportation={resolvedObj}
{user}
{collection}
readOnly={!canModify}
compact={true}
showImage={false}
on:delete={handleItemDelete}
itineraryItem={item}
on:removeFromItinerary={handleRemoveItineraryItem}
on:edit={handleEditTransportation}
on:moveToGlobal={(e) => moveItemToGlobal(e.detail.type, e.detail.id)}
/>
{:else if objectType === 'lodging'}
<LodgingCard
lodging={resolvedObj}
{user}
{collection}
itineraryItem={item}
showImage={false}
compact={true}
on:delete={handleItemDelete}
on:removeFromItinerary={handleRemoveItineraryItem}
on:edit={handleEditLodging}
on:moveToGlobal={(e) => moveItemToGlobal(e.detail.type, e.detail.id)}
/>
{:else if objectType === 'note'}
<NoteCard
note={resolvedObj}
{user}
{collection}
on:delete={handleItemDelete}
itineraryItem={item}
on:removeFromItinerary={handleRemoveItineraryItem}
on:edit={handleEditNote}
on:moveToGlobal={(e) => moveItemToGlobal(e.detail.type, e.detail.id)}
/>
{:else if objectType === 'checklist'}
<ChecklistCard
checklist={resolvedObj}
{user}
{collection}
on:delete={handleItemDelete}
itineraryItem={item}
on:removeFromItinerary={handleRemoveItineraryItem}
on:edit={handleEditChecklist}
on:moveToGlobal={(e) => moveItemToGlobal(e.detail.type, e.detail.id)}
/>
{/if}
{:else}
<div class="alert alert-warning">
<span>⚠️ {$t('itinerary.item_not_found')} (ID: {item.object_id})</span>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
<!-- Scheduled Days -->
{#each days as day, dayIndex}
{@const dayNumber = dayIndex + 1}
{@const totalDays = days.length}
{@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 preTimelineLodging = day.preTimelineLodging}
{@const postTimelineLodging = day.postTimelineLodging}
{@const dayTimelineItems = getDayTimelineItems(day)}
{@const firstConnectableItem = getFirstConnectableItem(day.items)}
{@const lastConnectableItem = getLastConnectableItem(day.items)}
{@const noLocationsInDay = !firstConnectableItem && !lastConnectableItem}
{@const shouldCollapseBoundaryLodging =
noLocationsInDay &&
preTimelineLodging?.id &&
postTimelineLodging?.id &&
preTimelineLodging.id === postTimelineLodging.id}
{@const startBoundaryConnector =
preTimelineLodging && firstConnectableItem
? getLocationConnector(preTimelineLodging, firstConnectableItem)
: null}
{@const startBoundaryDirectionsUrl =
preTimelineLodging && firstConnectableItem
? buildDirectionsUrl(
preTimelineLodging,
firstConnectableItem,
startBoundaryConnector?.mode || 'walking'
)
: null}
{@const endBoundaryConnector =
postTimelineLodging && lastConnectableItem
? getLocationConnector(lastConnectableItem, postTimelineLodging)
: null}
{@const endBoundaryDirectionsUrl =
postTimelineLodging && lastConnectableItem
? buildDirectionsUrl(
lastConnectableItem,
postTimelineLodging,
endBoundaryConnector?.mode || 'walking'
)
: null}
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<!-- Day Header (compact, shows date pill + Day X of Y + items + add/save) -->
<div class="flex items-start gap-4 mb-4 pb-4 border-b border-base-300">
<!-- Date pill -->
<div class="flex-none">
<div class="text-center bg-base-300 rounded-lg px-3 py-2 w-20">
<div class="text-xs opacity-70">{weekday}</div>
<div class="text-2xl font-bold -mt-1">{dayOfMonth}</div>
<div class="text-xs opacity-70">{monthAbbrev}</div>
<div class="text-[10px] opacity-80 mt-1">{formatDayTemperature(day)}</div>
</div>
</div>
<!-- Title and meta -->
<div class="flex-1 min-w-0 space-y-1">
<!-- Main date title + optional day name -->
<div class="flex items-baseline gap-2 flex-wrap">
<h3 class="text-lg md:text-xl font-bold">{day.displayDate}</h3>
<!-- Day name - inline with date -->
{#if canModify}
{#if day.dayMetadata?.name}
<input
type="text"
class="input input-ghost text-base font-medium px-1 py-0 -ml-1 focus:bg-base-100 focus:px-2 transition-all flex-shrink min-w-0"
style="width: {(day.dayMetadata.name.length + 5) * 8}px; max-width: 300px;"
value={day.dayMetadata.name}
placeholder="Day name"
on:blur={(e) => {
const newName = e.currentTarget.value.trim() || null;
if (newName !== day.dayMetadata?.name) {
saveDayMetadata(day.date, newName, day.dayMetadata?.description || null);
}
}}
/>
{:else}
<button
type="button"
class="text-sm opacity-40 hover:opacity-100 transition-opacity px-1"
on:click={(e) => {
const input = e.currentTarget.nextElementSibling;
if (input) input.focus();
}}
>
+ {$t('adventures.name')}
</button>
<input
type="text"
class="input input-ghost text-base font-medium px-1 py-0 opacity-0 focus:opacity-100 focus:bg-base-100 focus:px-2 transition-all w-0 focus:w-auto"
style="max-width: 300px;"
placeholder="Day name"
value=""
on:blur={(e) => {
const newName = e.currentTarget.value.trim() || null;
if (newName) {
saveDayMetadata(day.date, newName, day.dayMetadata?.description || null);
} else {
e.currentTarget.classList.add('w-0');
e.currentTarget.classList.remove('w-auto');
}
}}
on:focus={(e) => {
e.currentTarget.classList.remove('w-0');
e.currentTarget.classList.add('w-auto');
}}
/>
{/if}
{:else if day.dayMetadata?.name}
<span class="text-base font-medium opacity-90">— {day.dayMetadata.name}</span>
{/if}
</div>
<!-- Day meta info -->
<div class="text-sm opacity-70 flex items-center gap-3">
<span class="font-medium"
>{$t('calendar.day')} {dayNumber} {$t('worldtravel.of')} {totalDays}</span
>
<span class="opacity-50">•</span>
<span
>{day.items.length}
{day.items.length === 1 ? $t('checklist.item') : $t('checklist.items')}</span
>
{#if day.overnightLodging.length > 0}
<span class="badge badge-info badge-outline badge-sm"
>{$t('adventures.overnight')}</span
>
{/if}
</div>
<!-- Description - shows when present, ghost input when editing -->
{#if canModify}
<textarea
class="textarea textarea-ghost w-full px-2 py-1 text-sm leading-relaxed resize-none focus:bg-base-100 transition-all {day
.dayMetadata?.description
? ''
: 'opacity-40 hover:opacity-70 focus:opacity-100'}"
rows="2"
placeholder={'+ ' + $t('itinerary.add_description') + '...'}
value={day.dayMetadata?.description || ''}
on:blur={(e) => {
const newDesc = e.currentTarget.value.trim() || null;
if (newDesc !== day.dayMetadata?.description) {
saveDayMetadata(day.date, day.dayMetadata?.name || null, newDesc);
}
}}
/>
{:else if day.dayMetadata?.description}
<p class="text-sm leading-relaxed opacity-80 whitespace-pre-wrap px-2 py-1">
{day.dayMetadata.description}
</p>
{/if}
</div>
<!-- Actions: saving indicator + day quick actions -->
<div class="flex-none ml-3 flex items-start gap-2">
{#if savingDay === day.date}
<div>
<div class="badge badge-neutral-300 gap-2 p-2">
<span class="loading loading-spinner loading-sm"></span>
{$t('adventures.saving')}...
</div>
</div>
{/if}
{#if canModify}
<button
type="button"
class="btn btn-sm btn-outline"
disabled={isSavingOrder}
title={$t('itinerary.optimize')}
on:click={() => optimizeDayOrder(dayIndex)}
>
{$t('itinerary.optimize')}
</button>
{/if}
</div>
</div>
<!-- Day Items (vertical timeline with ordered stops) -->
<div>
{#if preTimelineLodging?.resolvedObject}
<div class="mb-3">
<LodgingCard
lodging={preTimelineLodging.resolvedObject}
{user}
{collection}
itineraryItem={preTimelineLodging}
showImage={false}
compact={true}
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,
day.date
)}
/>
{#if startBoundaryConnector}
<div
class="mt-2 rounded-lg border border-base-300 bg-base-200/60 px-3 py-2 text-xs"
>
{#if startBoundaryConnector.unavailable}
<div class="flex items-center gap-2 flex-wrap text-base-content/80">
<span class="inline-flex items-center gap-1 font-medium">
<LocationMarker class="w-3.5 h-3.5" />
{startBoundaryConnector.durationLabel}
</span>
<span class="text-base-content/40">•</span>
{#if startBoundaryDirectionsUrl}
<a
href={startBoundaryDirectionsUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-primary/80 font-medium underline underline-offset-2"
>
<LocationMarker class="w-3.5 h-3.5" />
{getI18nText('itinerary.directions', 'Directions')}
</a>
{/if}
</div>
{:else}
<div class="flex items-center gap-2 flex-wrap text-base-content">
<span class="inline-flex items-center gap-1 font-medium">
{#if startBoundaryConnector.mode === 'driving'}
<Car class="w-3.5 h-3.5" />
{:else}
<Walk class="w-3.5 h-3.5" />
{/if}
{startBoundaryConnector.durationLabel}
</span>
<span class="text-base-content/50">•</span>
<span class="font-medium">{startBoundaryConnector.distanceLabel}</span>
{#if startBoundaryDirectionsUrl}
<span class="text-base-content/50">•</span>
<a
href={startBoundaryDirectionsUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-primary font-medium underline underline-offset-2"
>
<LocationMarker class="w-3.5 h-3.5" />
{getI18nText('itinerary.directions', 'Directions')}
</a>
{/if}
</div>
{/if}
</div>
{/if}
</div>
{/if}
{#if dayTimelineItems.length === 0 && !preTimelineLodging?.resolvedObject && !postTimelineLodging?.resolvedObject}
<div
class="card bg-base-100 shadow-sm border border-dashed border-base-300 p-4 text-center"
>
<div class="card-body p-2">
<CalendarBlank class="w-8 h-8 mx-auto mb-2 opacity-40" />
<p class="opacity-70">{$t('itinerary.no_plans_for_day')}</p>
</div>
</div>
{:else}
<div
use:dndzone={{
items: dayTimelineItems,
flipDurationMs,
dropTargetStyle: { outline: 'none', border: 'none' },
dragDisabled: isSavingOrder || !canModify,
dropFromOthersDisabled: true
}}
on:consider={(e) => handleDndConsider(dayIndex, e)}
on:finalize={(e) => handleDndFinalize(dayIndex, e)}
class="space-y-3"
>
{#each dayTimelineItems as item, index (item.id)}
{@const objectType = item.item?.type || ''}
{@const resolvedObj = item.resolvedObject}
{@const multiDay = isMultiDay(item)}
{@const nextConnectableItem = findNextConnectableItem(dayTimelineItems, index)}
{@const locationConnector = getLocationConnector(item, nextConnectableItem)}
{@const directionsUrl = buildDirectionsUrl(
item,
nextConnectableItem,
locationConnector?.mode || 'walking'
)}
{@const isDraggingShadow = item[SHADOW_ITEM_MARKER_PROPERTY_NAME]}
{@const timelineNumber = index + 1}
<div
class="group relative transition-all duration-200 pointer-events-auto {isDraggingShadow
? 'opacity-40 scale-95'
: ''}"
animate:flip={{ duration: flipDurationMs }}
>
{#if resolvedObj}
<div class="flex gap-3">
<div class="relative flex flex-col items-center shrink-0 pt-1">
<div
class="w-7 h-7 rounded-full bg-primary text-primary-content text-xs font-bold flex items-center justify-center"
>
{timelineNumber}
</div>
{#if index < dayTimelineItems.length - 1}
<div class="w-px bg-base-300 flex-1 min-h-10 mt-1"></div>
{/if}
</div>
<div class="relative flex-1 min-w-0">
{#if canModify}
<div
class="absolute left-0 top-0 z-20 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
title={$t('itinerary.drag_to_reorder')}
>
<div
class="itinerary-drag-handle btn btn-circle btn-xs btn-ghost bg-base-100/80 backdrop-blur-sm shadow-sm hover:bg-base-200 cursor-grab active:cursor-grabbing"
aria-label={$t('itinerary.drag_to_reorder')}
role="button"
tabindex="0"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 8h16M4 16h16"
/>
</svg>
</div>
</div>
{/if}
{#if objectType === 'transportation'}
<TransportationCard
transportation={resolvedObj}
{user}
{collection}
itineraryItem={item}
compact={true}
showImage={false}
on:delete={handleItemDelete}
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,
day.date
)}
/>
{:else}
{#if multiDay && objectType === 'lodging'}
<div class="mb-2">
<div class="badge badge-info badge-xs gap-1 shadow-sm">
<span class="text-xs">{$t('itinerary.multi_day')}</span>
</div>
</div>
{/if}
{#if objectType === 'location'}
<LocationCard
adventure={resolvedObj}
on:edit={handleEditLocation}
on:delete={handleItemDelete}
on:duplicate={handleDuplicateLocation}
itineraryItem={item}
on:removeFromItinerary={handleRemoveItineraryItem}
on:moveToGlobal={(e) =>
moveItemToGlobal(e.detail.type, e.detail.id)}
{user}
{collection}
compact={true}
showImage={false}
on:changeDay={(e) =>
handleOpenDayPickerForItem(
e.detail.type,
e.detail.item,
e.detail.forcePicker,
day.date
)}
/>
{:else if objectType === 'lodging'}
<LodgingCard
lodging={resolvedObj}
{user}
{collection}
itineraryItem={item}
showImage={false}
compact={true}
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,
day.date
)}
/>
{:else if objectType === 'note'}
<NoteCard
note={resolvedObj}
{user}
{collection}
on:delete={handleItemDelete}
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,
day.date
)}
/>
{:else if objectType === 'checklist'}
<ChecklistCard
checklist={resolvedObj}
{user}
{collection}
on:delete={handleItemDelete}
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,
day.date
)}
/>
{/if}
{/if}
{#if locationConnector}
<div
class="mt-2 rounded-lg border border-base-300 bg-base-200/60 px-3 py-2 text-xs"
>
{#if locationConnector.unavailable}
<div class="flex items-center gap-2 flex-wrap text-base-content/80">
<span class="inline-flex items-center gap-1 font-medium">
<LocationMarker class="w-3.5 h-3.5" />
{locationConnector.durationLabel}
</span>
<span class="text-base-content/40">•</span>
{#if directionsUrl}
<a
href={directionsUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-primary/80 font-medium underline underline-offset-2"
>
<LocationMarker class="w-3.5 h-3.5" />
{getI18nText('itinerary.directions', 'Directions')}
</a>
{:else}
<span
class="inline-flex items-center gap-1 text-primary/80 font-medium underline underline-offset-2"
>
<LocationMarker class="w-3.5 h-3.5" />
{getI18nText('itinerary.directions', 'Directions')}
</span>
{/if}
</div>
{:else}
<div class="flex items-center gap-2 flex-wrap text-base-content">
<span class="inline-flex items-center gap-1 font-medium">
{#if locationConnector.mode === 'driving'}
<Car class="w-3.5 h-3.5" />
{:else}
<Walk class="w-3.5 h-3.5" />
{/if}
{locationConnector.durationLabel}
</span>
<span class="text-base-content/50">•</span>
<span class="font-medium">{locationConnector.distanceLabel}</span>
<span class="text-base-content/50">•</span>
{#if directionsUrl}
<a
href={directionsUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-primary font-medium underline underline-offset-2"
>
<LocationMarker class="w-3.5 h-3.5" />
{getI18nText('itinerary.directions', 'Directions')}
</a>
{:else}
<span
class="inline-flex items-center gap-1 text-primary font-medium underline underline-offset-2"
>
<LocationMarker class="w-3.5 h-3.5" />
{getI18nText('itinerary.directions', 'Directions')}
</span>
{/if}
</div>
{/if}
</div>
{/if}
</div>
</div>
{:else}
<!-- Fallback for unresolved items -->
<div class="alert alert-warning">
<span>⚠️ {$t('itinerary.item_not_found')} (ID: {item.object_id})</span>
</div>
{/if}
</div>
{/each}
</div>
{/if}
{#if postTimelineLodging?.resolvedObject && !shouldCollapseBoundaryLodging}
<div class="mt-3">
{#if endBoundaryConnector}
<div
class="mb-2 rounded-lg border border-base-300 bg-base-200/60 px-3 py-2 text-xs"
>
{#if endBoundaryConnector.unavailable}
<div class="flex items-center gap-2 flex-wrap text-base-content/80">
<span class="inline-flex items-center gap-1 font-medium">
<LocationMarker class="w-3.5 h-3.5" />
{endBoundaryConnector.durationLabel}
</span>
<span class="text-base-content/40">•</span>
{#if endBoundaryDirectionsUrl}
<a
href={endBoundaryDirectionsUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-primary/80 font-medium underline underline-offset-2"
>
<LocationMarker class="w-3.5 h-3.5" />
{getI18nText('itinerary.directions', 'Directions')}
</a>
{/if}
</div>
{:else}
<div class="flex items-center gap-2 flex-wrap text-base-content">
<span class="inline-flex items-center gap-1 font-medium">
{#if endBoundaryConnector.mode === 'driving'}
<Car class="w-3.5 h-3.5" />
{:else}
<Walk class="w-3.5 h-3.5" />
{/if}
{endBoundaryConnector.durationLabel}
</span>
<span class="text-base-content/50">•</span>
<span class="font-medium">{endBoundaryConnector.distanceLabel}</span>
{#if endBoundaryDirectionsUrl}
<span class="text-base-content/50">•</span>
<a
href={endBoundaryDirectionsUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-primary font-medium underline underline-offset-2"
>
<LocationMarker class="w-3.5 h-3.5" />
{getI18nText('itinerary.directions', 'Directions')}
</a>
{/if}
</div>
{/if}
</div>
{/if}
<LodgingCard
lodging={postTimelineLodging.resolvedObject}
{user}
{collection}
itineraryItem={postTimelineLodging}
showImage={false}
compact={true}
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,
day.date
)}
/>
</div>
{/if}
{#if canModify}
<div class="mt-4 pt-4 border-t border-base-300 border-dashed">
<div class="flex items-center justify-end gap-3 flex-wrap">
<div class="dropdown dropdown-end z-30">
<button
type="button"
class="btn btn-sm btn-outline"
aria-haspopup="menu"
aria-expanded="false"
>
<Plus class="w-4 h-4" />
{$t('adventures.add')}
</button>
<ul
class="dropdown-content menu p-2 shadow bg-base-300 rounded-box w-56"
role="menu"
>
<li>
<button
type="button"
role="menuitem"
on:click={() => {
linkModalTargetDate = day.date;
linkModalDisplayDate = day.displayDate;
isItineraryLinkModalOpen = true;
}}
>
{$t('itinerary.link_existing_item')}
</button>
</li>
<li class="menu-title">{$t('adventures.create_new')}</li>
<li>
<button
type="button"
role="menuitem"
on:click={() => {
pendingAddDate = day.date;
locationToEdit = null;
locationBeingUpdated = null;
isLocationModalOpen = true;
}}
>
{$t('locations.location')}
</button>
</li>
<li>
<button
type="button"
role="menuitem"
on:click={() => {
pendingAddDate = day.date;
pendingLodgingAddDate = day.date;
lodgingToEdit = null;
lodgingBeingUpdated = null;
isLodgingModalOpen = true;
}}
>
{$t('adventures.lodging')}
</button>
</li>
<li>
<button
type="button"
role="menuitem"
on:click={() => {
pendingAddDate = day.date;
isTransportationModalOpen = true;
}}
>
{$t('adventures.transportation')}
</button>
</li>
<li>
<button
type="button"
role="menuitem"
on:click={() => {
pendingAddDate = day.date;
isNoteModalOpen = true;
}}
>
{$t('adventures.note')}
</button>
</li>
<li>
<button
type="button"
role="menuitem"
on:click={() => {
pendingAddDate = day.date;
isChecklistModalOpen = true;
}}
>
{$t('adventures.checklist')}
</button>
</li>
</ul>
</div>
</div>
</div>
{/if}
</div>
<!-- Overnight Lodging + Dated Trip-wide Indicators (share row to save space) -->
{#if shouldShowOvernightSummary(day) || day.globalDatedItems.length > 0}
<div class="mt-4 pt-4 border-t border-base-300 border-dashed">
<div class="flex flex-wrap gap-6 items-start">
{#if shouldShowOvernightSummary(day)}
<div class="space-y-2 min-w-[240px] flex-1">
<div class="flex items-center gap-2 mb-1 opacity-70">
<Bed class="w-4 h-4" />
<span class="text-sm font-medium">{$t('itinerary.staying_overnight')}</span>
</div>
<div class="space-y-2">
{#each day.overnightLodging as lodging}
{@const checkOut = lodging.check_out
? DateTime.fromISO(lodging.check_out.split('T')[0]).toFormat('LLL d')
: null}
<div
class="flex items-center gap-3 bg-base-100 rounded-lg px-4 py-3 border border-base-300"
>
<div
class="flex items-center justify-center w-8 h-8 rounded-full bg-info/20 text-info"
>
<Bed class="w-4 h-4" />
</div>
<div class="flex-1 min-w-0">
<a
href={`/lodging/${lodging.id}`}
class="hover:text-primary transition-colors duration-200 line-clamp-2 text-md font-semibold"
>
{lodging.name}
</a>
{#if lodging.location}
<p class="text-xs opacity-60 truncate">{lodging.location}</p>
{/if}
</div>
{#if checkOut}
<div class="badge badge-ghost badge-sm">
{$t('adventures.check_out')}: {checkOut}
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
{#if day.globalDatedItems.length > 0}
<div class="space-y-2 min-w-[220px] flex-1">
<div class="flex items-center gap-2 mb-1 opacity-70">
<Globe class="w-4 h-4" />
<span class="text-sm font-medium"
>{$t('itinerary.trip_context') || 'Trip Context'}</span
>
</div>
<div class="space-y-2">
{#each day.globalDatedItems as globalItem (globalItem.id)}
{@const type = globalItem.item?.type || ''}
{@const obj = globalItem.resolvedObject}
{@const name = obj?.name || globalItem.item?.type || 'Item'}
{@const secondary =
type === 'location'
? obj?.location
: type === 'transportation'
? obj?.to_location || obj?.from_location
: type === 'lodging'
? obj?.location
: type === 'note' || type === 'checklist'
? obj?.name
: null}
<div
class="flex items-center gap-3 bg-base-100 rounded-lg px-4 py-3 border border-base-300"
>
<div
class="flex items-center justify-center w-8 h-8 rounded-full bg-base-300 text-base-content"
>
{#if type === 'lodging'}
<Bed class="w-4 h-4" />
{:else if type === 'location'}
{#if obj?.category?.icon}
<span class="text-lg">{obj.category.icon}</span>
{:else}
<LocationMarker class="w-4 h-4" />
{/if}
{:else if type === 'transportation'}
<Car class="w-4 h-4" />
{:else}
<Info class="w-4 h-4" />
{/if}
</div>
<div class="flex-1 min-w-0">
{#if type === 'location' && obj?.id}
<a
href={`/locations/${obj.id}`}
class="hover:text-primary transition-colors text-md font-semibold line-clamp-2 block"
>{name}</a
>
{:else if type === 'lodging' && obj?.id}
<a
href={`/lodging/${obj.id}`}
class="hover:text-primary transition-colors text-md font-semibold line-clamp-2 block"
>{name}</a
>
{:else if type === 'transportation' && obj?.id}
<a
href={`/transportations/${obj.id}`}
class="hover:text-primary transition-colors text-md font-semibold line-clamp-2 block"
>{name}</a
>
{:else}
<p class="text-md font-semibold line-clamp-2">{name}</p>
{/if}
{#if secondary}
<p class="text-xs opacity-60 truncate">{secondary}</p>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
{/each}
<!-- Unscheduled Items -->
{#if unscheduledItems.length > 0}
<div class="card bg-base-200 shadow-xl border-2 border-dashed border-base-300">
<div class="card-body">
<!-- Unscheduled Header -->
<div class="flex items-center gap-3 mb-4 pb-4 border-b border-base-300">
<div class="w-6 h-6 rounded-full border-2 border-dashed border-base-content/30"></div>
<h3 class="text-xl font-bold opacity-70">{$t('itinerary.unscheduled_items')}</h3>
<div class="badge badge-ghost ml-auto">
{unscheduledItems.length}
{unscheduledItems.length === 1 ? $t('checklist.item') : $t('checklist.items')}
</div>
</div>
<p class="text-sm opacity-70 mb-4">
{$t('itinerary.unscheduled_items_desc')}
</p>
<!-- Unscheduled Items List -->
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
{#each unscheduledItems as { type, item }}
<div class="relative group opacity-60 hover:opacity-100 transition-opacity h-full">
<!-- "Add to itinerary" indicator -->
{#if canModify}
<div
class="absolute left-2 top-2 z-20 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
>
<div
class="join bg-base-100/80 rounded-md p-1 shadow-sm backdrop-blur-sm border border-base-300"
>
<button
aria-label={$t('itinerary.add_to_day')}
class="btn btn-circle btn-xs btn-primary join-item shadow-sm"
title={$t('itinerary.add_to_day')}
on:click={() => handleOpenDayPickerForItem(type, item)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
</button>
<button
aria-label={$t('itinerary.add_to_trip_context')}
class="btn btn-circle btn-xs btn-outline join-item shadow-sm"
title={$t('itinerary.add_to_trip_context')}
on:click={() => addGlobalItineraryItemForObject(type, item.id)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 17l4 4 4-4m-4-13v17"
/></svg
>
</button>
</div>
</div>
{/if}
<!-- Display the appropriate card -->
{#if type === 'location'}
<LocationCard
adventure={item}
on:edit={handleEditLocation}
on:delete={handleItemDelete}
on:duplicate={handleDuplicateLocation}
{user}
{collection}
compact={true}
showImage={false}
/>
{:else if type === 'transportation'}
<TransportationCard
transportation={item}
{user}
{collection}
readOnly={!canModify}
compact={true}
showImage={false}
on:delete={handleItemDelete}
on:edit={handleEditTransportation}
/>
{:else if type === 'lodging'}
<LodgingCard
lodging={item}
{user}
{collection}
showImage={false}
compact={true}
on:delete={handleItemDelete}
on:edit={handleEditLodging}
/>
{:else if type === 'note'}
<NoteCard
note={item}
{user}
{collection}
on:delete={handleItemDelete}
on:edit={handleEditNote}
/>
{:else if type === 'checklist'}
<ChecklistCard
checklist={item}
{user}
{collection}
on:delete={handleItemDelete}
on:edit={handleEditChecklist}
/>
{/if}
</div>
{/each}
</div>
</div>
</div>
{/if}
</div>
{/if}