diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 08a76cea..7949aab1 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -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() diff --git a/frontend/src/lib/components/cards/TransportationCard.svelte b/frontend/src/lib/components/cards/TransportationCard.svelte index 8c3b7a8c..38327544 100644 --- a/frontend/src/lib/components/cards/TransportationCard.svelte +++ b/frontend/src/lib/components/cards/TransportationCard.svelte @@ -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 @@ >
- + {#if routeGeojson} + + {:else} + + {/if}
@@ -250,6 +276,12 @@
{/if} + {#if travelDurationLabel} +
+ ⏱️ {travelDurationLabel} +
+ {/if} + {#if transportation.rating}
diff --git a/frontend/src/lib/components/cards/TransportationRoutePreview.svelte b/frontend/src/lib/components/cards/TransportationRoutePreview.svelte new file mode 100644 index 00000000..35e568e3 --- /dev/null +++ b/frontend/src/lib/components/cards/TransportationRoutePreview.svelte @@ -0,0 +1,183 @@ + + +{#if showImageModal && sortedImages.length > 0} + +{/if} + +
+ + + + {#if hasRoute} + + + {#if startPoint} + + {/if} + {#if endPoint} + + {/if} + + {:else} + + Route unavailable + + {/if} + + +
+ GPX + Route +
+ + {#if sortedImages.length > 0} + + {/if} +
diff --git a/frontend/src/lib/components/transportation/TransportationDetails.svelte b/frontend/src/lib/components/transportation/TransportationDetails.svelte index 0648a2f7..e63ea7e3 100644 --- a/frontend/src/lib/components/transportation/TransportationDetails.svelte +++ b/frontend/src/lib/components/transportation/TransportationDetails.svelte @@ -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; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 78f2e514..e9d4169e 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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 = { diff --git a/frontend/src/routes/transportations/[id]/+page.svelte b/frontend/src/routes/transportations/[id]/+page.svelte index b2350e97..05f7161b 100644 --- a/frontend/src/routes/transportations/[id]/+page.svelte +++ b/frontend/src/routes/transportations/[id]/+page.svelte @@ -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 @@

{$t('adventures.distance') ?? 'Distance'}

-

{transportation.distance} km

+

{formatDistance(transportation.distance)}

{/if}