feat: add travel duration and GPX distance calculation to Transportation model and UI

This commit is contained in:
Sean Morley
2026-01-02 12:55:20 -05:00
parent 0e65929599
commit 00914f5296
6 changed files with 325 additions and 8 deletions

View File

@@ -7,6 +7,7 @@ from worldtravel.serializers import CountrySerializer, RegionSerializer, CitySer
from geopy.distance import geodesic
from integrations.models import ImmichIntegration
from adventures.utils.geojson import gpx_to_geojson
import gpxpy
import logging
logger = logging.getLogger(__name__)
@@ -424,6 +425,7 @@ class TransportationSerializer(CustomModelSerializer):
distance = serializers.SerializerMethodField()
images = serializers.SerializerMethodField()
attachments = serializers.SerializerMethodField()
travel_duration_minutes = serializers.SerializerMethodField()
class Meta:
model = Transportation
@@ -432,9 +434,10 @@ class TransportationSerializer(CustomModelSerializer):
'link', 'date', 'flight_number', 'from_location', 'to_location',
'is_public', 'collection', 'created_at', 'updated_at', 'end_date',
'origin_latitude', 'origin_longitude', 'destination_latitude', 'destination_longitude',
'start_timezone', 'end_timezone', 'distance', 'images', 'attachments', 'start_code', 'end_code'
'start_timezone', 'end_timezone', 'distance', 'images', 'attachments', 'start_code', 'end_code',
'travel_duration_minutes'
]
read_only_fields = ['id', 'created_at', 'updated_at', 'user', 'distance']
read_only_fields = ['id', 'created_at', 'updated_at', 'user', 'distance', 'travel_duration_minutes']
def get_images(self, obj):
serializer = ContentImageSerializer(obj.images.all(), many=True, context=self.context)
@@ -447,6 +450,10 @@ class TransportationSerializer(CustomModelSerializer):
return [attachment for attachment in serializer.data if attachment is not None]
def get_distance(self, obj):
gpx_distance = self._get_gpx_distance_km(obj)
if gpx_distance is not None:
return gpx_distance
if (
obj.origin_latitude and obj.origin_longitude and
obj.destination_latitude and obj.destination_longitude
@@ -459,6 +466,68 @@ class TransportationSerializer(CustomModelSerializer):
return None
return None
def _get_gpx_distance_km(self, obj):
gpx_attachments = obj.attachments.filter(file__iendswith='.gpx')
for attachment in gpx_attachments:
distance_km = self._parse_gpx_distance_km(attachment.file)
if distance_km is not None:
return distance_km
return None
def _parse_gpx_distance_km(self, gpx_file_field):
try:
with gpx_file_field.open('r') as gpx_file:
gpx = gpxpy.parse(gpx_file)
total_meters = 0.0
for track in gpx.tracks:
for segment in track.segments:
segment_length = segment.length_3d() or segment.length_2d()
if segment_length:
total_meters += segment_length
for route in gpx.routes:
route_length = route.length_3d() or route.length_2d()
if route_length:
total_meters += route_length
if total_meters > 0:
return round(total_meters / 1000, 2)
except Exception as exc:
logger.warning(
"Failed to calculate GPX distance for file %s: %s",
getattr(gpx_file_field, 'name', 'unknown'),
exc,
)
return None
def get_travel_duration_minutes(self, obj):
if not obj.date or not obj.end_date:
return None
if self._is_all_day(obj.date) and self._is_all_day(obj.end_date):
return None
try:
total_minutes = int((obj.end_date - obj.date).total_seconds() // 60)
return total_minutes if total_minutes >= 0 else None
except Exception:
logger.warning(
"Failed to calculate travel duration for transportation %s",
getattr(obj, "id", "unknown"),
exc_info=True,
)
return None
def _is_all_day(self, dt_value):
return (
dt_value.time().hour == 0
and dt_value.time().minute == 0
and dt_value.time().second == 0
and dt_value.time().microsecond == 0
)
class LodgingSerializer(CustomModelSerializer):
images = serializers.SerializerMethodField()
attachments = serializers.SerializerMethodField()

View File

@@ -11,6 +11,7 @@
import { formatAllDayDate, formatDateInTimezone } from '$lib/dateUtils';
import { isAllDay } from '$lib';
import CardCarousel from '../CardCarousel.svelte';
import TransportationRoutePreview from './TransportationRoutePreview.svelte';
import Eye from '~icons/mdi/eye';
import EyeOff from '~icons/mdi/eye-off';
@@ -48,6 +49,23 @@
const toMiles = (km: any) => (Number(km) * 0.621371).toFixed(1);
const formatTravelDuration = (minutes: number | null | undefined) => {
if (minutes === null || minutes === undefined || Number.isNaN(minutes)) return null;
const safeMinutes = Math.max(0, Math.floor(minutes));
const hours = Math.floor(safeMinutes / 60);
const mins = safeMinutes % 60;
const parts = [] as string[];
if (hours) parts.push(`${hours}h`);
parts.push(`${mins}m`);
return parts.join(' ');
};
let travelDurationLabel: string | null = null;
$: travelDurationLabel = formatTravelDuration(transportation?.travel_duration_minutes ?? null);
$: routeGeojson =
transportation?.attachments?.find((attachment) => attachment?.geojson)?.geojson ?? null;
let isWarningModalOpen: boolean = false;
function editTransportation() {
@@ -101,11 +119,19 @@
>
<!-- Image Section with Overlay -->
<div class="relative overflow-hidden rounded-t-2xl">
<CardCarousel
images={transportation.images}
icon={getTransportationIcon(transportation.type)}
name={transportation.name}
/>
{#if routeGeojson}
<TransportationRoutePreview
geojson={routeGeojson}
name={transportation.name}
images={transportation.images}
/>
{:else}
<CardCarousel
images={transportation.images}
icon={getTransportationIcon(transportation.type)}
name={transportation.name}
/>
{/if}
<!-- Privacy Indicator -->
<div class="absolute top-2 right-4">
@@ -250,6 +276,12 @@
</div>
{/if}
{#if travelDurationLabel}
<div class="badge badge-ghost badge-sm">
⏱️ {travelDurationLabel}
</div>
{/if}
{#if transportation.rating}
<div class="flex items-center gap-1">
<div class="flex -ml-1">

View File

@@ -0,0 +1,183 @@
<script lang="ts">
import ImageDisplayModal from '../ImageDisplayModal.svelte';
import type { ContentImage } from '$lib/types';
export let geojson: any;
export let name: string = '';
export let images: ContentImage[] = [];
export let heightClass: string = 'h-48';
let showImageModal = false;
let modalInitialIndex = 0;
$: sortedImages = [...images]
.filter((img) => !!img?.image)
.sort((a, b) => Number(b?.is_primary) - Number(a?.is_primary));
$: routeCoordinates = extractLineCoords(geojson);
$: normalizedRoute = normalizeCoords(routeCoordinates);
$: pathD = buildPath(normalizedRoute);
$: hasRoute = !!pathD;
$: startPoint = normalizedRoute.length > 0 ? normalizedRoute[0] : null;
$: endPoint =
normalizedRoute.length > 1 ? normalizedRoute[normalizedRoute.length - 1] : startPoint;
function openImageModal(initialIndex: number = 0) {
if (!sortedImages.length) return;
modalInitialIndex = initialIndex;
showImageModal = true;
}
function closeImageModal() {
showImageModal = false;
}
type LonLat = [number, number];
type NormalizedPoint = { x: number; y: number };
function extractLineCoords(data: any): LonLat[] {
if (!data || typeof data !== 'object') return [];
if (data.type === 'FeatureCollection' && Array.isArray(data.features)) {
return data.features.flatMap((feature: any) => extractLineCoords(feature));
}
if (data.type === 'Feature') {
return extractLineCoords(data.geometry);
}
if (data.type === 'GeometryCollection' && Array.isArray(data.geometries)) {
return data.geometries.flatMap((geom: any) => extractLineCoords(geom));
}
if (data.type === 'LineString' && Array.isArray(data.coordinates)) {
return sanitizeCoordinates(data.coordinates);
}
if (data.type === 'MultiLineString' && Array.isArray(data.coordinates)) {
return data.coordinates.flatMap((line: any) => sanitizeCoordinates(line));
}
return [];
}
function sanitizeCoordinates(raw: any): LonLat[] {
if (!Array.isArray(raw)) return [];
const coords: LonLat[] = [];
for (const point of raw) {
if (!Array.isArray(point) || point.length < 2) continue;
const lon = Number(point[0]);
const lat = Number(point[1]);
if (Number.isFinite(lon) && Number.isFinite(lat)) {
coords.push([lon, lat]);
}
}
return coords;
}
function normalizeCoords(coords: LonLat[]): NormalizedPoint[] {
if (!coords.length) return [];
const lons = coords.map(([lon]) => lon);
const lats = coords.map(([, lat]) => lat);
const minLon = Math.min(...lons);
const maxLon = Math.max(...lons);
const minLat = Math.min(...lats);
const maxLat = Math.max(...lats);
const spanLon = maxLon - minLon || 1;
const spanLat = maxLat - minLat || 1;
const padLon = spanLon * 0.05;
const padLat = spanLat * 0.05;
const originLon = minLon - padLon;
const originLat = minLat - padLat;
const scaleX = 100 / (spanLon + padLon * 2);
const scaleY = 100 / (spanLat + padLat * 2);
return coords.map(([lon, lat]) => ({
x: (lon - originLon) * scaleX,
y: 100 - (lat - originLat) * scaleY
}));
}
function buildPath(points: NormalizedPoint[]): string {
if (!points.length) return '';
return points
.map((p, idx) => `${idx === 0 ? 'M' : 'L'} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`)
.join(' ');
}
</script>
{#if showImageModal && sortedImages.length > 0}
<ImageDisplayModal
images={sortedImages}
initialIndex={modalInitialIndex}
on:close={closeImageModal}
{name}
/>
{/if}
<div
class={`relative w-full ${heightClass} rounded-t-2xl bg-gradient-to-r from-success via-base to-primary overflow-hidden`}
aria-label="route-preview"
>
<svg viewBox="0 0 100 100" class="w-full h-full" role="img" aria-label="Route preview">
<!-- No separate bg; rely on parent gradient -->
{#if hasRoute}
<g stroke-linecap="round" stroke-linejoin="round">
<path
d={pathD}
fill="none"
stroke="var(--color-base-content, #111827)"
stroke-width="2.6"
opacity="0.55"
/>
{#if startPoint}
<circle
cx={startPoint.x}
cy={startPoint.y}
r="2.4"
fill="var(--color-base-content, #111827)"
opacity="0.9"
/>
{/if}
{#if endPoint}
<circle
cx={endPoint.x}
cy={endPoint.y}
r="2.8"
fill="var(--color-base-content, #111827)"
opacity="0.9"
/>
{/if}
</g>
{:else}
<text
x="50"
y="50"
text-anchor="middle"
fill="var(--color-base-content, #111827)"
opacity="0.6"
>
Route unavailable
</text>
{/if}
</svg>
<div class="absolute top-3 left-3 badge badge-primary gap-1 shadow">
<span class="text-xs font-semibold">GPX</span>
<span class="text-xs">Route</span>
</div>
{#if sortedImages.length > 0}
<button
type="button"
on:click|stopPropagation={() => openImageModal(0)}
class="btn btn-xs btn-neutral absolute bottom-3 right-3 shadow"
>
View photos ({sortedImages.length})
</button>
{/if}
</div>

View File

@@ -84,6 +84,11 @@
$: user = currentUser;
$: transportationToEdit = editingTransportation;
// Set the full date range for constraining purposes (from collection)
$: if (collection && collection.start_date && collection.end_date) {
fullStartDate = `${collection.start_date}T00:00`;
fullEndDate = `${collection.end_date}T23:59`;
}
// Only assign timezones when this is a timed transportation. Keep timezones null for all-day entries.
$: {
transportation.start_timezone = allDay ? null : selectedTimezone;

View File

@@ -198,6 +198,7 @@ export type Transportation = {
updated_at: string; // ISO 8601 date string
images: ContentImage[]; // Array of images associated with the transportation
attachments: Attachment[]; // Array of attachments associated with the transportation
travel_duration_minutes?: number | null;
};
export type Note = {

View File

@@ -177,6 +177,33 @@
return null;
}
/**
* Format a distance given in kilometers according to the current user's
* measurement system (metric or imperial). For metric show meters for <1km,
* otherwise km; for imperial show feet for very small distances, otherwise miles.
*/
function formatDistance(distanceKm: number | null): string | null {
if (distanceKm === null || distanceKm === undefined) return null;
const ms = data.user?.measurement_system ?? 'metric';
if (ms === 'imperial') {
const miles = distanceKm * 0.621371;
// show miles if at least 0.1 mi, otherwise show feet
if (miles >= 0.1) {
return `${miles.toFixed(1)} mi`;
}
const feet = Math.round(miles * 5280);
return `${feet} ft`;
} else {
// metric
if (distanceKm >= 1) {
return `${distanceKm.toFixed(1)} km`;
}
const meters = Math.round(distanceKm * 1000);
return `${meters} m`;
}
}
function collectAttachmentGeojson(item: Transportation) {
if (!item.attachments || item.attachments.length === 0) return null;
const features: any[] = [];
@@ -612,7 +639,7 @@
<p class="font-semibold text-sm opacity-70">
{$t('adventures.distance') ?? 'Distance'}
</p>
<p class="text-base">{transportation.distance} km</p>
<p class="text-base">{formatDistance(transportation.distance)}</p>
</div>
</div>
{/if}