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

@@ -54,7 +54,7 @@ Voyage aims to be simple, beautiful, and open to everyone — inheriting Adventu
<img src="./brand/screenshots/map-satellite.png" alt="Location Details" />
<p>View a 3D representation of your locations and activities on the map, allowing for a more immersive exploration of your travel history.</p>
<img src="./brand/screenshots/dashboard.png" alt="Dashboard" />
<p>Displays a summary of your locations, including your world travel stats.</p>
<p>Displays a summary of your world travel stats and your most recently updated collections.</p>
<img src="./brand/screenshots/itinerary.png" alt="Itinerary" />
<p>Plan your adventures with a timeline-style itinerary planner. Each day shows numbered stops, compact transportation connectors between locations, and inline controls for adding places. Drag-and-drop reordering, day-level actions, and multiple views help you build the perfect trip.</p>
<img src="./brand/screenshots/countries.png" alt="Countries" />
@@ -98,8 +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.
- **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.
- A timeline-style day view shows ordered stops with numbered markers, compact transportation connector rows (mode, duration, distance), and inline add-place rows per day.
- Day-level quick actions include Auto-fill (to populate an empty itinerary from dated records) and an Optimize placeholder for future route optimization.
- A timeline-style day view shows ordered stops with numbered markers and compact location cards (no image banners) for a dense overview.
- 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.
- Each day has a single `+ Add` control to insert new places, and day-level quick actions include Auto-fill and an Optimize placeholder.
- 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.
- **Share Your Experiences** 📸: Share your adventures with friends and family and collaborate on trips together.

View File

@@ -26,6 +26,7 @@ EMAIL_BACKEND='console'
# DEFAULT_FROM_EMAIL='user@example.com'
# GOOGLE_MAPS_API_KEY='key'
# OSRM_BASE_URL='https://router.project-osrm.org' # replace with self-host URL if needed (e.g. http://osrm:5000)
# ACCOUNT_EMAIL_VERIFICATION='none' # 'none', 'optional', 'mandatory' # You can change this as needed for your environment

View File

