feat: anchor lodging in itinerary days with boundary connectors

This commit is contained in:
2026-03-07 17:28:03 +00:00
parent 7d279883d5
commit 3af4f06944
5 changed files with 381 additions and 106 deletions

View File

@@ -100,6 +100,7 @@
export let collection: Collection | null = null;
export let readOnly: boolean = false;
export let itineraryItem: CollectionItineraryItem | null = null;
export let showImage: boolean = true;
let isWarningModalOpen: boolean = false;
@@ -156,40 +157,42 @@
class="card w-full max-w-md bg-base-300 shadow hover:shadow-md transition-all duration-200 border border-base-300 group"
aria-label="lodging-card"
>
<!-- Image Section with Overlay -->
<div class="relative overflow-hidden rounded-t-2xl">
<CardCarousel images={lodging.images} icon={getLodgingIcon(lodging.type)} name={lodging.name} />
{#if showImage}
<!-- Image Section with Overlay -->
<div class="relative overflow-hidden rounded-t-2xl">
<CardCarousel images={lodging.images} icon={getLodgingIcon(lodging.type)} name={lodging.name} />
<!-- Privacy Indicator -->
<div class="absolute top-2 right-4">
<div
class="tooltip tooltip-left"
data-tip={lodging.is_public ? $t('adventures.public') : $t('adventures.private')}
>
<!-- Privacy Indicator -->
<div class="absolute top-2 right-4">
<div
class="badge badge-sm p-1 rounded-full text-base-content shadow-sm"
role="img"
aria-label={lodging.is_public ? $t('adventures.public') : $t('adventures.private')}
class="tooltip tooltip-left"
data-tip={lodging.is_public ? $t('adventures.public') : $t('adventures.private')}
>
{#if lodging.is_public}
<Eye class="w-4 h-4" />
{:else}
<EyeOff class="w-4 h-4" />
{/if}
<div
class="badge badge-sm p-1 rounded-full text-base-content shadow-sm"
role="img"
aria-label={lodging.is_public ? $t('adventures.public') : $t('adventures.private')}
>
{#if lodging.is_public}
<Eye class="w-4 h-4" />
{:else}
<EyeOff class="w-4 h-4" />
{/if}
</div>
</div>
</div>
</div>
<!-- Category Badge -->
{#if lodging.type}
<div class="absolute bottom-4 left-4">
<div class="badge badge-primary shadow-lg font-medium">
{$t(`lodging.${lodging.type}`)}
{getLodgingIcon(lodging.type)}
<!-- Category Badge -->
{#if lodging.type}
<div class="absolute bottom-4 left-4">
<div class="badge badge-primary shadow-lg font-medium">
{$t(`lodging.${lodging.type}`)}
{getLodgingIcon(lodging.type)}
</div>
</div>
</div>
{/if}
</div>
{/if}
</div>
{/if}
<div class="card-body p-4 space-y-3 min-w-0">
<!-- Header -->
<div class="flex items-start justify-between gap-3">

View File

@@ -55,6 +55,7 @@
date: string;
displayDate: string;
items: ResolvedItineraryItem[];
boundaryLodgingItem: ResolvedItineraryItem | null; // Displayed as day start + end anchors
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
@@ -390,6 +391,7 @@
// 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();
@@ -506,6 +508,8 @@
};
};
type ConnectableItemType = 'location' | 'lodging';
type RouteMetricResult = {
distance_label?: string;
duration_label?: string;
@@ -517,6 +521,83 @@
let connectorMetricsMap: Record<string, LocationConnector> = {};
let activeConnectorFetchVersion = 0;
function isConnectableItemType(type: string): type is ConnectableItemType {
return type === 'location' || type === 'lodging';
}
function getCoordinatesFromItineraryItem(
item: ResolvedItineraryItem | null
): { latitude: number; longitude: number } | null {
if (!item) return null;
const itemType = item.item?.type || '';
if (!isConnectableItemType(itemType)) return null;
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 getFirstLocationItem(items: ResolvedItineraryItem[]): ResolvedItineraryItem | null {
for (const item of items) {
if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) continue;
if ((item.item?.type || '') === 'location') return item;
}
return null;
}
function getLastLocationItem(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 ((item.item?.type || '') === 'location') return item;
}
return null;
}
function getBoundaryLodgingItem(items: ResolvedItineraryItem[]): ResolvedItineraryItem | null {
for (const item of items) {
if (item?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) continue;
if ((item.item?.type || '') === 'lodging') return item;
}
return null;
}
function getDayTimelineItems(day: DayGroup): ResolvedItineraryItem[] {
if (!day.boundaryLodgingItem) return day.items;
return day.items.filter((item) => item.id !== day.boundaryLodgingItem?.id);
}
function reinsertBoundaryLodgingItem(
day: DayGroup,
timelineItems: ResolvedItineraryItem[]
): ResolvedItineraryItem[] {
if (!day.boundaryLodgingItem) return timelineItems;
const boundaryItem = day.boundaryLodgingItem;
if (timelineItems.some((item) => item.id === boundaryItem.id)) return timelineItems;
const previousBoundaryIndex = day.items.findIndex((item) => item.id === boundaryItem.id);
const insertIndex =
previousBoundaryIndex >= 0
? Math.min(previousBoundaryIndex, timelineItems.length)
: Math.min(0, timelineItems.length);
return [
...timelineItems.slice(0, insertIndex),
boundaryItem,
...timelineItems.slice(insertIndex)
];
}
function getLocationConnectorKey(
currentItem: ResolvedItineraryItem,
nextItem: ResolvedItineraryItem | null
@@ -534,39 +615,19 @@
const currentType = currentItem.item?.type || '';
const nextType = nextItem.item?.type || '';
if (currentType !== 'location' || nextType !== 'location') return null;
if (!isConnectableItemType(currentType) || !isConnectableItemType(nextType)) return null;
const currentLocation = currentItem.resolvedObject as Location | null;
const nextLocation = nextItem.resolvedObject as Location | null;
if (!currentLocation || !nextLocation) return null;
const fromLatitude = normalizeCoordinate(currentLocation.latitude);
const fromLongitude = normalizeCoordinate(currentLocation.longitude);
const toLatitude = normalizeCoordinate(nextLocation.latitude);
const toLongitude = normalizeCoordinate(nextLocation.longitude);
if (
fromLatitude === null ||
fromLongitude === null ||
toLatitude === null ||
toLongitude === null
) {
return null;
}
const fromCoordinates = getCoordinatesFromItineraryItem(currentItem);
const toCoordinates = getCoordinatesFromItineraryItem(nextItem);
if (!fromCoordinates || !toCoordinates) return null;
const key = getLocationConnectorKey(currentItem, nextItem);
if (!key) return null;
return {
key,
from: {
latitude: fromLatitude,
longitude: fromLongitude
},
to: {
latitude: toLatitude,
longitude: toLongitude
}
from: fromCoordinates,
to: toCoordinates
};
}
@@ -589,16 +650,34 @@
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) {
for (let index = 0; index < dayGroup.items.length - 1; index += 1) {
const currentItem = dayGroup.items[index];
const dayTimelineItems = getDayTimelineItems(dayGroup);
const firstLocationItem = getFirstLocationItem(dayGroup.items);
const lastLocationItem = getLastLocationItem(dayGroup.items);
if (dayGroup.boundaryLodgingItem && firstLocationItem) {
pushPair(getConnectorPair(dayGroup.boundaryLodgingItem, firstLocationItem));
}
for (let index = 0; index < dayTimelineItems.length - 1; index += 1) {
const currentItem = dayTimelineItems[index];
if (currentItem?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) {
continue;
}
const nextLocationItem = findNextLocationItem(dayGroup.items, index);
const pair = getConnectorPair(currentItem, nextLocationItem);
if (pair) pairs.push(pair);
const nextLocationItem = findNextLocationItem(dayTimelineItems, index);
pushPair(getConnectorPair(currentItem, nextLocationItem));
}
if (dayGroup.boundaryLodgingItem && lastLocationItem) {
pushPair(getConnectorPair(lastLocationItem, dayGroup.boundaryLodgingItem));
}
}
@@ -723,7 +802,7 @@
const currentType = currentItem.item?.type || '';
const nextType = nextItem.item?.type || '';
if (currentType !== 'location' || nextType !== 'location') return null;
if (!isConnectableItemType(currentType) || !isConnectableItemType(nextType)) return null;
const unavailableConnector: LocationConnector = {
distanceLabel: '',
@@ -732,8 +811,8 @@
unavailable: true
};
const currentLocation = currentItem.resolvedObject as Location | null;
const nextLocation = nextItem.resolvedObject as Location | null;
const currentLocation = currentItem.resolvedObject as Location | Lodging | null;
const nextLocation = nextItem.resolvedObject as Location | Lodging | null;
if (!currentLocation || !nextLocation) return unavailableConnector;
const distanceKm = haversineDistanceKm(currentLocation, nextLocation);
@@ -771,10 +850,10 @@
const currentType = currentItem.item?.type || '';
const nextType = nextItem.item?.type || '';
if (currentType !== 'location' || nextType !== 'location') return null;
if (!isConnectableItemType(currentType) || !isConnectableItemType(nextType)) return null;
const currentLocation = currentItem.resolvedObject as Location | null;
const nextLocation = nextItem.resolvedObject as Location | null;
const currentLocation = currentItem.resolvedObject as Location | Lodging | null;
const nextLocation = nextItem.resolvedObject as Location | Lodging | null;
if (!currentLocation || !nextLocation) return null;
const fromLatitude = normalizeCoordinate(currentLocation.latitude);
@@ -1025,21 +1104,26 @@
// If a new lodging was just created and we have a pending add-date,
// attach it to that date in the itinerary.
$: if (
lodgingBeingUpdated?.id &&
pendingAddDate &&
!addedToItinerary.has(lodgingBeingUpdated.id)
) {
$: {
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 || pendingAddDate;
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
@@ -1274,6 +1358,7 @@
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 boundaryLodgingItem = getBoundaryLodgingItem(items);
const overnightLodging = getOvernightLodgingForDate(collection, iso);
const globalDatedItems = globalByDate.get(iso) || [];
@@ -1284,6 +1369,7 @@
date: iso,
displayDate: dt.toFormat('cccc, LLLL d, yyyy'),
items,
boundaryLodgingItem,
overnightLodging,
globalDatedItems,
dayMetadata
@@ -1348,8 +1434,10 @@
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 = newItems;
days[dayIndex].items = reinsertBoundaryLodgingItem(day, newItems);
days = [...days];
}
@@ -1378,9 +1466,11 @@
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 = newItems;
days[dayIndex].items = reinsertBoundaryLodgingItem(day, newItems);
days = [...days];
// Save to backend if item was actually moved (not just considered)
@@ -1992,6 +2082,7 @@
lodgingToEdit = null;
lodgingBeingUpdated = null;
pendingAddDate = null;
pendingLodgingAddDate = null;
addedToItinerary.clear();
addedToItinerary = addedToItinerary;
}}
@@ -1999,7 +2090,7 @@
{lodgingToEdit}
bind:lodging={lodgingBeingUpdated}
{collection}
initialVisitDate={pendingAddDate}
initialVisitDate={pendingLodgingAddDate || pendingAddDate}
/>
{/if}
@@ -2239,10 +2330,11 @@
lodging={resolvedObj}
{user}
{collection}
itineraryItem={item}
on:delete={handleItemDelete}
on:removeFromItinerary={handleRemoveItineraryItem}
on:edit={handleEditLodging}
itineraryItem={item}
showImage={false}
on:delete={handleItemDelete}
on:removeFromItinerary={handleRemoveItineraryItem}
on:edit={handleEditLodging}
on:moveToGlobal={(e) => moveItemToGlobal(e.detail.type, e.detail.id)}
/>
{:else if objectType === 'note'}
@@ -2287,6 +2379,33 @@
{@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 boundaryLodgingItem = day.boundaryLodgingItem}
{@const firstLocationItem = getFirstLocationItem(day.items)}
{@const lastLocationItem = getLastLocationItem(day.items)}
{@const startBoundaryConnector =
boundaryLodgingItem && firstLocationItem
? getLocationConnector(boundaryLodgingItem, firstLocationItem)
: null}
{@const startBoundaryDirectionsUrl =
boundaryLodgingItem && firstLocationItem
? buildDirectionsUrl(
boundaryLodgingItem,
firstLocationItem,
startBoundaryConnector?.mode || 'walking'
)
: null}
{@const endBoundaryConnector =
boundaryLodgingItem && lastLocationItem
? getLocationConnector(lastLocationItem, boundaryLodgingItem)
: null}
{@const endBoundaryDirectionsUrl =
boundaryLodgingItem && lastLocationItem
? buildDirectionsUrl(
lastLocationItem,
boundaryLodgingItem,
endBoundaryConnector?.mode || 'walking'
)
: null}
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
@@ -2428,7 +2547,81 @@
<!-- Day Items (vertical timeline with ordered stops) -->
<div>
{#if day.items.length === 0}
{#if boundaryLodgingItem?.resolvedObject}
<div class="mb-3">
<LodgingCard
lodging={boundaryLodgingItem.resolvedObject}
{user}
{collection}
itineraryItem={boundaryLodgingItem}
showImage={false}
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 getDayTimelineItems(day).length === 0 && !boundaryLodgingItem?.resolvedObject}
<div
class="card bg-base-100 shadow-sm border border-dashed border-base-300 p-4 text-center"
>
@@ -2440,7 +2633,7 @@
{:else}
<div
use:dndzone={{
items: day.items,
items: getDayTimelineItems(day),
flipDurationMs,
dropTargetStyle: { outline: 'none', border: 'none' },
dragDisabled: isSavingOrder || !canModify,
@@ -2450,11 +2643,11 @@
on:finalize={(e) => handleDndFinalize(dayIndex, e)}
class="space-y-3"
>
{#each day.items as item, index (item.id)}
{#each getDayTimelineItems(day) as item, index (item.id)}
{@const objectType = item.item?.type || ''}
{@const resolvedObj = item.resolvedObject}
{@const multiDay = isMultiDay(item)}
{@const nextLocationItem = findNextLocationItem(day.items, index)}
{@const nextLocationItem = findNextLocationItem(getDayTimelineItems(day), index)}
{@const locationConnector = getLocationConnector(item, nextLocationItem)}
{@const directionsUrl = buildDirectionsUrl(
item,
@@ -2462,6 +2655,7 @@
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
@@ -2475,9 +2669,9 @@
<div
class="w-7 h-7 rounded-full bg-primary text-primary-content text-xs font-bold flex items-center justify-center"
>
{index + 1}
{timelineNumber}
</div>
{#if index < day.items.length - 1}
{#if index < getDayTimelineItems(day).length - 1}
<div class="w-px bg-base-300 flex-1 min-h-10 mt-1"></div>
{/if}
</div>
@@ -2627,10 +2821,11 @@
lodging={resolvedObj}
{user}
{collection}
itineraryItem={item}
on:delete={handleItemDelete}
on:removeFromItinerary={handleRemoveItineraryItem}
on:edit={handleEditLodging}
itineraryItem={item}
showImage={false}
on:delete={handleItemDelete}
on:removeFromItinerary={handleRemoveItineraryItem}
on:edit={handleEditLodging}
on:moveToGlobal={(e) =>
moveItemToGlobal(e.detail.type, e.detail.id)}
on:changeDay={(e) =>
@@ -2756,6 +2951,81 @@
</div>
{/if}
{#if boundaryLodgingItem?.resolvedObject}
<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={boundaryLodgingItem.resolvedObject}
{user}
{collection}
itineraryItem={boundaryLodgingItem}
showImage={false}
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">
@@ -2805,12 +3075,13 @@
<button
type="button"
role="menuitem"
on:click={() => {
pendingAddDate = day.date;
lodgingToEdit = null;
lodgingBeingUpdated = null;
isLodgingModalOpen = true;
}}
on:click={() => {
pendingAddDate = day.date;
pendingLodgingAddDate = day.date;
lodgingToEdit = null;
lodgingBeingUpdated = null;
isLodgingModalOpen = true;
}}
>
{$t('adventures.lodging')}
</button>
@@ -3081,11 +3352,12 @@
/>
{:else if type === 'lodging'}
<LodgingCard
lodging={item}
{user}
{collection}
on:delete={handleItemDelete}
on:edit={handleEditLodging}
lodging={item}
{user}
{collection}
showImage={false}
on:delete={handleItemDelete}
on:edit={handleEditLodging}
/>
{:else if type === 'note'}
<NoteCard