feat: refine itinerary flow and add OSRM connector metrics

This commit is contained in:
2026-03-07 11:54:13 +00:00
parent 246d836459
commit a3d12bf4b2
15 changed files with 785 additions and 110 deletions

View File

@@ -37,6 +37,7 @@
export let collection: Collection | null = null;
export let readOnly: boolean = false;
export let compact: boolean = false; // For compact grid display in itinerary
export let showImage: boolean = true;
export let itineraryItem: CollectionItineraryPlanner | null = null;
let isCollectionModalOpen: boolean = false;
@@ -278,86 +279,92 @@
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="location-card"
>
<!-- Image Section with Overlay -->
<div class="relative overflow-hidden rounded-t-2xl">
<CardCarousel images={adventure.images} icon={adventure.category?.icon} name={adventure.name} />
{#if showImage}
<!-- Image Section with Overlay -->
<div class="relative overflow-hidden rounded-t-2xl">
<CardCarousel
images={adventure.images}
icon={adventure.category?.icon}
name={adventure.name}
/>
<!-- Status Overlay (icon-only) -->
<div class="absolute top-2 left-4 flex items-center gap-3">
<div
class="tooltip tooltip-right"
data-tip={adventure.is_visited ? $t('adventures.visited') : $t('adventures.not_visited')}
>
{#if adventure.is_visited}
<div class="badge badge-sm badge-success p-1 rounded-full shadow-sm">
<Calendar class="w-4 h-4" />
</div>
{:else}
<div class="badge badge-sm badge-warning p-1 rounded-full shadow-sm">
<Clock class="w-4 h-4" />
</div>
{/if}
</div>
</div>
<!-- Privacy Indicator -->
<div class="absolute top-2 right-4">
<div
class="tooltip tooltip-left"
data-tip={adventure.is_public ? $t('adventures.public') : $t('adventures.private')}
>
<!-- Status Overlay (icon-only) -->
<div class="absolute top-2 left-4 flex items-center gap-3">
<div
class="badge badge-sm p-1 rounded-full text-base-content shadow-sm"
role="img"
aria-label={adventure.is_public ? $t('adventures.public') : $t('adventures.private')}
class="tooltip tooltip-right"
data-tip={adventure.is_visited ? $t('adventures.visited') : $t('adventures.not_visited')}
>
{#if adventure.is_public}
<Eye class="w-4 h-4" />
{#if adventure.is_visited}
<div class="badge badge-sm badge-success p-1 rounded-full shadow-sm">
<Calendar class="w-4 h-4" />
</div>
{:else}
<EyeOff class="w-4 h-4" />
<div class="badge badge-sm badge-warning p-1 rounded-full shadow-sm">
<Clock class="w-4 h-4" />
</div>
{/if}
</div>
</div>
</div>
<!-- Category Badge -->
{#if adventure.category}
<div class="absolute bottom-4 left-4">
<a
href="/locations?types={adventure.category.name}"
class="badge badge-primary shadow-lg font-medium cursor-pointer hover:brightness-110 transition-all"
<!-- Privacy Indicator -->
<div class="absolute top-2 right-4">
<div
class="tooltip tooltip-left"
data-tip={adventure.is_public ? $t('adventures.public') : $t('adventures.private')}
>
{adventure.category.display_name}
{adventure.category.icon}
</a>
</div>
{/if}
<!-- Creator Avatar -->
{#if adventure.user && collection}
<div class="absolute bottom-4 right-4">
<div class="tooltip tooltip-left" data-tip={creatorDisplayName}>
<div class="avatar">
<div class="w-7 h-7 rounded-full ring-2 ring-white/40 shadow">
{#if adventure.user.profile_pic}
<img
src={adventure.user.profile_pic}
alt={creatorDisplayName}
class="rounded-full object-cover"
/>
{:else}
<div
class="w-7 h-7 bg-gradient-to-br from-primary to-secondary rounded-full flex items-center justify-center text-primary-content font-semibold text-xs"
>
{creatorInitials.toUpperCase()}
</div>
{/if}
</div>
<div
class="badge badge-sm p-1 rounded-full text-base-content shadow-sm"
role="img"
aria-label={adventure.is_public ? $t('adventures.public') : $t('adventures.private')}
>
{#if adventure.is_public}
<Eye class="w-4 h-4" />
{:else}
<EyeOff class="w-4 h-4" />
{/if}
</div>
</div>
</div>
{/if}
</div>
<!-- Category Badge -->
{#if adventure.category}
<div class="absolute bottom-4 left-4">
<a
href="/locations?types={adventure.category.name}"
class="badge badge-primary shadow-lg font-medium cursor-pointer hover:brightness-110 transition-all"
>
{adventure.category.display_name}
{adventure.category.icon}
</a>
</div>
{/if}
<!-- Creator Avatar -->
{#if adventure.user && collection}
<div class="absolute bottom-4 right-4">
<div class="tooltip tooltip-left" data-tip={creatorDisplayName}>
<div class="avatar">
<div class="w-7 h-7 rounded-full ring-2 ring-white/40 shadow">
{#if adventure.user.profile_pic}
<img
src={adventure.user.profile_pic}
alt={creatorDisplayName}
class="rounded-full object-cover"
/>
{:else}
<div
class="w-7 h-7 bg-gradient-to-br from-primary to-secondary rounded-full flex items-center justify-center text-primary-content font-semibold text-xs"
>
{creatorInitials.toUpperCase()}
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}
</div>
{/if}
<!-- Content Section -->
<div

View File

@@ -421,6 +421,306 @@
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 haversineDistanceKm(from: Location, to: Location): number | null {
if (
from.latitude === null ||
from.longitude === null ||
to.latitude === null ||
to.longitude === null
) {
return null;
}
if (
!Number.isFinite(from.latitude) ||
!Number.isFinite(from.longitude) ||
!Number.isFinite(to.latitude) ||
!Number.isFinite(to.longitude)
) {
return null;
}
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));
const distanceKm = earthRadiusKm * c;
return Number.isFinite(distanceKm) ? distanceKm : null;
}
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';
};
type ConnectorPair = {
key: string;
from: {
latitude: number;
longitude: number;
};
to: {
latitude: number;
longitude: number;
};
};
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 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 (currentType !== 'location' || nextType !== 'location') return null;
const currentLocation = currentItem.resolvedObject as Location | null;
const nextLocation = nextItem.resolvedObject as Location | null;
if (!currentLocation || !nextLocation) return null;
const fromLatitude = currentLocation.latitude;
const fromLongitude = currentLocation.longitude;
const toLatitude = nextLocation.latitude;
const toLongitude = nextLocation.longitude;
if (
fromLatitude === null ||
fromLongitude === null ||
toLatitude === null ||
toLongitude === null ||
!Number.isFinite(fromLatitude) ||
!Number.isFinite(fromLongitude) ||
!Number.isFinite(toLatitude) ||
!Number.isFinite(toLongitude)
) {
return null;
}
const key = getLocationConnectorKey(currentItem, nextItem);
if (!key) return null;
return {
key,
from: {
latitude: fromLatitude,
longitude: fromLongitude
},
to: {
latitude: toLatitude,
longitude: toLongitude
}
};
}
function getConnectorPairs(dayGroups: DayGroup[]): ConnectorPair[] {
const pairs: ConnectorPair[] = [];
for (const dayGroup of dayGroups) {
for (let index = 0; index < dayGroup.items.length - 1; index += 1) {
const pair = getConnectorPair(dayGroup.items[index], dayGroup.items[index + 1]);
if (pair) pairs.push(pair);
}
}
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);
connectorMetricsMap = {};
}
}
$: {
const connectorPairs = getConnectorPairs(days);
activeConnectorFetchVersion += 1;
const fetchVersion = activeConnectorFetchVersion;
loadConnectorMetrics(connectorPairs, fetchVersion);
}
function getFallbackLocationConnector(
currentItem: ResolvedItineraryItem,
nextItem: ResolvedItineraryItem | null
): LocationConnector | null {
if (!nextItem) return null;
const currentType = currentItem.item?.type || '';
const nextType = nextItem.item?.type || '';
if (currentType !== 'location' || nextType !== 'location') return null;
const currentLocation = currentItem.resolvedObject as Location | null;
const nextLocation = nextItem.resolvedObject as Location | null;
if (!currentLocation || !nextLocation) return null;
const distanceKm = haversineDistanceKm(currentLocation, nextLocation);
if (distanceKm === null) return null;
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 editTransportationInline(transportation: Transportation) {
handleEditTransportation({ detail: transportation } as CustomEvent<Transportation>);
}
@@ -1833,6 +2133,7 @@
{user}
{collection}
compact={true}
showImage={false}
/>
{:else if objectType === 'transportation'}
<TransportationCard
@@ -2065,6 +2366,8 @@
{@const objectType = item.item?.type || ''}
{@const resolvedObj = item.resolvedObject}
{@const multiDay = isMultiDay(item)}
{@const nextItem = index < day.items.length - 1 ? day.items[index + 1] : null}
{@const locationConnector = getLocationConnector(item, nextItem)}
{@const isDraggingShadow = item[SHADOW_ITEM_MARKER_PROPERTY_NAME]}
<div
@@ -2217,6 +2520,7 @@
{user}
{collection}
compact={true}
showImage={false}
on:changeDay={(e) =>
handleOpenDayPickerForItem(
e.detail.type,
@@ -2284,6 +2588,21 @@
/>
{/if}
{/if}
{#if locationConnector}
<div
class="mt-2 rounded-lg border border-dashed border-base-300 bg-base-100/70 px-3 py-2 text-xs opacity-80"
>
<div class="flex items-center gap-2 flex-wrap">
<span class="font-medium">{locationConnector.distanceLabel}</span>
<span class="opacity-50">•</span>
<span>
{locationConnector.mode === 'driving' ? '🚗' : '🚶'}
{locationConnector.durationLabel}
</span>
</div>
</div>
{/if}
</div>
</div>
{:else}
@@ -2299,8 +2618,7 @@
{#if canModify}
<div class="mt-4 pt-4 border-t border-base-300 border-dashed">
<div class="flex items-center justify-between gap-3 flex-wrap">
<p class="text-sm opacity-70">{$t('itinerary.add_place')}</p>
<div class="flex items-center justify-end gap-3 flex-wrap">
<div class="dropdown dropdown-end z-30">
<button
type="button"
@@ -2611,6 +2929,7 @@
{user}
{collection}
compact={true}
showImage={false}
/>
{:else if type === 'transportation'}
<TransportationCard

View File

@@ -26,7 +26,8 @@
"aestheticDark": "Aesthetic Dark",
"aqua": "Aqua",
"northernLights": "Northern Lights",
"dim": "Dim"
"dim": "Dim",
"catppuccinMocha": "Catppuccin Mocha"
},
"navigation": "Navigation"
},

View File

@@ -26,7 +26,8 @@
"aestheticDark": "Estetic întunecat",
"aqua": "Acvatic",
"northernLights": "Luminiile Nordului",
"dim": "Întunecă"
"dim": "Întunecă",
"catppuccinMocha": "Catppuccin Mocha"
},
"navigation": "Navigație"
},

View File

@@ -1,24 +1,35 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
import type { Location } from '$lib/types';
import type { SlimCollection } from '$lib/types';
const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
const defaultStats = {
visited_country_count: 0,
visited_region_count: 0,
visited_city_count: 0,
location_count: 0,
trips_count: 0
};
export const load = (async (event) => {
if (!event.locals.user) {
return redirect(302, '/login');
} else {
let adventures: Location[] = [];
let collections: SlimCollection[] = [];
let initialFetch = await event.fetch(`${serverEndpoint}/api/locations/`, {
headers: {
Cookie: `sessionid=${event.cookies.get('sessionid')}`
},
credentials: 'include'
});
let initialFetch = await event.fetch(
`${serverEndpoint}/api/collections/?order_by=updated_at&order_direction=desc&nested=true`,
{
headers: {
Cookie: `sessionid=${event.cookies.get('sessionid')}`
},
credentials: 'include'
}
);
let stats = null;
let stats = { ...defaultStats };
let res = await event.fetch(
`${serverEndpoint}/api/stats/counts/${event.locals.user.username}/`,
@@ -31,24 +42,27 @@ export const load = (async (event) => {
if (!res.ok) {
console.error('Failed to fetch user stats');
} else {
stats = await res.json();
const statsPayload = await res.json();
stats = {
...defaultStats,
...(statsPayload || {})
};
}
if (!initialFetch.ok) {
let error_message = await initialFetch.json();
console.error(error_message);
console.error('Failed to fetch visited adventures');
console.error('Failed to fetch recent collections');
return redirect(302, '/login');
} else {
let res = await initialFetch.json();
let visited = res.results as Location[];
// only get the first 3 adventures or less if there are less than 3
adventures = visited.slice(0, 3);
let recentCollections = res.results as SlimCollection[];
collections = recentCollections.slice(0, 3);
}
return {
props: {
adventures,
collections,
stats
}
};

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import LocationCard from '$lib/components/cards/LocationCard.svelte';
import CollectionCard from '$lib/components/cards/CollectionCard.svelte';
import type { PageData } from './$types';
import { t } from 'svelte-i18n';
@@ -14,7 +14,7 @@
export let data: PageData;
const user = data.user;
const recentAdventures = data.props.adventures;
const recentCollections = (data.props as any).collections ?? [];
const stats = data.props.stats;
// Calculate completion percentage
@@ -153,8 +153,8 @@
</div>
</div>
<!-- Recent Adventures Section -->
{#if recentAdventures.length > 0}
<!-- Recent Collections Section -->
{#if recentCollections.length > 0}
<div class="mb-8">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
@@ -162,20 +162,20 @@
<CalendarClock class="w-6 h-6 text-primary" />
</div>
<div>
<h2 class="text-3xl font-bold">{$t('dashboard.recent_adventures')}</h2>
<h2 class="text-3xl font-bold">{$t('navbar.collections')}</h2>
<p class="text-base-content/60">{$t('home.latest_travel_experiences')}</p>
</div>
</div>
<a href="/locations" class="btn btn-ghost gap-2">
<a href="/collections" class="btn btn-ghost gap-2">
{$t('dashboard.view_all')}
<span class="badge badge-primary">{stats.location_count}</span>
<span class="badge badge-primary">{stats.trips_count ?? 0}</span>
</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{#each recentAdventures as adventure}
{#each recentCollections as collection}
<div class="adventure-card">
<LocationCard {adventure} readOnly user={null} />
<CollectionCard {collection} type="viewonly" {user} />
</div>
{/each}
</div>
@@ -183,7 +183,7 @@
{/if}
<!-- Empty State / Inspiration -->
{#if recentAdventures.length === 0}
{#if recentCollections.length === 0}
<div
class="empty-state card bg-gradient-to-br from-base-100 to-base-200 shadow-2xl border border-base-300"
>
@@ -197,19 +197,19 @@
<h2
class="text-3xl font-bold mb-4 bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent"
>
{$t('dashboard.no_recent_adventures')}
{$t('collection.no_collections_yet')}
</h2>
<p class="text-lg text-base-content/60 mb-8 max-w-md mx-auto leading-relaxed">
{$t('dashboard.document_some_adventures')}
{$t('collection.create_first')}
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a
href="/locations"
href="/collections"
class="btn btn-primary btn-lg gap-2 shadow-lg hover:shadow-xl transition-all duration-300"
>
<Plus class="w-5 h-5" />
{$t('map.add_location')}
{$t('collection.new_collection')}
</a>
<a href="/worldtravel" class="btn btn-outline btn-lg gap-2">
<FlagCheckeredVariantIcon class="w-5 h-5" />