@@ -25,6 +25,7 @@ router.register(r'activities', ActivityViewSet, basename='activities')
router.register(r'visits', VisitViewSet, basename='visits')
router.register(r'itineraries', ItineraryViewSet, basename='itineraries')
router.register(r'itinerary-days', ItineraryDayViewSet, basename='itinerary-days')
router.register(r'route-metrics', RouteMetricsViewSet, basename='route-metrics')
urlpatterns = [
# Include the router under the 'api/' prefix

View File

@@ -19,3 +19,4 @@ from .trail_view import *
from .activity_view import *
from .visit_view import *
from .itinerary_view import *
from .route_metrics_view import *

View File

@@ -0,0 +1,326 @@
import hashlib
import logging
import math
from urllib.parse import urlparse, urlunparse
import requests
from django.conf import settings
from django.core.cache import cache
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
logger = logging.getLogger(__name__)
class RouteMetricsViewSet(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
MAX_PAIRS = 50
CACHE_TIMEOUT_SECONDS = 60 * 60 * 24
WALKING_SPEED_KMH = 5
DRIVING_SPEED_KMH = 60
WALKING_THRESHOLD_MINUTES = 20
OSRM_HEADERS = {"User-Agent": "Voyage Server"}
OSRM_DEFAULT_BASE_URL = "https://router.project-osrm.org"
@action(detail=False, methods=["post"])
def query(self, request):
pairs = request.data.get("pairs")
if not isinstance(pairs, list):
return Response(
{"error": "'pairs' must be a list"}, status=status.HTTP_400_BAD_REQUEST
)
if len(pairs) > self.MAX_PAIRS:
return Response(
{
"error": f"A maximum of {self.MAX_PAIRS} pairs is allowed per request"
},
status=status.HTTP_400_BAD_REQUEST,
)
results = []
for index, pair in enumerate(pairs):
try:
from_lat, from_lon, to_lat, to_lon = self._parse_pair(pair)
except ValueError as error:
logger.warning(
"Skipping invalid route-metrics pair at index %s: %s", index, error
)
results.append(
self._invalid_metrics_result(
error_code="invalid_pair", message="Invalid route pair payload"
)
)
continue
cache_key = self._build_cache_key(from_lat, from_lon, to_lat, to_lon)
cached_result = self._safe_cache_get(cache_key)
if cached_result:
results.append(cached_result)
continue
try:
result = self._calculate_osrm_metrics(
from_lat, from_lon, to_lat, to_lon
)
except Exception as error:
logger.warning(
"OSRM metrics unavailable for pair index %s; using haversine fallback (%s)",
index,
error.__class__.__name__,
)
try:
result = self._calculate_haversine_fallback(
from_lat, from_lon, to_lat, to_lon
)
except Exception as fallback_error:
logger.warning(
"Haversine fallback failed for pair index %s; returning invalid placeholder (%s)",
index,
fallback_error.__class__.__name__,
)
result = self._invalid_metrics_result(
error_code="compute_failed",
message="Route metrics unavailable",
)
self._safe_cache_set(cache_key, result)
results.append(result)
return Response({"results": results})
def _parse_pair(self, pair):
if not isinstance(pair, dict):
raise ValueError("Each pair must be an object")
from_data = pair.get("from")
to_data = pair.get("to")
if not isinstance(from_data, dict) or not isinstance(to_data, dict):
raise ValueError("Each pair must include 'from' and 'to' objects")
from_lat = self._parse_coordinate(from_data.get("latitude"), "from.latitude")
from_lon = self._parse_coordinate(from_data.get("longitude"), "from.longitude")
to_lat = self._parse_coordinate(to_data.get("latitude"), "to.latitude")
to_lon = self._parse_coordinate(to_data.get("longitude"), "to.longitude")
if not (-90 <= from_lat <= 90) or not (-90 <= to_lat <= 90):
raise ValueError("Latitude must be between -90 and 90")
if not (-180 <= from_lon <= 180) or not (-180 <= to_lon <= 180):
raise ValueError("Longitude must be between -180 and 180")
return from_lat, from_lon, to_lat, to_lon
def _parse_coordinate(self, value, field_name):
try:
coordinate = float(value)
except (TypeError, ValueError):
raise ValueError(f"{field_name} must be a number")
if not math.isfinite(coordinate):
raise ValueError(f"{field_name} must be finite")
return coordinate
def _calculate_osrm_metrics(self, from_lat, from_lon, to_lat, to_lon):
foot_distance_km, foot_duration_minutes = self._query_osrm_route(
profile="foot",
from_lat=from_lat,
from_lon=from_lon,
to_lat=to_lat,
to_lon=to_lon,
)
if foot_duration_minutes <= self.WALKING_THRESHOLD_MINUTES:
return self._build_metrics_result(
distance_km=foot_distance_km,
duration_minutes=foot_duration_minutes,
mode="walking",
source="osrm",
)
car_distance_km, car_duration_minutes = self._query_osrm_route(
profile="car",
from_lat=from_lat,
from_lon=from_lon,
to_lat=to_lat,
to_lon=to_lon,
)
return self._build_metrics_result(
distance_km=car_distance_km,
duration_minutes=car_duration_minutes,
mode="driving",
source="osrm",
)
def _query_osrm_route(self, profile, from_lat, from_lon, to_lat, to_lon):
base_url = self._get_validated_osrm_base_url()
route_coords = f"{from_lon},{from_lat};{to_lon},{to_lat}"
base_parts = urlparse(base_url)
base_path = base_parts.path.rstrip("/")
route_path = f"{base_path}/route/v1/{profile}/{route_coords}"
url = urlunparse(
(
base_parts.scheme,
base_parts.netloc,
route_path,
"",
"",
"",
)
)
response = requests.get(
url,
params={"overview": "false"},
headers=self.OSRM_HEADERS,
timeout=(2, 5),
)
response.raise_for_status()
data = response.json()
routes = data.get("routes") or []
if not routes:
raise ValueError("OSRM route response missing routes")
route = routes[0]
distance_meters = route.get("distance")
duration_seconds = route.get("duration")
if not isinstance(distance_meters, (int, float)) or not math.isfinite(
distance_meters
):
raise ValueError("OSRM route distance is invalid")
if not isinstance(duration_seconds, (int, float)) or not math.isfinite(
duration_seconds
):
raise ValueError("OSRM route duration is invalid")
distance_km = max(distance_meters / 1000, 0)
duration_minutes = max(duration_seconds / 60, 0)
return distance_km, duration_minutes
def _calculate_haversine_fallback(self, from_lat, from_lon, to_lat, to_lon):
distance_km = self._haversine_distance_km(from_lat, from_lon, to_lat, to_lon)
walking_minutes = (distance_km / self.WALKING_SPEED_KMH) * 60
driving_minutes = (distance_km / self.DRIVING_SPEED_KMH) * 60
use_driving = walking_minutes > self.WALKING_THRESHOLD_MINUTES
return self._build_metrics_result(
distance_km=distance_km,
duration_minutes=driving_minutes if use_driving else walking_minutes,
mode="driving" if use_driving else "walking",
source="haversine",
)
def _haversine_distance_km(self, from_lat, from_lon, to_lat, to_lon):
earth_radius_km = 6371
lat_delta = math.radians(to_lat - from_lat)
lon_delta = math.radians(to_lon - from_lon)
from_lat_radians = math.radians(from_lat)
to_lat_radians = math.radians(to_lat)
a = (
math.sin(lat_delta / 2) ** 2
+ math.cos(from_lat_radians)
* math.cos(to_lat_radians)
* math.sin(lon_delta / 2) ** 2
)
a = min(max(a, 0.0), 1.0)
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
return max(earth_radius_km * c, 0)
def _get_validated_osrm_base_url(self):
configured_base_url = (
getattr(settings, "OSRM_BASE_URL", self.OSRM_DEFAULT_BASE_URL)
or self.OSRM_DEFAULT_BASE_URL
)
parsed = urlparse(configured_base_url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
logger.warning(
"Invalid OSRM_BASE_URL configured; falling back to haversine metrics"
)
raise ValueError("invalid_osrm_base_url")
normalized_path = parsed.path.rstrip("/")
return urlunparse(
(
parsed.scheme,
parsed.netloc,
normalized_path,
"",
"",
"",
)
)
def _invalid_metrics_result(self, error_code, message):
return {
"distance_km": None,
"duration_minutes": None,
"distance_label": None,
"duration_label": None,
"mode": None,
"source": "invalid",
"error": error_code,
"message": message,
}
def _build_metrics_result(self, distance_km, duration_minutes, mode, source):
safe_distance = max(distance_km, 0)
safe_duration = max(duration_minutes, 0)
rounded_distance = round(safe_distance, 2)
rounded_duration = int(round(safe_duration))
return {
"distance_km": rounded_distance,
"duration_minutes": rounded_duration,
"distance_label": self._format_distance_label(safe_distance),
"duration_label": self._format_duration_label(safe_duration),
"mode": mode,
"source": source,
}
def _format_distance_label(self, distance_km):
if distance_km < 10:
return f"{distance_km:.1f} km"
return f"{int(round(distance_km))} km"
def _format_duration_label(self, minutes):
safe_minutes = max(0, int(round(minutes)))
hours = safe_minutes // 60
remaining_minutes = safe_minutes % 60
if hours == 0:
return f"{remaining_minutes}m"
if remaining_minutes == 0:
return f"{hours}h"
return f"{hours}h {remaining_minutes}m"
def _build_cache_key(self, from_lat, from_lon, to_lat, to_lon):
raw = (
f"{from_lat:.6f},{from_lon:.6f}:{to_lat:.6f},{to_lon:.6f}"
f":walk={self.WALKING_THRESHOLD_MINUTES}"
)
hashed = hashlib.sha256(raw.encode("utf-8")).hexdigest()
return f"route_metrics:{hashed}"
def _safe_cache_get(self, key):
try:
return cache.get(key)
except Exception as error:
logger.warning("Route metrics cache get failed: %s", error)
return None
def _safe_cache_set(self, key, value):
try:
cache.set(key, value, self.CACHE_TIMEOUT_SECONDS)
except Exception as error:
logger.warning("Route metrics cache set failed: %s", error)

View File

@@ -377,3 +377,4 @@ COUNTRY_REGION_JSON_VERSION = 'v3.0'
GOOGLE_MAPS_API_KEY = getenv('GOOGLE_MAPS_API_KEY', '')
STRAVA_CLIENT_ID = getenv('STRAVA_CLIENT_ID', '')
STRAVA_CLIENT_SECRET = getenv('STRAVA_CLIENT_SECRET', '')
OSRM_BASE_URL = getenv('OSRM_BASE_URL', 'https://router.project-osrm.org')

View File

@@ -7,3 +7,4 @@ In addition to the primary configuration variables listed above, there are sever
| `ACCOUNT_EMAIL_VERIFICATION` | No | Enable email verification for new accounts. Options are `none`, `optional`, or `mandatory` | `none` | Backend |
| `FORCE_SOCIALACCOUNT_LOGIN` | No | When set to `True`, only social login is allowed (no password login). The login page will show only social providers or redirect directly to the first provider if only one is configured. | `False` | Backend |
| `SOCIALACCOUNT_ALLOW_SIGNUP` | No | When set to `True`, signup will be allowed via social providers even if registration is disabled. | `False` | Backend |
| `OSRM_BASE_URL` | No | Base URL of the OSRM routing server used for itinerary connector distance/travel-time metrics. The public OSRM demo server is used by default. Set this to point at your own OSRM instance (e.g. `http://osrm:5000`) for higher rate limits or offline use. When the OSRM server is unreachable, the backend automatically falls back to haversine-based approximations so the itinerary UI always shows metrics. | `https://router.project-osrm.org` | Backend |

View File

@@ -14,8 +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.
- **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.
- A timeline-style day view shows ordered stops with numbered markers, compact transportation connector rows (mode, duration, distance), and inline add-place rows per day.
- Day-level quick actions include Auto-fill (to populate an empty itinerary from dated records) and an Optimize placeholder for future route optimization.
- A timeline-style day view shows ordered stops with numbered markers and compact location cards (no image banners) for a dense overview.
- 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.
- Each day has a single `+ Add` control to insert new places, and day-level quick actions include Auto-fill and an Optimize placeholder.
- 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.
- **Share Your Experiences** 📸: Share your adventures with friends and family and collaborate on trips together.

View File

@@ -22,7 +22,7 @@ The term "Location" is now used instead of "Adventure" - the usage remains the s
#### 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, compact transportation connector rows, and an inline add-place row. 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. 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. 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.
- **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.
- **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.

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" />