feat: anchor lodging in itinerary days with boundary connectors
This commit is contained in:
@@ -98,9 +98,9 @@ Voyage aims to be simple, beautiful, and open to everyone — inheriting Adventu
|
|||||||
- Upload trails and activities to your locations to remember your experiences with detailed maps and stats.
|
- Upload trails and activities to your locations to remember your experiences with detailed maps and stats.
|
||||||
- **Plan Your Next Trip** 📃: Take the guesswork out of planning your next adventure with an easy-to-use itinerary planner.
|
- **Plan Your Next Trip** 📃: Take the guesswork out of planning your next adventure with an easy-to-use itinerary planner.
|
||||||
- Itineraries can be created for any number of days and can include multiple destinations.
|
- Itineraries can be created for any number of days and can include multiple destinations.
|
||||||
- A timeline-style day view shows ordered stops with numbered markers and compact location cards (no image banners) for a dense overview.
|
- A timeline-style day view shows ordered stops with numbered markers and compact location cards (no image banners) for a dense overview. Lodging appears as boundary anchors — before the first stop and after the last stop of each day — giving a clear at-a-glance view of where you're staying. Lodging cards use the same compact style (no image header) as location cards within the itinerary.
|
||||||
- Connector rows between consecutive locations display distance and travel time powered by [OSRM](https://project-osrm.org/) routing (walking if ≤ 20 min, driving otherwise), with automatic haversine fallback when OSRM is unavailable. Self-hosted OSRM instances are supported via the `OSRM_BASE_URL` environment variable. Transportation items appear as separate compact connector rows showing mode, duration, and distance.
|
- Connector rows between consecutive locations display distance and travel time powered by [OSRM](https://project-osrm.org/) routing (walking if ≤ 20 min, driving otherwise), with automatic haversine fallback when OSRM is unavailable. Self-hosted OSRM instances are supported via the `OSRM_BASE_URL` environment variable. Transportation items appear as separate compact connector rows showing mode, duration, and distance. Boundary transitions between lodging and the first/last stop of the day are also shown as connector rows.
|
||||||
- Each day has a single `+ Add` control to insert new places, and day-level quick actions include Auto-fill and an Optimize placeholder.
|
- Each day has a single `+ Add` control to insert new places, and day-level quick actions include Auto-fill and an Optimize placeholder. Lodging added from within a day is automatically scheduled to that day.
|
||||||
- Itineraries include many planning features like flight information, notes, checklists, and links to external resources.
|
- Itineraries include many planning features like flight information, notes, checklists, and links to external resources.
|
||||||
- Itineraries can be shared with friends and family for collaborative planning.
|
- Itineraries can be shared with friends and family for collaborative planning.
|
||||||
- **Share Your Experiences** 📸: Share your adventures with friends and family and collaborate on trips together.
|
- **Share Your Experiences** 📸: Share your adventures with friends and family and collaborate on trips together.
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ Voyage is a full-fledged travel companion. With Voyage, you can log your adventu
|
|||||||
- Upload trails and activities to your locations to remember your experiences with detailed maps and stats.
|
- Upload trails and activities to your locations to remember your experiences with detailed maps and stats.
|
||||||
- **Plan Your Next Trip** 📃: Take the guesswork out of planning your next adventure with an easy-to-use itinerary planner.
|
- **Plan Your Next Trip** 📃: Take the guesswork out of planning your next adventure with an easy-to-use itinerary planner.
|
||||||
- Itineraries can be created for any number of days and can include multiple destinations.
|
- Itineraries can be created for any number of days and can include multiple destinations.
|
||||||
- A timeline-style day view shows ordered stops with numbered markers and compact location cards (no image banners) for a dense overview.
|
- A timeline-style day view shows ordered stops with numbered markers and compact location cards (no image banners) for a dense overview. Lodging appears as boundary anchors — before the first stop and after the last stop of each day — giving a clear at-a-glance view of where you're staying. Lodging cards use the same compact style (no image header) as location cards within the itinerary.
|
||||||
- Connector rows between consecutive locations display distance and travel time powered by [OSRM](https://project-osrm.org/) routing (walking if ≤ 20 min, driving otherwise), with automatic haversine fallback when OSRM is unavailable. Self-hosted OSRM instances are supported via the `OSRM_BASE_URL` environment variable. Transportation items appear as separate compact connector rows showing mode, duration, and distance.
|
- Connector rows between consecutive locations display distance and travel time powered by [OSRM](https://project-osrm.org/) routing (walking if ≤ 20 min, driving otherwise), with automatic haversine fallback when OSRM is unavailable. Self-hosted OSRM instances are supported via the `OSRM_BASE_URL` environment variable. Transportation items appear as separate compact connector rows showing mode, duration, and distance. Boundary transitions between lodging and the first/last stop of the day are also shown as connector rows.
|
||||||
- Each day has a single `+ Add` control to insert new places, and day-level quick actions include Auto-fill and an Optimize placeholder.
|
- Each day has a single `+ Add` control to insert new places, and day-level quick actions include Auto-fill and an Optimize placeholder. Lodging added from within a day is automatically scheduled to that day.
|
||||||
- Itineraries include many planning features like flight information, notes, checklists, and links to external resources.
|
- Itineraries include many planning features like flight information, notes, checklists, and links to external resources.
|
||||||
- Itineraries can be shared with friends and family for collaborative planning.
|
- Itineraries can be shared with friends and family for collaborative planning.
|
||||||
- **Share Your Experiences** 📸: Share your adventures with friends and family and collaborate on trips together.
|
- **Share Your Experiences** 📸: Share your adventures with friends and family and collaborate on trips together.
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ The term "Location" is now used instead of "Adventure" - the usage remains the s
|
|||||||
|
|
||||||
#### Collections
|
#### Collections
|
||||||
|
|
||||||
- **Collection**: a collection is a way to group locations together. Collections are flexible and can be used in many ways. When no start or end date is added to a collection, it acts like a folder to group locations together. When a start and end date is added to a collection, it acts like a trip to group locations together that were visited during that time period. With start and end dates, the collection is transformed into a full itinerary with a timeline-style day view — each day displays numbered stops as compact cards (without image banners), connector rows between consecutive locations showing distance and travel time via OSRM routing (walking if ≤ 20 min, driving otherwise) with automatic haversine fallback when OSRM is unavailable, and a single `+ Add` control for inserting new places. Day-level quick actions include Auto-fill (populates an empty itinerary from dated records) and an Optimize placeholder for future route optimization. The itinerary also includes a map showing the route taken between locations. Your most recently updated collections also appear on the dashboard. For example, you could have a collection for a trip to Europe with dates so you can plan where you want to visit, a collection of local hiking trails, or a collection for a list of restaurants you want to try.
|
- **Collection**: a collection is a way to group locations together. Collections are flexible and can be used in many ways. When no start or end date is added to a collection, it acts like a folder to group locations together. When a start and end date is added to a collection, it acts like a trip to group locations together that were visited during that time period. With start and end dates, the collection is transformed into a full itinerary with a timeline-style day view — each day displays numbered stops as compact cards (without image banners), connector rows between consecutive locations showing distance and travel time via OSRM routing (walking if ≤ 20 min, driving otherwise) with automatic haversine fallback when OSRM is unavailable, and a single `+ Add` control for inserting new places. Lodging appears as boundary anchors at the start and end of each day (before the first stop and after the last stop), with connector rows linking them to adjacent locations. Day-level quick actions include Auto-fill (populates an empty itinerary from dated records) and an Optimize placeholder for future route optimization. The itinerary also includes a map showing the route taken between locations. Your most recently updated collections also appear on the dashboard. For example, you could have a collection for a trip to Europe with dates so you can plan where you want to visit, a collection of local hiking trails, or a collection for a list of restaurants you want to try.
|
||||||
- **Transportation**: a transportation is a collection exclusive feature that allows you to add transportation information to your trip. In the itinerary timeline view, transportation items appear as compact connector rows between stops — showing the travel mode, duration, and distance. This can be used to show the route taken between locations and the mode of transportation used. It can also be used to track flight information, such as flight number and departure time.
|
- **Transportation**: a transportation is a collection exclusive feature that allows you to add transportation information to your trip. In the itinerary timeline view, transportation items appear as compact connector rows between stops — showing the travel mode, duration, and distance. This can be used to show the route taken between locations and the mode of transportation used. It can also be used to track flight information, such as flight number and departure time.
|
||||||
- **Lodging**: a lodging is a collection exclusive feature that allows you to add lodging information to your trip. This can be used to plan where you will stay during your trip and add notes about the lodging location. It can also be used to track reservation information, such as reservation number and check-in time.
|
- **Lodging**: a lodging is a collection exclusive feature that allows you to add lodging information to your trip. This can be used to plan where you will stay during your trip and add notes about the lodging location. It can also be used to track reservation information, such as reservation number and check-in time. In the itinerary timeline view, lodging is displayed as compact cards (without image headers) positioned as boundary anchors — before the first stop and after the last stop of each day. Lodging added from within a specific day is automatically scheduled to that day. Connector rows show boundary transitions between lodging and adjacent locations.
|
||||||
- **Note**: a note is a collection exclusive feature that allows you to add notes to your trip. This can be used to add additional information about your trip, such as a summary of the trip or a list of things to do. Notes can be assigned to a specific day of the trip to help organize the information.
|
- **Note**: a note is a collection exclusive feature that allows you to add notes to your trip. This can be used to add additional information about your trip, such as a summary of the trip or a list of things to do. Notes can be assigned to a specific day of the trip to help organize the information.
|
||||||
- **Checklist**: a checklist is a collection exclusive feature that allows you to add a checklist to your trip. This can be used to create a list of things to do during your trip or for planning purposes like packing lists. Checklists can be assigned to a specific day of the trip to help organize the information.
|
- **Checklist**: a checklist is a collection exclusive feature that allows you to add a checklist to your trip. This can be used to create a list of things to do during your trip or for planning purposes like packing lists. Checklists can be assigned to a specific day of the trip to help organize the information.
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,7 @@
|
|||||||
export let collection: Collection | null = null;
|
export let collection: Collection | null = null;
|
||||||
export let readOnly: boolean = false;
|
export let readOnly: boolean = false;
|
||||||
export let itineraryItem: CollectionItineraryItem | null = null;
|
export let itineraryItem: CollectionItineraryItem | null = null;
|
||||||
|
export let showImage: boolean = true;
|
||||||
|
|
||||||
let isWarningModalOpen: boolean = false;
|
let isWarningModalOpen: boolean = false;
|
||||||
|
|
||||||
@@ -156,6 +157,7 @@
|
|||||||
class="card w-full max-w-md bg-base-300 shadow hover:shadow-md transition-all duration-200 border border-base-300 group"
|
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"
|
aria-label="lodging-card"
|
||||||
>
|
>
|
||||||
|
{#if showImage}
|
||||||
<!-- Image Section with Overlay -->
|
<!-- Image Section with Overlay -->
|
||||||
<div class="relative overflow-hidden rounded-t-2xl">
|
<div class="relative overflow-hidden rounded-t-2xl">
|
||||||
<CardCarousel images={lodging.images} icon={getLodgingIcon(lodging.type)} name={lodging.name} />
|
<CardCarousel images={lodging.images} icon={getLodgingIcon(lodging.type)} name={lodging.name} />
|
||||||
@@ -190,6 +192,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="card-body p-4 space-y-3 min-w-0">
|
<div class="card-body p-4 space-y-3 min-w-0">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
date: string;
|
date: string;
|
||||||
displayDate: string;
|
displayDate: string;
|
||||||
items: ResolvedItineraryItem[];
|
items: ResolvedItineraryItem[];
|
||||||
|
boundaryLodgingItem: ResolvedItineraryItem | null; // Displayed as day start + end anchors
|
||||||
overnightLodging: Lodging[]; // Lodging where guest is staying overnight (not check-in day)
|
overnightLodging: Lodging[]; // Lodging where guest is staying overnight (not check-in day)
|
||||||
globalDatedItems: ResolvedItineraryItem[]; // Trip-wide items that still carry a date
|
globalDatedItems: ResolvedItineraryItem[]; // Trip-wide items that still carry a date
|
||||||
dayMetadata: CollectionItineraryDay | null; // Day name and description
|
dayMetadata: CollectionItineraryDay | null; // Day name and description
|
||||||
@@ -390,6 +391,7 @@
|
|||||||
|
|
||||||
// When opening a "create new item" modal we store the target date here
|
// When opening a "create new item" modal we store the target date here
|
||||||
let pendingAddDate: string | null = null;
|
let pendingAddDate: string | null = null;
|
||||||
|
let pendingLodgingAddDate: string | null = null;
|
||||||
// Track if we've already added this location to the itinerary
|
// Track if we've already added this location to the itinerary
|
||||||
let addedToItinerary: Set<string> = new Set();
|
let addedToItinerary: Set<string> = new Set();
|
||||||
|
|
||||||
@@ -506,6 +508,8 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ConnectableItemType = 'location' | 'lodging';
|
||||||
|
|
||||||
type RouteMetricResult = {
|
type RouteMetricResult = {
|
||||||
distance_label?: string;
|
distance_label?: string;
|
||||||
duration_label?: string;
|
duration_label?: string;
|
||||||
@@ -517,6 +521,83 @@
|
|||||||
let connectorMetricsMap: Record<string, LocationConnector> = {};
|
let connectorMetricsMap: Record<string, LocationConnector> = {};
|
||||||
let activeConnectorFetchVersion = 0;
|
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(
|
function getLocationConnectorKey(
|
||||||
currentItem: ResolvedItineraryItem,
|
currentItem: ResolvedItineraryItem,
|
||||||
nextItem: ResolvedItineraryItem | null
|
nextItem: ResolvedItineraryItem | null
|
||||||
@@ -534,39 +615,19 @@
|
|||||||
|
|
||||||
const currentType = currentItem.item?.type || '';
|
const currentType = currentItem.item?.type || '';
|
||||||
const nextType = nextItem.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 fromCoordinates = getCoordinatesFromItineraryItem(currentItem);
|
||||||
const nextLocation = nextItem.resolvedObject as Location | null;
|
const toCoordinates = getCoordinatesFromItineraryItem(nextItem);
|
||||||
if (!currentLocation || !nextLocation) return null;
|
if (!fromCoordinates || !toCoordinates) 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 key = getLocationConnectorKey(currentItem, nextItem);
|
const key = getLocationConnectorKey(currentItem, nextItem);
|
||||||
if (!key) return null;
|
if (!key) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
from: {
|
from: fromCoordinates,
|
||||||
latitude: fromLatitude,
|
to: toCoordinates
|
||||||
longitude: fromLongitude
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
latitude: toLatitude,
|
|
||||||
longitude: toLongitude
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,16 +650,34 @@
|
|||||||
|
|
||||||
function getConnectorPairs(dayGroups: DayGroup[]): ConnectorPair[] {
|
function getConnectorPairs(dayGroups: DayGroup[]): ConnectorPair[] {
|
||||||
const pairs: 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 (const dayGroup of dayGroups) {
|
||||||
for (let index = 0; index < dayGroup.items.length - 1; index += 1) {
|
const dayTimelineItems = getDayTimelineItems(dayGroup);
|
||||||
const currentItem = dayGroup.items[index];
|
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]) {
|
if (currentItem?.[SHADOW_ITEM_MARKER_PROPERTY_NAME]) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const nextLocationItem = findNextLocationItem(dayGroup.items, index);
|
const nextLocationItem = findNextLocationItem(dayTimelineItems, index);
|
||||||
const pair = getConnectorPair(currentItem, nextLocationItem);
|
pushPair(getConnectorPair(currentItem, nextLocationItem));
|
||||||
if (pair) pairs.push(pair);
|
}
|
||||||
|
|
||||||
|
if (dayGroup.boundaryLodgingItem && lastLocationItem) {
|
||||||
|
pushPair(getConnectorPair(lastLocationItem, dayGroup.boundaryLodgingItem));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -723,7 +802,7 @@
|
|||||||
|
|
||||||
const currentType = currentItem.item?.type || '';
|
const currentType = currentItem.item?.type || '';
|
||||||
const nextType = nextItem.item?.type || '';
|
const nextType = nextItem.item?.type || '';
|
||||||
if (currentType !== 'location' || nextType !== 'location') return null;
|
if (!isConnectableItemType(currentType) || !isConnectableItemType(nextType)) return null;
|
||||||
|
|
||||||
const unavailableConnector: LocationConnector = {
|
const unavailableConnector: LocationConnector = {
|
||||||
distanceLabel: '',
|
distanceLabel: '',
|
||||||
@@ -732,8 +811,8 @@
|
|||||||
unavailable: true
|
unavailable: true
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentLocation = currentItem.resolvedObject as Location | null;
|
const currentLocation = currentItem.resolvedObject as Location | Lodging | null;
|
||||||
const nextLocation = nextItem.resolvedObject as Location | null;
|
const nextLocation = nextItem.resolvedObject as Location | Lodging | null;
|
||||||
if (!currentLocation || !nextLocation) return unavailableConnector;
|
if (!currentLocation || !nextLocation) return unavailableConnector;
|
||||||
|
|
||||||
const distanceKm = haversineDistanceKm(currentLocation, nextLocation);
|
const distanceKm = haversineDistanceKm(currentLocation, nextLocation);
|
||||||
@@ -771,10 +850,10 @@
|
|||||||
|
|
||||||
const currentType = currentItem.item?.type || '';
|
const currentType = currentItem.item?.type || '';
|
||||||
const nextType = nextItem.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 currentLocation = currentItem.resolvedObject as Location | Lodging | null;
|
||||||
const nextLocation = nextItem.resolvedObject as Location | null;
|
const nextLocation = nextItem.resolvedObject as Location | Lodging | null;
|
||||||
if (!currentLocation || !nextLocation) return null;
|
if (!currentLocation || !nextLocation) return null;
|
||||||
|
|
||||||
const fromLatitude = normalizeCoordinate(currentLocation.latitude);
|
const fromLatitude = normalizeCoordinate(currentLocation.latitude);
|
||||||
@@ -1025,21 +1104,26 @@
|
|||||||
|
|
||||||
// If a new lodging was just created and we have a pending add-date,
|
// If a new lodging was just created and we have a pending add-date,
|
||||||
// attach it to that date in the itinerary.
|
// attach it to that date in the itinerary.
|
||||||
$: if (
|
$: {
|
||||||
|
const targetPendingDate = pendingLodgingAddDate || pendingAddDate;
|
||||||
|
if (
|
||||||
lodgingBeingUpdated?.id &&
|
lodgingBeingUpdated?.id &&
|
||||||
pendingAddDate &&
|
targetPendingDate &&
|
||||||
!addedToItinerary.has(lodgingBeingUpdated.id)
|
!addedToItinerary.has(lodgingBeingUpdated.id)
|
||||||
) {
|
) {
|
||||||
// Normalize check_in to date-only (YYYY-MM-DD) if present
|
// Normalize check_in to date-only (YYYY-MM-DD) if present
|
||||||
const lodgingCheckInDate = lodgingBeingUpdated.check_in
|
const lodgingCheckInDate = lodgingBeingUpdated.check_in
|
||||||
? String(lodgingBeingUpdated.check_in).split('T')[0]
|
? String(lodgingBeingUpdated.check_in).split('T')[0]
|
||||||
: null;
|
: null;
|
||||||
const targetDate = lodgingCheckInDate || pendingAddDate;
|
const targetDate = lodgingCheckInDate || targetPendingDate;
|
||||||
|
|
||||||
addItineraryItemForObject('lodging', lodgingBeingUpdated.id, targetDate);
|
addItineraryItemForObject('lodging', lodgingBeingUpdated.id, targetDate);
|
||||||
// Mark this lodging as added to prevent duplicates
|
// Mark this lodging as added to prevent duplicates
|
||||||
addedToItinerary.add(lodgingBeingUpdated.id);
|
addedToItinerary.add(lodgingBeingUpdated.id);
|
||||||
addedToItinerary = addedToItinerary; // trigger reactivity
|
addedToItinerary = addedToItinerary; // trigger reactivity
|
||||||
|
pendingAddDate = null;
|
||||||
|
pendingLodgingAddDate = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync the transportationBeingUpdated with the collection.transportations array
|
// Sync the transportationBeingUpdated with the collection.transportations array
|
||||||
@@ -1274,6 +1358,7 @@
|
|||||||
for (let dt = start; dt <= end; dt = dt.plus({ days: 1 })) {
|
for (let dt = start; dt <= end; dt = dt.plus({ days: 1 })) {
|
||||||
const iso = dt.toISODate();
|
const iso = dt.toISODate();
|
||||||
const items = (grouped.get(iso) || []).sort((a, b) => a.order - b.order);
|
const items = (grouped.get(iso) || []).sort((a, b) => a.order - b.order);
|
||||||
|
const boundaryLodgingItem = getBoundaryLodgingItem(items);
|
||||||
const overnightLodging = getOvernightLodgingForDate(collection, iso);
|
const overnightLodging = getOvernightLodgingForDate(collection, iso);
|
||||||
const globalDatedItems = globalByDate.get(iso) || [];
|
const globalDatedItems = globalByDate.get(iso) || [];
|
||||||
|
|
||||||
@@ -1284,6 +1369,7 @@
|
|||||||
date: iso,
|
date: iso,
|
||||||
displayDate: dt.toFormat('cccc, LLLL d, yyyy'),
|
displayDate: dt.toFormat('cccc, LLLL d, yyyy'),
|
||||||
items,
|
items,
|
||||||
|
boundaryLodgingItem,
|
||||||
overnightLodging,
|
overnightLodging,
|
||||||
globalDatedItems,
|
globalDatedItems,
|
||||||
dayMetadata
|
dayMetadata
|
||||||
@@ -1348,8 +1434,10 @@
|
|||||||
|
|
||||||
function handleDndConsider(dayIndex: number, e: CustomEvent) {
|
function handleDndConsider(dayIndex: number, e: CustomEvent) {
|
||||||
const { items: newItems } = e.detail;
|
const { items: newItems } = e.detail;
|
||||||
|
const day = days[dayIndex];
|
||||||
|
if (!day) return;
|
||||||
// Update the local state immediately for smooth drag feedback
|
// Update the local state immediately for smooth drag feedback
|
||||||
days[dayIndex].items = newItems;
|
days[dayIndex].items = reinsertBoundaryLodgingItem(day, newItems);
|
||||||
days = [...days];
|
days = [...days];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1378,9 +1466,11 @@
|
|||||||
|
|
||||||
async function handleDndFinalize(dayIndex: number, e: CustomEvent) {
|
async function handleDndFinalize(dayIndex: number, e: CustomEvent) {
|
||||||
const { items: newItems, info } = e.detail;
|
const { items: newItems, info } = e.detail;
|
||||||
|
const day = days[dayIndex];
|
||||||
|
if (!day) return;
|
||||||
|
|
||||||
// Update local state
|
// Update local state
|
||||||
days[dayIndex].items = newItems;
|
days[dayIndex].items = reinsertBoundaryLodgingItem(day, newItems);
|
||||||
days = [...days];
|
days = [...days];
|
||||||
|
|
||||||
// Save to backend if item was actually moved (not just considered)
|
// Save to backend if item was actually moved (not just considered)
|
||||||
@@ -1992,6 +2082,7 @@
|
|||||||
lodgingToEdit = null;
|
lodgingToEdit = null;
|
||||||
lodgingBeingUpdated = null;
|
lodgingBeingUpdated = null;
|
||||||
pendingAddDate = null;
|
pendingAddDate = null;
|
||||||
|
pendingLodgingAddDate = null;
|
||||||
addedToItinerary.clear();
|
addedToItinerary.clear();
|
||||||
addedToItinerary = addedToItinerary;
|
addedToItinerary = addedToItinerary;
|
||||||
}}
|
}}
|
||||||
@@ -1999,7 +2090,7 @@
|
|||||||
{lodgingToEdit}
|
{lodgingToEdit}
|
||||||
bind:lodging={lodgingBeingUpdated}
|
bind:lodging={lodgingBeingUpdated}
|
||||||
{collection}
|
{collection}
|
||||||
initialVisitDate={pendingAddDate}
|
initialVisitDate={pendingLodgingAddDate || pendingAddDate}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -2240,6 +2331,7 @@
|
|||||||
{user}
|
{user}
|
||||||
{collection}
|
{collection}
|
||||||
itineraryItem={item}
|
itineraryItem={item}
|
||||||
|
showImage={false}
|
||||||
on:delete={handleItemDelete}
|
on:delete={handleItemDelete}
|
||||||
on:removeFromItinerary={handleRemoveItineraryItem}
|
on:removeFromItinerary={handleRemoveItineraryItem}
|
||||||
on:edit={handleEditLodging}
|
on:edit={handleEditLodging}
|
||||||
@@ -2287,6 +2379,33 @@
|
|||||||
{@const weekday = DateTime.fromISO(day.date).toFormat('ccc')}
|
{@const weekday = DateTime.fromISO(day.date).toFormat('ccc')}
|
||||||
{@const dayOfMonth = DateTime.fromISO(day.date).toFormat('d')}
|
{@const dayOfMonth = DateTime.fromISO(day.date).toFormat('d')}
|
||||||
{@const monthAbbrev = DateTime.fromISO(day.date).toFormat('LLL')}
|
{@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 bg-base-200 shadow-xl">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -2428,7 +2547,81 @@
|
|||||||
|
|
||||||
<!-- Day Items (vertical timeline with ordered stops) -->
|
<!-- Day Items (vertical timeline with ordered stops) -->
|
||||||
<div>
|
<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
|
<div
|
||||||
class="card bg-base-100 shadow-sm border border-dashed border-base-300 p-4 text-center"
|
class="card bg-base-100 shadow-sm border border-dashed border-base-300 p-4 text-center"
|
||||||
>
|
>
|
||||||
@@ -2440,7 +2633,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
use:dndzone={{
|
use:dndzone={{
|
||||||
items: day.items,
|
items: getDayTimelineItems(day),
|
||||||
flipDurationMs,
|
flipDurationMs,
|
||||||
dropTargetStyle: { outline: 'none', border: 'none' },
|
dropTargetStyle: { outline: 'none', border: 'none' },
|
||||||
dragDisabled: isSavingOrder || !canModify,
|
dragDisabled: isSavingOrder || !canModify,
|
||||||
@@ -2450,11 +2643,11 @@
|
|||||||
on:finalize={(e) => handleDndFinalize(dayIndex, e)}
|
on:finalize={(e) => handleDndFinalize(dayIndex, e)}
|
||||||
class="space-y-3"
|
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 objectType = item.item?.type || ''}
|
||||||
{@const resolvedObj = item.resolvedObject}
|
{@const resolvedObj = item.resolvedObject}
|
||||||
{@const multiDay = isMultiDay(item)}
|
{@const multiDay = isMultiDay(item)}
|
||||||
{@const nextLocationItem = findNextLocationItem(day.items, index)}
|
{@const nextLocationItem = findNextLocationItem(getDayTimelineItems(day), index)}
|
||||||
{@const locationConnector = getLocationConnector(item, nextLocationItem)}
|
{@const locationConnector = getLocationConnector(item, nextLocationItem)}
|
||||||
{@const directionsUrl = buildDirectionsUrl(
|
{@const directionsUrl = buildDirectionsUrl(
|
||||||
item,
|
item,
|
||||||
@@ -2462,6 +2655,7 @@
|
|||||||
locationConnector?.mode || 'walking'
|
locationConnector?.mode || 'walking'
|
||||||
)}
|
)}
|
||||||
{@const isDraggingShadow = item[SHADOW_ITEM_MARKER_PROPERTY_NAME]}
|
{@const isDraggingShadow = item[SHADOW_ITEM_MARKER_PROPERTY_NAME]}
|
||||||
|
{@const timelineNumber = index + 1}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="group relative transition-all duration-200 pointer-events-auto {isDraggingShadow
|
class="group relative transition-all duration-200 pointer-events-auto {isDraggingShadow
|
||||||
@@ -2475,9 +2669,9 @@
|
|||||||
<div
|
<div
|
||||||
class="w-7 h-7 rounded-full bg-primary text-primary-content text-xs font-bold flex items-center justify-center"
|
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>
|
</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>
|
<div class="w-px bg-base-300 flex-1 min-h-10 mt-1"></div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -2628,6 +2822,7 @@
|
|||||||
{user}
|
{user}
|
||||||
{collection}
|
{collection}
|
||||||
itineraryItem={item}
|
itineraryItem={item}
|
||||||
|
showImage={false}
|
||||||
on:delete={handleItemDelete}
|
on:delete={handleItemDelete}
|
||||||
on:removeFromItinerary={handleRemoveItineraryItem}
|
on:removeFromItinerary={handleRemoveItineraryItem}
|
||||||
on:edit={handleEditLodging}
|
on:edit={handleEditLodging}
|
||||||
@@ -2756,6 +2951,81 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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}
|
{#if canModify}
|
||||||
<div class="mt-4 pt-4 border-t border-base-300 border-dashed">
|
<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="flex items-center justify-end gap-3 flex-wrap">
|
||||||
@@ -2807,6 +3077,7 @@
|
|||||||
role="menuitem"
|
role="menuitem"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
pendingAddDate = day.date;
|
pendingAddDate = day.date;
|
||||||
|
pendingLodgingAddDate = day.date;
|
||||||
lodgingToEdit = null;
|
lodgingToEdit = null;
|
||||||
lodgingBeingUpdated = null;
|
lodgingBeingUpdated = null;
|
||||||
isLodgingModalOpen = true;
|
isLodgingModalOpen = true;
|
||||||
@@ -3084,6 +3355,7 @@
|
|||||||
lodging={item}
|
lodging={item}
|
||||||
{user}
|
{user}
|
||||||
{collection}
|
{collection}
|
||||||
|
showImage={false}
|
||||||
on:delete={handleItemDelete}
|
on:delete={handleItemDelete}
|
||||||
on:edit={handleEditLodging}
|
on:edit={handleEditLodging}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user