From fde6c49396dff9eb2fdabed1c9fd4046ea58e084 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 09:28:54 +0000 Subject: [PATCH] feat: Road Trip addon Adds admin-toggleable Road Trip addon with OSRM road routing, fuel cost tracking, driving constraints, real stop finder, and historical route visualisation. Split into focused services: routingService, fuelService, stopSearchService. All external calls have timeouts, input validation, and rate limiting. Full i18n support across all 12 languages. https://claude.ai/code/session_01Gbv3XcW64sYVptMTmcyW62 --- client/src/api/client.ts | 9 + client/src/components/Admin/AddonManager.tsx | 4 +- client/src/components/Map/MapView.tsx | 157 ++++- client/src/components/Map/RoadTripRoute.tsx | 371 ++++++++++++ client/src/components/PDF/TripPDF.tsx | 132 ++++- .../src/components/Planner/DayDetailPanel.tsx | 265 ++++++++- .../src/components/Planner/DayPlanSidebar.tsx | 328 ++++++++++- .../src/components/Trip/RoadTripSummary.tsx | 371 ++++++++++++ client/src/i18n/translations/ar.ts | 119 ++++ client/src/i18n/translations/br.ts | 119 ++++ client/src/i18n/translations/cs.ts | 119 ++++ client/src/i18n/translations/de.ts | 119 ++++ client/src/i18n/translations/en.ts | 119 ++++ client/src/i18n/translations/es.ts | 119 ++++ client/src/i18n/translations/fr.ts | 119 ++++ client/src/i18n/translations/hu.ts | 119 ++++ client/src/i18n/translations/it.ts | 119 ++++ client/src/i18n/translations/nl.ts | 119 ++++ client/src/i18n/translations/ru.ts | 119 ++++ client/src/i18n/translations/zh.ts | 119 ++++ client/src/pages/AtlasPage.tsx | 239 +++++++- client/src/pages/SettingsPage.tsx | 360 +++++++++++- client/src/pages/TripPlannerPage.tsx | 35 +- client/src/store/roadtripStore.ts | 318 ++++++++++ client/src/types.ts | 67 +++ client/src/utils/directionFormatters.ts | 25 + client/src/utils/roadtripFormatters.ts | 127 ++++ client/src/utils/solarCalculation.ts | 81 +++ server/src/db/schema.ts | 21 + server/src/db/seeds.ts | 1 + server/src/index.ts | 4 +- server/src/routes/atlas.ts | 88 ++- server/src/routes/roadtrip.ts | 329 +++++++++++ server/src/services/fuelService.ts | 124 ++++ server/src/services/routingService.ts | 263 +++++++++ server/src/services/stopSearchService.ts | 552 ++++++++++++++++++ 36 files changed, 5657 insertions(+), 42 deletions(-) create mode 100644 client/src/components/Map/RoadTripRoute.tsx create mode 100644 client/src/components/Trip/RoadTripSummary.tsx create mode 100644 client/src/store/roadtripStore.ts create mode 100644 client/src/utils/directionFormatters.ts create mode 100644 client/src/utils/roadtripFormatters.ts create mode 100644 client/src/utils/solarCalculation.ts create mode 100644 server/src/routes/roadtrip.ts create mode 100644 server/src/services/fuelService.ts create mode 100644 server/src/services/routingService.ts create mode 100644 server/src/services/stopSearchService.ts diff --git a/client/src/api/client.ts b/client/src/api/client.ts index cd97c3e6..8637b8d8 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -277,6 +277,15 @@ export const collabApi = { linkPreview: (tripId: number | string, url: string) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data), } +export const roadtripApi = { + listLegs: (tripId: number | string) => apiClient.get(`/trips/${tripId}/route-legs`).then(r => r.data), + updateLeg: (tripId: number | string, legId: number, data: Record) => apiClient.put(`/trips/${tripId}/route-legs/${legId}`, data).then(r => r.data), + calculateLeg: (tripId: number | string, data: { day_index: number; from_place_id: string | number; to_place_id: string | number }) => apiClient.post(`/trips/${tripId}/route-legs/calculate`, data).then(r => r.data), + deleteLeg: (tripId: number | string, legId: number) => apiClient.delete(`/trips/${tripId}/route-legs/${legId}`).then(r => r.data), + recalculate: (tripId: number | string) => apiClient.post(`/trips/${tripId}/route-legs/recalculate`).then(r => r.data), + findStops: (tripId: number | string, legId: number, data: { stop_type: string; search_points: { lat: number; lng: number; distance_along_route_meters: number }[]; corridor?: boolean }) => apiClient.post(`/trips/${tripId}/route-legs/${legId}/find-stops`, data).then(r => r.data), +} + export const backupApi = { list: () => apiClient.get('/backup/list').then(r => r.data), create: () => apiClient.post('/backup/create').then(r => r.data), diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 30502586..8f6ab77a 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -4,10 +4,10 @@ import { useTranslation } from '../../i18n' import { useSettingsStore } from '../../store/settingsStore' import { useAddonStore } from '../../store/addonStore' import { useToast } from '../shared/Toast' -import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2 } from 'lucide-react' +import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Car } from 'lucide-react' const ICON_MAP = { - ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, + ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Car, } interface Addon { diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 26e744ac..45b2dd71 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -7,7 +7,13 @@ import 'leaflet.markercluster/dist/MarkerCluster.css' import 'leaflet.markercluster/dist/MarkerCluster.Default.css' import { mapsApi } from '../../api/client' import { getCategoryIcon } from '../shared/categoryIcons' -import type { Place } from '../../types' +import { useRoadtripStore } from '../../store/roadtripStore' +import { useTranslation } from '../../i18n' +import { useAddonStore } from '../../store/addonStore' +import { useSettingsStore } from '../../store/settingsStore' +import RoadTripRoute, { decodePolyline } from './RoadTripRoute' +import { calculateVehicleRange } from '../../utils/roadtripFormatters' +import type { Place, RouteLeg } from '../../types' // Fix default marker icons for vite delete L.Icon.Default.prototype._getIconUrl @@ -240,6 +246,71 @@ function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) { const mapPhotoCache = new Map() const mapPhotoInFlight = new Set() +// Show Full Route button — fits all road trip legs on map +function FullRouteButton({ tripId, allLegs, dayPlaces, paddingOpts, t }: { + tripId: string | null + allLegs: RouteLeg[] + dayPlaces: Place[] + paddingOpts: Record + t: (key: string) => string +}) { + const map = useMap() + const [isFullView, setIsFullView] = useState(false) + + const legsWithGeometry = allLegs.filter(l => l.is_road_trip && l.route_geometry) + if (legsWithGeometry.length === 0) return null + + const handleClick = () => { + if (isFullView) { + // Reset to day view + if (dayPlaces.length > 0) { + try { + const bounds = L.latLngBounds(dayPlaces.map(p => [p.lat!, p.lng!])) + if (bounds.isValid()) map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true }) + } catch {} + } + setIsFullView(false) + return + } + + // Collect all polyline coordinates + const allCoords: [number, number][] = [] + for (const leg of legsWithGeometry) { + const coords = decodePolyline(leg.route_geometry!) + allCoords.push(...coords) + } + if (allCoords.length === 0) return + + try { + const bounds = L.latLngBounds(allCoords) + if (bounds.isValid()) { + map.fitBounds(bounds, { padding: [50, 50], animate: true }) + setIsFullView(true) + } + } catch {} + } + + return ( +
+ +
+ ) +} + // Live location tracker — blue dot with pulse animation (like Apple/Google Maps) function LocationTracker() { const map = useMap() @@ -347,7 +418,47 @@ export function MapView({ leftWidth = 0, rightWidth = 0, hasInspector = false, + tripId = null, + selectedDayId = null, + days = [], }) { + const { t } = useTranslation() + const roadtripEnabled = useAddonStore(s => s.isEnabled('roadtrip')) + const roadtripStore = useRoadtripStore() + const unitSystem = useSettingsStore(s => s.settings.roadtrip_unit_system) || 'metric' + const fuelCurrency = useSettingsStore(s => s.settings.roadtrip_fuel_currency) || useSettingsStore(s => s.settings.default_currency) || 'USD' + const tankSizeVal = useSettingsStore(s => s.settings.roadtrip_tank_size) + const fuelConsumptionVal = useSettingsStore(s => s.settings.roadtrip_fuel_consumption) + const vehicleRangeMeters = (() => { + if (!tankSizeVal || !fuelConsumptionVal) return null + const tank = parseFloat(tankSizeVal) + const consumption = parseFloat(fuelConsumptionVal) + if (!tank || !consumption) return null + const us = unitSystem as 'metric' | 'imperial' + return calculateVehicleRange(tank, consumption, us) * (us === 'imperial' ? 1609.344 : 1000) + })() + const restIntervalHoursVal = useSettingsStore(s => s.settings.roadtrip_rest_interval_hours) + const restIntervalHours = restIntervalHoursVal ? parseFloat(restIntervalHoursVal) : null + const fuelBrandSetting = useSettingsStore(s => s.settings.roadtrip_fuel_brand) || 'any' + const preferredBrands = fuelBrandSetting === 'any' ? [] : fuelBrandSetting.split(',').map((b: string) => b.trim()).filter(Boolean) + + // Get the day index for the selected day + const dayIndex = useMemo(() => { + if (!selectedDayId || !days.length) return -1 + return days.findIndex(d => d.id === selectedDayId) + }, [selectedDayId, days]) + + // Get road trip legs for current day + const roadTripLegs: RouteLeg[] = useMemo(() => { + if (!roadtripEnabled || !tripId || dayIndex < 0) return [] + return roadtripStore.getLegsForDay(String(tripId), dayIndex).filter(l => l.is_road_trip) + }, [roadtripEnabled, tripId, dayIndex, roadtripStore.routeLegs]) + + // All road trip legs across all days (for full route button) + const allRoadTripLegs: RouteLeg[] = useMemo(() => { + if (!roadtripEnabled || !tripId) return [] + return (roadtripStore.routeLegs[String(tripId)] || []).filter(l => l.is_road_trip) + }, [roadtripEnabled, tripId, roadtripStore.routeLegs]) // Dynamic padding: account for sidebars + bottom inspector const paddingOpts = useMemo(() => { const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 @@ -423,6 +534,7 @@ export function MapView({ + {roadtripEnabled && } - {route && route.length > 1 && ( - <> - - {routeSegments.map((seg, i) => ( - - ))} - - )} + {route && route.length > 1 && (() => { + // Build per-segment polylines, substituting road trip geometry where available + const segments: any[] = [] + for (let i = 0; i < route.length - 1; i++) { + const from = route[i] + const to = route[i + 1] + // Check if this segment has a road trip leg + const leg = roadTripLegs.find(l => { + const fMatch = Math.abs((l.from_lat || 0) - from[0]) < 0.0001 && Math.abs((l.from_lng || 0) - from[1]) < 0.0001 + const tMatch = Math.abs((l.to_lat || 0) - to[0]) < 0.0001 && Math.abs((l.to_lng || 0) - to[1]) < 0.0001 + return fMatch && tMatch + }) + if (leg) { + segments.push() + } else { + segments.push( + + ) + // Show route label for non-road-trip segments + if (routeSegments[i]) { + const seg = routeSegments[i] + segments.push( + + ) + } + } + } + return <>{segments} + })()} ) } diff --git a/client/src/components/Map/RoadTripRoute.tsx b/client/src/components/Map/RoadTripRoute.tsx new file mode 100644 index 00000000..7b3e30d6 --- /dev/null +++ b/client/src/components/Map/RoadTripRoute.tsx @@ -0,0 +1,371 @@ +import { Polyline, useMap, Popup } from 'react-leaflet' +import { useState, useEffect, useMemo } from 'react' +import L from 'leaflet' +import { Marker } from 'react-leaflet' +import type { RouteLeg, FoundStop } from '../../types' +import { formatDistance, formatDuration, formatFuelCost } from '../../utils/roadtripFormatters' + +/** + * Decode a Google-encoded polyline string into an array of [lat, lng] pairs. + */ +export function decodePolyline(encoded: string): [number, number][] { + const points: [number, number][] = [] + let index = 0 + let lat = 0 + let lng = 0 + + while (index < encoded.length) { + let shift = 0 + let result = 0 + let byte: number + do { + byte = encoded.charCodeAt(index++) - 63 + result |= (byte & 0x1f) << shift + shift += 5 + } while (byte >= 0x20) + lat += result & 1 ? ~(result >> 1) : result >> 1 + + shift = 0 + result = 0 + do { + byte = encoded.charCodeAt(index++) - 63 + result |= (byte & 0x1f) << shift + shift += 5 + } while (byte >= 0x20) + lng += result & 1 ? ~(result >> 1) : result >> 1 + + points.push([lat / 1e5, lng / 1e5]) + } + return points +} + +/** Haversine distance in meters between two [lat, lng] points */ +export function haversine(a: [number, number], b: [number, number]): number { + const R = 6371000 + const dLat = (b[0] - a[0]) * Math.PI / 180 + const dLng = (b[1] - a[1]) * Math.PI / 180 + const lat1 = a[0] * Math.PI / 180 + const lat2 = b[0] * Math.PI / 180 + const s = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2 + return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s)) +} + +/** Walk along polyline and find positions at distance intervals */ +export function getRefuelPoints(positions: [number, number][], rangeMeters: number): [number, number][] { + const points: [number, number][] = [] + let cumulative = 0 + let nextThreshold = rangeMeters // first marker at 1x range + + for (let i = 1; i < positions.length; i++) { + const segDist = haversine(positions[i - 1], positions[i]) + const prevCumulative = cumulative + cumulative += segDist + + while (cumulative >= nextThreshold) { + // Interpolate position on this segment + const overshoot = nextThreshold - prevCumulative + const fraction = overshoot / segDist + const lat = positions[i - 1][0] + fraction * (positions[i][0] - positions[i - 1][0]) + const lng = positions[i - 1][1] + fraction * (positions[i][1] - positions[i - 1][1]) + points.push([lat, lng]) + nextThreshold += rangeMeters + } + } + return points +} + +interface RoadTripRouteLabelProps { + midpoint: [number, number] + distance: string + duration: string + fuelText: string | null + exceedsRange: boolean + exceedsRangeText: string +} + +function RoadTripRouteLabel({ midpoint, distance, duration, fuelText, exceedsRange, exceedsRangeText }: RoadTripRouteLabelProps) { + const map = useMap() + const [visible, setVisible] = useState(map ? map.getZoom() >= 12 : false) + + useEffect(() => { + if (!map) return + const check = () => setVisible(map.getZoom() >= 12) + check() + map.on('zoomend', check) + return () => { map.off('zoomend', check) } + }, [map]) + + if (!visible || !midpoint) return null + + const extraParts: string[] = [] + if (fuelText) extraParts.push(fuelText) + if (exceedsRange) extraParts.push(exceedsRangeText) + + const extraHtml = extraParts.length > 0 + ? `|${extraParts.join(' ')}` + : '' + + const icon = L.divIcon({ + className: 'route-info-pill', + html: `
+ + + ${distance} + + | + ${duration} + ${extraHtml} +
`, + iconSize: [0, 0], + iconAnchor: [0, 0], + }) + + return +} + +function RefuelMarker({ position }: { position: [number, number] }) { + const icon = useMemo(() => L.divIcon({ + className: '', + html: `
`, + iconSize: [0, 0], + iconAnchor: [0, 0], + }), []) + + return +} + +function RestMarker({ position }: { position: [number, number] }) { + const icon = useMemo(() => L.divIcon({ + className: '', + html: `
`, + iconSize: [0, 0], + iconAnchor: [0, 0], + }), []) + + return +} + +/** Classify fuel stops as critical (must refuel) or optional based on vehicle range */ +export function classifyFuelStops(fuelStops: FoundStop[], totalDistMeters: number, vehicleRangeMeters: number): Set { + // Sort by distance along route + const sorted = [...fuelStops] + .map((s, origIdx) => ({ ...s, origIdx })) + .sort((a, b) => a.distance_along_route_meters - b.distance_along_route_meters) + + const criticalIndices = new Set() + let lastRefuelAt = 0 // start with full tank at 0 + + while (lastRefuelAt + vehicleRangeMeters < totalDistMeters) { + // Find the last reachable fuel station before running out + const reachable = sorted.filter(s => + s.distance_along_route_meters > lastRefuelAt && + s.distance_along_route_meters <= lastRefuelAt + vehicleRangeMeters + ) + if (reachable.length === 0) break // no fuel reachable — gap too large + // Pick the furthest reachable station (maximise range per stop) + const chosen = reachable[reachable.length - 1] + criticalIndices.add(chosen.origIdx) + lastRefuelAt = chosen.distance_along_route_meters + } + + return criticalIndices +} + +function FoundStopMarker({ stop, isPreferred }: { stop: FoundStop; isPreferred?: boolean }) { + const isFuel = stop.type === 'fuel' + const borderColor = isPreferred ? '#f59e0b' : 'white' + const borderWidth = isPreferred ? 3 : 2 + const icon = useMemo(() => L.divIcon({ + className: '', + html: `
${isFuel ? '⛽' : '☕'}
`, + iconSize: [0, 0], + iconAnchor: [0, 0], + }), [isFuel, isPreferred]) + + const mapsUrl = `https://www.google.com/maps/search/?api=1&query=${stop.lat},${stop.lng}` + const distKm = Math.round(stop.distance_along_route_meters / 1000) + + return ( + + +
+ {stop.brand ? `${stop.brand} — ${stop.name}` : stop.name} + {isFuel && distKm > 0 && ( +
+ ⛽ REFUEL — {distKm}km from start +
+ )} + {stop.rating != null &&
{'⭐'.repeat(Math.round(stop.rating))} {stop.rating}
} + {stop.opening_hours &&
{stop.opening_hours}
} +
+ {Math.round(stop.distance_from_route_meters)}m from route +
+ + Open in Google Maps ↗ + +
+
+
+ ) +} + +/** Parse found_stops from route_metadata JSON */ +function getFoundStops(leg: RouteLeg): FoundStop[] { + if (!leg.route_metadata) return [] + try { + const meta = JSON.parse(leg.route_metadata) + return Array.isArray(meta.found_stops) ? meta.found_stops : [] + } catch { /* expected when route_metadata has no found_stops */ return [] } +} + +interface RoadTripRouteProps { + leg: RouteLeg + unitSystem: string + vehicleRangeMeters?: number | null + fuelCurrency?: string + exceedsRangeText?: string + restIntervalHours?: number | null + preferredBrands?: string[] +} + +export default function RoadTripRoute({ leg, unitSystem, vehicleRangeMeters, fuelCurrency, exceedsRangeText, restIntervalHours, preferredBrands }: RoadTripRouteProps) { + if (!leg.route_geometry) { + // Loading state: thin animated dashed line between endpoints + if (leg.from_lat != null && leg.from_lng != null && leg.to_lat != null && leg.to_lng != null) { + return ( + + ) + } + return null + } + + const positions = decodePolyline(leg.route_geometry) + if (positions.length < 2) return null + + const midIndex = Math.floor(positions.length / 2) + const midpoint = positions[midIndex] + + const distance = leg.distance_meters ? formatDistance(leg.distance_meters, unitSystem) : '' + const duration = leg.duration_seconds ? formatDuration(leg.duration_seconds) : '' + const fuelText = leg.fuel_cost != null && fuelCurrency ? formatFuelCost(leg.fuel_cost, fuelCurrency) : null + const exceedsRange = !!(vehicleRangeMeters && leg.distance_meters && leg.distance_meters > vehicleRangeMeters) + + // Real found stops from route_metadata + const foundStops = useMemo(() => getFoundStops(leg), [leg.route_metadata]) + const fuelStops = useMemo(() => foundStops.filter(s => s.type === 'fuel'), [foundStops]) + const restStops = useMemo(() => foundStops.filter(s => s.type === 'rest'), [foundStops]) + const hasRealFuelStops = fuelStops.length > 0 + const hasRealRestStops = restStops.length > 0 + + // Classify fuel stops as critical vs optional + const criticalFuelIndices = useMemo(() => { + if (!hasRealFuelStops || !vehicleRangeMeters || !leg.distance_meters) return new Set() + return classifyFuelStops(fuelStops, leg.distance_meters, vehicleRangeMeters) + }, [fuelStops, vehicleRangeMeters, leg.distance_meters]) + + // Only show critical fuel stops + all rest stops + const visibleStops = useMemo(() => { + const criticalSet = criticalFuelIndices + let fuelIdx = 0 + return foundStops.filter(s => { + if (s.type !== 'fuel') return true + const isCrit = criticalSet.has(fuelIdx++) + return isCrit + }) + }, [foundStops, criticalFuelIndices]) + + // Refuel markers — hide approximate ones when real fuel stops exist + const refuelPoints = useMemo(() => { + if (hasRealFuelStops) return [] + if (!vehicleRangeMeters || !exceedsRange) return [] + return getRefuelPoints(positions, vehicleRangeMeters) + }, [leg.route_geometry, vehicleRangeMeters, exceedsRange, hasRealFuelStops]) + + // Rest markers — hide approximate ones when real rest stops exist + const restPoints = useMemo(() => { + if (hasRealRestStops) return [] + if (!restIntervalHours || !leg.distance_meters || !leg.duration_seconds) return [] + const avgSpeedMs = leg.distance_meters / leg.duration_seconds + const restIntervalMeters = restIntervalHours * 3600 * avgSpeedMs + if (leg.distance_meters <= restIntervalMeters) return [] + const raw = getRefuelPoints(positions, restIntervalMeters) + if (refuelPoints.length === 0) return raw + return raw.filter(rp => !refuelPoints.some(fp => haversine(rp, fp) < 5000)) + }, [leg.route_geometry, restIntervalHours, leg.distance_meters, leg.duration_seconds, refuelPoints, hasRealRestStops]) + + return ( + <> + + {distance && duration && ( + + )} + {refuelPoints.map((pos, i) => ( + + ))} + {restPoints.map((pos, i) => ( + + ))} + {visibleStops.map((stop, i) => { + const isPreferred = !!(preferredBrands?.length && stop.type === 'fuel' && + preferredBrands.some(b => stop.brand?.toLowerCase().includes(b.toLowerCase()) || stop.name?.toLowerCase().includes(b.toLowerCase()))); + return ( + + ); + })} + + ) +} diff --git a/client/src/components/PDF/TripPDF.tsx b/client/src/components/PDF/TripPDF.tsx index c83cc4b7..259cfc0f 100644 --- a/client/src/components/PDF/TripPDF.tsx +++ b/client/src/components/PDF/TripPDF.tsx @@ -3,7 +3,8 @@ import { createElement } from 'react' import { getCategoryIcon } from '../shared/categoryIcons' import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark } from 'lucide-react' import { mapsApi } from '../../api/client' -import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types' +import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap, RouteLeg, RouteDirection } from '../../types' +import { parseDirections, dirSymbol } from '../../utils/directionFormatters' const NOTE_ICON_MAP = { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark } function noteIconSvg(iconId) { @@ -90,12 +91,38 @@ async function fetchPlacePhotos(assignments) { try { const data = await mapsApi.placePhoto(place.google_place_id) if (data.photoUrl) photoMap[place.id] = data.photoUrl - } catch {} + } catch { /* photo fetch failure is non-critical for PDF */ } }) ) return photoMap } +// Road trip formatting helpers (mirrors roadtripFormatters.ts for inline HTML generation) +function pdfFormatDistance(meters: number, unitSystem: string): string { + if (unitSystem === 'imperial') { + const miles = meters / 1609.344 + return `${miles.toFixed(1)} mi` + } + return `${(meters / 1000).toFixed(1)} km` +} + +function pdfFormatDuration(seconds: number): string { + const h = Math.floor(seconds / 3600) + const m = Math.round((seconds % 3600) / 60) + if (h > 0) return `${h}h ${m}m` + return `${m} min` +} + +function pdfFormatFuelCost(cost: number, currency: string): string { + try { + return new Intl.NumberFormat(undefined, { + style: 'currency', currency: currency || 'USD', + minimumFractionDigits: 2, maximumFractionDigits: 2, + }).format(cost) + } catch { /* expected for unsupported currency codes */ return `${cost.toFixed(2)} ${currency || 'USD'}` } +} + + interface downloadTripPDFProps { trip: Trip days: Day[] @@ -104,11 +131,17 @@ interface downloadTripPDFProps { categories: Category[] dayNotes: DayNotesMap reservations?: any[] + routeLegs?: RouteLeg[] + unitSystem?: string + fuelCurrency?: string + maxDrivingHours?: number | null + restIntervalHours?: number | null + restDurationMinutes?: number | null t: (key: string, params?: Record) => string locale: string } -export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, reservations = [], t: _t, locale: _locale }: downloadTripPDFProps) { +export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, reservations = [], routeLegs = [], unitSystem: _unitSystem, fuelCurrency: _fuelCurrency, maxDrivingHours, restIntervalHours, restDurationMinutes, t: _t, locale: _locale }: downloadTripPDFProps) { await ensureRenderer() const loc = _locale || undefined const tr = _t || (k => k) @@ -225,6 +258,73 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor ` }).join('') + // Road trip driving summary for this day + const us = _unitSystem || 'metric' + const fc = _fuelCurrency || 'USD' + const dayLegs = (routeLegs || []).filter(l => l.is_road_trip && l.day_index === di) + let drivingHtml = '' + if (dayLegs.length > 0) { + const totalDist = dayLegs.reduce((s, l) => s + (l.distance_meters || 0), 0) + const totalDur = dayLegs.reduce((s, l) => s + (l.duration_seconds || 0), 0) + const totalFuel = dayLegs.reduce((s, l) => s + (l.fuel_cost || 0), 0) + const drivingHours = totalDur / 3600 + + const summaryParts = [pdfFormatDistance(totalDist, us), pdfFormatDuration(totalDur)] + if (totalFuel > 0) summaryParts.push(pdfFormatFuelCost(totalFuel, fc) + ' fuel') + + // Rest breaks + let dayRestBreaks = 0 + if (restIntervalHours && restDurationMinutes) { + for (const leg of dayLegs) { + const lh = (leg.duration_seconds || 0) / 3600 + if (lh > restIntervalHours) dayRestBreaks += Math.floor(lh / restIntervalHours) + } + } + + const legsHtml = dayLegs.map(l => { + const parts = [escHtml((l.from_place_name || '?') + ' → ' + (l.to_place_name || '?'))] + const details = [] + if (l.distance_meters) details.push(pdfFormatDistance(l.distance_meters, us)) + if (l.duration_seconds) details.push(pdfFormatDuration(l.duration_seconds)) + if (l.fuel_cost) details.push(pdfFormatFuelCost(l.fuel_cost, fc)) + + // Turn-by-turn directions + let directionsHtml = '' + const directions = parseDirections(l.route_metadata) + if (directions.length > 0) { + if ((l.distance_meters || 0) > 800000) { + directionsHtml = `
${escHtml(tr('roadtrip.directionsOmitted'))}
` + } else { + directionsHtml = directions.map(d => { + const sym = dirSymbol(d.maneuver, d.instruction) + const dist = d.distance_meters > 0 ? ` (${pdfFormatDistance(d.distance_meters, us)})` : '' + return `
+ ${sym} + ${escHtml(d.instruction)}${dist} +
` + }).join('') + } + } + + return `
+ ${parts[0]} + ${details.join(' · ')} +
${directionsHtml}` + }).join('') + + drivingHtml = ` +
+
+ ${transportIconSvg('car')} + ${escHtml(tr('roadtrip.pdfDrivingSummary'))} + ${summaryParts.join(' · ')} +
+ ${legsHtml} + ${dayRestBreaks > 0 ? `
☕ ${escHtml(tr('roadtrip.restBreaks', { count: String(dayRestBreaks), minutes: String(dayRestBreaks * (restDurationMinutes || 0)) }))}
` : ''} + ${maxDrivingHours && drivingHours > maxDrivingHours ? `
⚠ ${escHtml(tr('roadtrip.drivingTimeWarning', { actual: pdfFormatDuration(totalDur), limit: maxDrivingHours + 'h' }))}
` : ''} +
` + } + return `
@@ -233,7 +333,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor ${day.date ? `${shortDate(day.date, loc)}` : ''} ${cost ? `${cost}` : ''}
-
${itemsHtml}
+
${itemsHtml}${drivingHtml}
` }).join('') @@ -426,6 +526,30 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor ${daysHtml} +${(() => { + const allLegs = (routeLegs || []).filter(l => l.is_road_trip) + if (allLegs.length === 0) return '' + const us = _unitSystem || 'metric' + const fc = _fuelCurrency || 'USD' + const totalDist = allLegs.reduce((s, l) => s + (l.distance_meters || 0), 0) + const totalDur = allLegs.reduce((s, l) => s + (l.duration_seconds || 0), 0) + const totalFuel = allLegs.reduce((s, l) => s + (l.fuel_cost || 0), 0) + return ` +
+
+ ${escHtml(tr('roadtrip.pdfTripTotal')).toUpperCase()} + ${escHtml(tr('roadtrip.summary'))} +
+
+
+
${pdfFormatDistance(totalDist, us)}
${escHtml(tr('roadtrip.totalDistance'))}
+
${pdfFormatDuration(totalDur)}
${escHtml(tr('roadtrip.totalTime'))}
+ ${totalFuel > 0 ? `
${pdfFormatFuelCost(totalFuel, fc)}
${escHtml(tr('roadtrip.totalFuel'))}
` : ''} +
+
+
` +})()} + ` // Open in modal with srcdoc iframe (no URL loading = no X-Frame-Options issue) diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index 362ac355..e4d2f6cf 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -1,15 +1,21 @@ import React, { useState, useEffect } from 'react' import ReactDOM from 'react-dom' -import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind, Droplets, Sunrise, Sunset, Hotel, Calendar, MapPin, LogIn, LogOut, Hash, Pencil, Plane, Utensils, Train, Car, Ship, Ticket, FileText, Users } from 'lucide-react' +import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind, Droplets, Sunrise, Sunset, Hotel, Calendar, MapPin, LogIn, LogOut, Hash, Pencil, Plane, Utensils, Train, Car, Ship, Ticket, FileText, Users, AlertTriangle } from 'lucide-react' const RES_TYPE_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText } const RES_TYPE_COLORS = { flight: '#3b82f6', hotel: '#8b5cf6', restaurant: '#ef4444', train: '#06b6d4', car: '#6b7280', cruise: '#0ea5e9', event: '#f59e0b', tour: '#10b981', other: '#6b7280' } import { weatherApi, accommodationsApi } from '../../api/client' +import { useToast } from '../shared/Toast' import CustomSelect from '../shared/CustomSelect' import CustomTimePicker from '../shared/CustomTimePicker' import { useSettingsStore } from '../../store/settingsStore' +import { useAddonStore } from '../../store/addonStore' +import { useRoadtripStore } from '../../store/roadtripStore' import { getLocaleForLanguage, useTranslation } from '../../i18n' -import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types' +import { formatDistance, formatDuration, formatFuelCost, calculateDaylightDriving, checkDaylightBookings } from '../../utils/roadtripFormatters' +import { calculateSunriseSunset, formatSolarTime } from '../../utils/solarCalculation' +import { parseDirections, dirSymbol } from '../../utils/directionFormatters' +import type { Day, Place, Category, Reservation, AssignmentsMap, RouteDirection } from '../../types' const WEATHER_ICON_MAP = { Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle, @@ -38,6 +44,42 @@ function formatTime12(val, is12h) { return `${h12}:${String(m).padStart(2, '0')} ${period}` } +function LegDirections({ directions, unitSystem }: { directions: RouteDirection[]; unitSystem: string }) { + const { t } = useTranslation() + const [expanded, setExpanded] = useState(false) + if (directions.length === 0) return null + return ( +
+ + {expanded && ( +
+ {directions.map((d, i) => ( +
+ {dirSymbol(d.maneuver, d.instruction)} + {d.instruction} + {d.distance_meters > 0 && ( + + {formatDistance(d.distance_meters, unitSystem)} + + )} +
+ ))} +
+ )} +
+ ) +} + interface DayDetailPanelProps { day: Day days: Day[] @@ -55,6 +97,7 @@ interface DayDetailPanelProps { } export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0 }: DayDetailPanelProps) { + const toast = useToast() const { t, language, locale } = useTranslation() const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit' const is12h = useSettingsStore(s => s.settings.time_format) === '12h' @@ -251,6 +294,224 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri ) )} + {/* ── Road Trip ── */} + {(() => { + const roadtripEnabled = useAddonStore.getState().isEnabled('roadtrip') + if (!roadtripEnabled) return null + const dayIndex = days.indexOf(day) + if (dayIndex < 0) return null + const roadtripStore = useRoadtripStore.getState() + const legs = roadtripStore.getLegsForDay(String(tripId), dayIndex).filter(l => l.is_road_trip) + if (legs.length === 0) return null + const dayTotals = roadtripStore.getDayTotals(String(tripId), dayIndex) + // Skip warnings if any leg is still pending route calculation + const hasPendingLegs = legs.some(l => !l.duration_seconds) + const unitSystem = (useSettingsStore.getState().settings.roadtrip_unit_system || 'metric') as 'metric' | 'imperial' + const fuelCurrency = useSettingsStore.getState().settings.roadtrip_fuel_currency || useSettingsStore.getState().settings.default_currency || 'USD' + const timeFormat = useSettingsStore.getState().settings.time_format || '24h' + const maxHoursSetting = useSettingsStore.getState().settings.roadtrip_max_driving_hours + const maxHours = maxHoursSetting ? parseFloat(maxHoursSetting) : null + const drivingHours = dayTotals.durationSeconds / 3600 + const exceedsMax = !hasPendingLegs && maxHours != null && drivingHours > maxHours + const daylightOnly = useSettingsStore.getState().settings.roadtrip_daylight_only === 'true' + const restIntervalHours = parseFloat(useSettingsStore.getState().settings.roadtrip_rest_interval_hours || '0') || null + const restDurationMinutes = parseFloat(useSettingsStore.getState().settings.roadtrip_rest_duration_minutes || '0') || null + const hasFuelCost = dayTotals.fuelCost > 0 + + // Daylight driving calculation with departure/arrival recommendations + let daylightDriving: ReturnType | null = null + if (daylightOnly && day.date && !hasPendingLegs) { + const da = assignments[String(day.id)] || [] + const firstGeo = da.find(a => a.place?.lat && a.place?.lng) + const lastGeo = [...da].reverse().find(a => a.place?.lat && a.place?.lng) + if (firstGeo?.place && lastGeo?.place) { + let totalRestSecs = 0 + if (restIntervalHours && restDurationMinutes) { + totalRestSecs = totalRestBreaks * restDurationMinutes * 60 + } + daylightDriving = calculateDaylightDriving( + firstGeo.place.lat!, firstGeo.place.lng!, + lastGeo.place.lat!, lastGeo.place.lng!, + new Date(day.date + 'T12:00:00'), + dayTotals.durationSeconds, totalRestSecs + ) + } + } + + // Rest breaks per leg + let totalRestBreaks = 0 + if (restIntervalHours && restDurationMinutes) { + for (const leg of legs) { + const legHours = (leg.duration_seconds || 0) / 3600 + if (legHours > restIntervalHours) totalRestBreaks += Math.floor(legHours / restIntervalHours) + } + } + const totalRestMinutes = totalRestBreaks * (restDurationMinutes || 0) + + return ( +
+
+
+ + {t('roadtrip.driving')} +
+ + {/* Summary chips */} +
+
+ {formatDistance(dayTotals.distanceMeters, unitSystem)} +
+
+ {formatDuration(dayTotals.durationSeconds)} +
+ {hasFuelCost && ( +
+ {formatFuelCost(dayTotals.fuelCost, fuelCurrency)} +
+ )} +
+ + {/* Daylight driving recommendations */} + {daylightDriving && (() => { + const originAccom = dayAccommodations.find(a => a.end_day_id === day.id) + const destAccom = dayAccommodations.find(a => a.start_day_id === day.id && a.id !== originAccom?.id) || dayAccommodations.find(a => a.start_day_id === day.id) + const booking = checkDaylightBookings(daylightDriving, originAccom?.check_out, destAccom?.check_in) + const formatDep = formatSolarTime(daylightDriving.hasSufficientDaylight ? daylightDriving.latestDepartureForArrival : daylightDriving.recommendedDeparture, timeFormat) + const formatSafeDep = formatSolarTime(daylightDriving.recommendedDeparture, timeFormat) + const formatArr = formatSolarTime(daylightDriving.latestArrival, timeFormat) + const safeDepTimeHHMM = (() => { + const d = daylightDriving.recommendedDeparture + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` + })() + const arrTimeHHMM = (() => { + const d = daylightDriving.latestArrival + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` + })() + + if (booking.allSafe) { + return ( +
+
+
+ + {t('roadtrip.daylightWindow', { sunrise: formatSolarTime(daylightDriving.sunrise, timeFormat), sunset: formatSolarTime(daylightDriving.sunset, timeFormat), hours: daylightDriving.availableHours.toFixed(1) })} +
+
+ + {t('roadtrip.bookingsSafe', { checkout: booking.originCheckout!, checkin: booking.destCheckin! })} +
+
+
+ ) + } + + return ( +
+
+
+ + {t('roadtrip.daylightWindow', { sunrise: formatSolarTime(daylightDriving.sunrise, timeFormat), sunset: formatSolarTime(daylightDriving.sunset, timeFormat), hours: daylightDriving.availableHours.toFixed(1) })} +
+
+ + + {t('roadtrip.recommendedDeparture')}: {formatDep} + + {booking.originSafe === false && ( + + ({t('roadtrip.checkoutTooEarly', { time: booking.originCheckout!, safe: formatSafeDep })}) + + )} + {originAccom && booking.originSafe !== true && ( + + )} +
+
+ + + {t('roadtrip.arrivalBefore')}: {formatArr} + + {booking.destSafe === false && ( + + ({t('roadtrip.checkinTooLate', { time: booking.destCheckin!, safe: formatArr })}) + + )} + {destAccom && booking.destSafe !== true && ( + + )} +
+
+ {t('roadtrip.drivingWindow')}: {daylightDriving.availableHours.toFixed(1)}h +
+ {!daylightDriving.hasSufficientDaylight && ( +
+ + {t('roadtrip.insufficientDaylight')} +
+ )} +
+
+ ) + })()} + + {/* Driving time warning */} + {exceedsMax && ( +
+ + {t('roadtrip.drivingTimeWarning', { actual: formatDuration(dayTotals.durationSeconds), limit: `${maxHours}h` })} +
+ )} + + {/* Individual legs */} +
+ {legs.map(leg => { + const dirs = parseDirections(leg.route_metadata) + return ( +
+
+ + {leg.from_place_name || '?'} → {leg.to_place_name || '?'} + + + {leg.distance_meters ? formatDistance(leg.distance_meters, unitSystem) : ''} + {leg.duration_seconds ? ` · ${formatDuration(leg.duration_seconds)}` : ''} + {leg.fuel_cost ? ` · ${formatFuelCost(leg.fuel_cost, fuelCurrency)}` : ''} + +
+ +
+ ) + })} +
+ + {/* Rest breaks */} + {totalRestBreaks > 0 && ( +
+ ☕ {t('roadtrip.restBreaks', { count: String(totalRestBreaks), minutes: String(totalRestMinutes) })} +
+ )} +
+ ) + })()} + {/* ── Reservations for this day's assignments ── */} {(() => { const dayAssignments = assignments[String(day.id)] || [] diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 9bc511a9..4235af76 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -4,7 +4,7 @@ declare global { interface Window { __dragData: DragDataPayload | null } } import React, { useState, useEffect, useRef } from 'react' import ReactDOM from 'react-dom' -import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users } from 'lucide-react' +import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Fuel, Search } from 'lucide-react' const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText } import { assignmentsApi, reservationsApi } from '../../api/client' @@ -17,9 +17,14 @@ import { useToast } from '../shared/Toast' import { getCategoryIcon } from '../shared/categoryIcons' import { useTripStore } from '../../store/tripStore' import { useSettingsStore } from '../../store/settingsStore' +import { useAddonStore } from '../../store/addonStore' +import { useRoadtripStore } from '../../store/roadtripStore' import { useTranslation } from '../../i18n' import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters' +import { formatDistance, formatDuration, formatFuelCost, calculateVehicleRange, calculateDaylightDriving, checkDaylightBookings } from '../../utils/roadtripFormatters' +import { calculateSunriseSunset, formatSolarTime } from '../../utils/solarCalculation' import { useDayNotes } from '../../hooks/useDayNotes' +import RoadTripSummary from '../Trip/RoadTripSummary' import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types' const NOTE_ICONS = [ @@ -94,6 +99,25 @@ export default function DayPlanSidebar({ const ctxMenu = useContextMenu() const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' const tripStore = useTripStore() + const roadtripEnabled = useAddonStore(s => s.isEnabled('roadtrip')) + const roadtripStore = useRoadtripStore() + const rtUnitSystem = useSettingsStore(s => s.settings.roadtrip_unit_system) || 'metric' + const rtFuelCurrency = useSettingsStore(s => s.settings.roadtrip_fuel_currency) || useSettingsStore(s => s.settings.default_currency) || 'USD' + const rtTankSize = useSettingsStore(s => s.settings.roadtrip_tank_size) + const rtFuelConsumptionForRange = useSettingsStore(s => s.settings.roadtrip_fuel_consumption) + const vehicleRangeMeters = (() => { + if (!rtTankSize || !rtFuelConsumptionForRange) return null + const tank = parseFloat(rtTankSize) + const consumption = parseFloat(rtFuelConsumptionForRange) + if (!tank || !consumption) return null + const us = rtUnitSystem as 'metric' | 'imperial' + return calculateVehicleRange(tank, consumption, us) * (us === 'imperial' ? 1609.344 : 1000) + })() + const rtMaxDrivingHours = useSettingsStore(s => s.settings.roadtrip_max_driving_hours) + const rtRestIntervalHours = useSettingsStore(s => s.settings.roadtrip_rest_interval_hours) + const rtRestDurationMinutes = useSettingsStore(s => s.settings.roadtrip_rest_duration_minutes) + const rtDaylightOnly = useSettingsStore(s => s.settings.roadtrip_daylight_only) === 'true' + const findingStopsLegIds = useRoadtripStore(s => s.findingStopsLegIds) const { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote: _openAddNote, openEditNote: _openEditNote, cancelNote, saveNote, deleteNote: _deleteNote, moveNote: _moveNote } = useDayNotes(tripId) @@ -101,7 +125,7 @@ export default function DayPlanSidebar({ try { const saved = sessionStorage.getItem(`day-expanded-${tripId}`) if (saved) return new Set(JSON.parse(saved)) - } catch {} + } catch { /* sessionStorage may be unavailable */ } return new Set(days.map(d => d.id)) }) const [editingDayId, setEditingDayId] = useState(null) @@ -156,7 +180,7 @@ export default function DayPlanSidebar({ setExpandedDays(prev => { const n = new Set(prev) days.forEach(d => { if (!prev.has(d.id)) n.add(d.id) }) - try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {} + try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch { /* sessionStorage may be unavailable */ } return n }) } @@ -185,7 +209,7 @@ export default function DayPlanSidebar({ setExpandedDays(prev => { const n = new Set(prev) n.has(dayId) ? n.delete(dayId) : n.add(dayId) - try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {} + try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch { /* sessionStorage may be unavailable */ } return n }) } @@ -698,7 +722,16 @@ export default function DayPlanSidebar({ notes.map(n => ({ ...n, day_id: Number(dayId) })) ) try { - await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale }) + const allLegs = Object.values(roadtripStore.routeLegs).flat() + await downloadTripPDF({ + trip, days, places, assignments, categories, dayNotes: flatNotes, reservations, t, locale, + routeLegs: roadtripEnabled ? allLegs : [], + unitSystem: rtUnitSystem, + fuelCurrency: rtFuelCurrency, + maxDrivingHours: rtMaxDrivingHours ? parseFloat(rtMaxDrivingHours) : null, + restIntervalHours: rtRestIntervalHours ? parseFloat(rtRestIntervalHours) : null, + restDurationMinutes: rtRestDurationMinutes ? parseFloat(rtRestDurationMinutes) : null, + }) } catch (e) { console.error('PDF error:', e) toast.error(t('dayplan.pdfError') + ': ' + (e?.message || String(e))) @@ -880,6 +913,106 @@ export default function DayPlanSidebar({
+ {/* Road trip day warnings */} + {roadtripEnabled && isExpanded && (() => { + const dayNum = days.findIndex(d => d.id === day.id) + const dayTotals = roadtripStore.getDayTotals(String(tripId), dayNum) + if (dayTotals.durationSeconds === 0) return null + // Skip warnings if any leg is still pending route calculation + const dayLegs = roadtripStore.getLegsForDay(String(tripId), dayNum).filter(l => l.is_road_trip) + if (dayLegs.some(l => !l.duration_seconds)) return null + const drivingHours = dayTotals.durationSeconds / 3600 + const maxHours = rtMaxDrivingHours ? parseFloat(rtMaxDrivingHours) : null + const exceedsMax = maxHours && drivingHours > maxHours + + // Daylight calculation with departure/arrival recommendations + let daylightDriving: ReturnType | null = null + if (rtDaylightOnly && day.date) { + const firstGeo = da.find(a => a.place?.lat && a.place?.lng) + const lastGeo = [...da].reverse().find(a => a.place?.lat && a.place?.lng) + if (firstGeo?.place && lastGeo?.place) { + const restInterval = rtRestIntervalHours ? parseFloat(rtRestIntervalHours) : null + const restDuration = rtRestDurationMinutes ? parseFloat(rtRestDurationMinutes) : null + let totalRestSeconds = 0 + if (restInterval && restDuration) { + const legs = roadtripStore.getLegsForDay(String(tripId), dayNum).filter(l => l.is_road_trip) + for (const l of legs) { + const lh = (l.duration_seconds || 0) / 3600 + if (lh > restInterval) totalRestSeconds += Math.floor(lh / restInterval) * restDuration * 60 + } + } + daylightDriving = calculateDaylightDriving( + firstGeo.place.lat!, firstGeo.place.lng!, + lastGeo.place.lat!, lastGeo.place.lng!, + new Date(day.date + 'T12:00:00'), + dayTotals.durationSeconds, totalRestSeconds + ) + } + } + + if (!exceedsMax && !daylightDriving) return null + return ( +
+ {exceedsMax && ( +
+ + {t('roadtrip.drivingTimeWarning', { actual: formatDuration(dayTotals.durationSeconds), limit: `${maxHours}h` })} +
+ )} + {daylightDriving && (() => { + const dayAccs = accommodations.filter((a: any) => day.id >= a.start_day_id && day.id <= a.end_day_id) + const originAccom = dayAccs.find((a: any) => a.end_day_id === day.id) + const destAccom = dayAccs.find((a: any) => a.start_day_id === day.id && a.id !== originAccom?.id) || dayAccs.find((a: any) => a.start_day_id === day.id) + const booking = checkDaylightBookings(daylightDriving, originAccom?.check_out, destAccom?.check_in) + if (booking.allSafe) { + return ( +
+ + {t('roadtrip.daylightOk')} +
+ ) + } + return ( +
+
+ ☀️ + {formatSolarTime(daylightDriving.sunrise, timeFormat)} – {formatSolarTime(daylightDriving.sunset, timeFormat)} ({daylightDriving.availableHours.toFixed(1)}h) +
+ {booking.originSafe === false && ( +
+ + {t('roadtrip.checkoutTooEarly', { time: booking.originCheckout!, safe: formatSolarTime(daylightDriving.recommendedDeparture, timeFormat) })} +
+ )} + {booking.destSafe === false && ( +
+ + {t('roadtrip.checkinTooLate', { time: booking.destCheckin!, safe: formatSolarTime(daylightDriving.latestArrival, timeFormat) })} +
+ )} + {booking.originSafe !== false && booking.destSafe !== false && ( +
+ {t('roadtrip.departBy', { time: formatSolarTime(daylightDriving.hasSufficientDaylight ? daylightDriving.latestDepartureForArrival : daylightDriving.recommendedDeparture, timeFormat), arrival: formatSolarTime(daylightDriving.latestArrival, timeFormat) })} +
+ )} + {!daylightDriving.hasSufficientDaylight && ( +
+ + {t('roadtrip.insufficientDaylight')} +
+ )} +
+ ) + })()} +
+ ) + })()} + {/* Aufgeklappte Orte + Notizen */} {isExpanded && (
)} + {/* Road trip leg info line */} + {roadtripEnabled && placeIdx > 0 && (() => { + const prevAssignment = placeItems[placeIdx - 1]?.data + const prevPlace = prevAssignment?.place + if (!prevPlace?.id || !place?.id) return null + const dayNum = days.findIndex(d => d.id === day.id) + const leg = roadtripStore.getLegBetween(String(tripId), dayNum, String(prevPlace.id), String(place.id)) + if (!leg?.is_road_trip || !leg.distance_meters) return null + const exceedsRange = vehicleRangeMeters ? leg.distance_meters > vehicleRangeMeters : false + // Rest break calculation + const restInterval = rtRestIntervalHours ? parseFloat(rtRestIntervalHours) : null + const restDuration = rtRestDurationMinutes ? parseFloat(rtRestDurationMinutes) : null + const legHours = (leg.duration_seconds || 0) / 3600 + const restBreaks = restInterval && restDuration && legHours > restInterval + ? Math.floor(legHours / restInterval) : 0 + const totalRestMinutes = restBreaks * (restDuration || 0) + // Parse found stops from route_metadata + const foundStops = (() => { + if (!leg.route_metadata) return [] + try { + const meta = JSON.parse(leg.route_metadata) + return Array.isArray(meta.found_stops) ? meta.found_stops : [] + } catch { /* expected when route_metadata is malformed */ return [] } + })() + const fuelStops = foundStops.filter((s: { type: string }) => s.type === 'fuel') + const restStops = foundStops.filter((s: { type: string }) => s.type === 'rest') + // Classify critical fuel stops + const criticalFuel: typeof fuelStops = [] + if (fuelStops.length > 0 && vehicleRangeMeters && leg.distance_meters) { + const sorted = [...fuelStops].sort((a, b) => a.distance_along_route_meters - b.distance_along_route_meters) + let lastRefuelAt = 0 + while (lastRefuelAt + vehicleRangeMeters < leg.distance_meters) { + const reachable = sorted.filter(s => + s.distance_along_route_meters > lastRefuelAt && + s.distance_along_route_meters <= lastRefuelAt + vehicleRangeMeters + ) + if (reachable.length === 0) break + const chosen = reachable[reachable.length - 1] + criticalFuel.push(chosen) + lastRefuelAt = chosen.distance_along_route_meters + } + } + const isFinding = findingStopsLegIds.has(leg.id) + return ( +
+
+ + {formatDistance(leg.distance_meters, rtUnitSystem)} + · + {formatDuration(leg.duration_seconds || 0)} + {leg.fuel_cost != null && ( + <> + · + {formatFuelCost(leg.fuel_cost, rtFuelCurrency)} + + )} + {restBreaks > 0 && ( + <> + · + {t('roadtrip.restBreaks', { count: String(restBreaks), minutes: String(totalRestMinutes) })} + + )} + {exceedsRange && ( + + + + )} +
+ {/* Found stop indicators + loading */} + {isFinding ? ( +
+
+ {t('roadtrip.findingStops')} +
+ ) : foundStops.length > 0 ? ( +
+ {criticalFuel.length > 0 && ( + + + {criticalFuel.map(s => s.name).join(', ')} + + )} + {fuelStops.length > 0 && criticalFuel.length === 0 && ( + + {fuelStops.length} {t('roadtrip.realFuelStop')} + + )} + {restStops.length > 0 && ( + + {restStops.length} {t('roadtrip.realRestStop')} + + )} + {/* Refresh Stops button — only when stops already exist */} + +
+ ) : null} +
+ ) + })()} {(() => { const res = reservations.find(r => r.assignment_id === assignment.id) if (!res) return null @@ -1157,6 +1406,36 @@ export default function DayPlanSidebar({
)} + {/* Road Trip toggle — only for non-first places when addon is enabled */} + {roadtripEnabled && placeIdx > 0 && (() => { + const prevAssignment = placeItems[placeIdx - 1]?.data + const prevPlace = prevAssignment?.place + if (!prevPlace?.id || !place?.id) return null + const dayNum = days.findIndex(d => d.id === day.id) + const leg = roadtripStore.getLegBetween(String(tripId), dayNum, String(prevPlace.id), String(place.id)) + const isRoadTrip = leg?.is_road_trip ?? false + return ( + + ) + })()}
+ {roadtripEnabled && (() => { + const da = getDayAssignments(day.id) + const dayNum = days.findIndex(d => d.id === day.id) + const dayLegs = roadtripStore.getLegsForDay(String(tripId), dayNum) + const allActive = da.length >= 2 && dayLegs.filter(l => l.is_road_trip).length >= da.length - 1 + return ( + + ) + })()} + + {/* Global toggle */} + {totalPossiblePairs > 0 && ( + + )} +
+ + {expanded && hasActiveLegs && totals && ( +
+ {/* Daylight mode indicator */} + {daylightOnly && ( +
+ 🌅 + {t('roadtrip.daylightMode')} +
+ )} + + {/* Summary totals */} +
+
+
+ {formatDistance(totals.totalDistanceMeters, unitSystem)} +
+
{t('roadtrip.totalDistance')}
+
+
+
+ {formatDuration(totals.totalDurationSeconds)} +
+
{t('roadtrip.totalTime')}
+
+ {hasFuelCost && ( +
+
+ {formatFuelCost(totals.totalFuelCost, fuelCurrency)} +
+
{t('roadtrip.totalFuel')}
+
+ )} +
+ + {/* Fuel price — inline editable */} + {hasFuelCost && ( +
+ + {editingFuelPrice ? ( +
{ + e.preventDefault() + try { + await tripStore.updateTrip(String(tripId), { roadtrip_fuel_price: fuelPriceInput || '' }) + await roadtripStore.recalculate(String(tripId)) + } catch (err) { console.error('Failed to update fuel price:', err) } + setEditingFuelPrice(false) + }} style={{ display: 'flex', alignItems: 'center', gap: 3 }}> + {t('roadtrip.tripFuelPrice')}: + setFuelPriceInput(e.target.value)} + placeholder={userFuelPrice || '0.00'} + onBlur={() => setEditingFuelPrice(false)} + style={{ width: 60, padding: '1px 4px', fontSize: 10, border: '1px solid var(--border-faint)', borderRadius: 4, background: 'var(--bg-card)', color: 'var(--text-primary)', fontFamily: 'inherit' }} + /> + {fuelCurrency}/{unitSystem === 'imperial' ? 'gal' : 'L'} +
+ ) : ( + <> + {t('roadtrip.tripFuelPrice')}: {tripFuelPrice || userFuelPrice || '—'} {fuelCurrency}/{unitSystem === 'imperial' ? 'gal' : 'L'} + {tripFuelPrice && ({t('roadtrip.tripOverride')})} + + + )} +
+ )} + + {/* Total with breaks */} + {hasRestBreaks && ( +
+ + {t('roadtrip.totalWithBreaks')}: {formatDuration(totals.totalTravelTimeWithBreaks)} + ({totals.totalRestBreaks} {t('roadtrip.restBreaks', { count: String(totals.totalRestBreaks), minutes: String(totals.totalRestBreaks * (restDuration || 0)) }).split('(')[0].trim()}) +
+ )} + + {/* Fuel stops estimate */} + {vehicleRangeMeters && totalFuelStops > 0 && ( +
+ + {t('roadtrip.fuelStopsEstimate', { count: String(totalFuelStops) })} +
+ )} + + {/* Per-day breakdown */} + {dayBreakdown.length > 1 && ( +
+ + + + + + + {hasFuelCost && } + {daylightOnly && } + + + + {dayBreakdown.map(row => { + const drivingHours = row.durationSeconds / 3600 + const exceedsDaylight = row.daylightHours != null && row.daylightHours > 0 && drivingHours > row.daylightHours + return ( + + + + + {hasFuelCost && ( + + )} + {daylightOnly && ( + + )} + + ) + })} + +
{t('roadtrip.day')}{t('roadtrip.distance')}{t('roadtrip.driveTime')}{t('roadtrip.fuelCost')}☀️
+ {row.day.title || `${t('roadtrip.day')} ${row.dayIndex + 1}`} + + {formatDistance(row.distanceMeters, unitSystem)} + + {formatDuration(row.durationSeconds)} + + {row.fuelCost > 0 ? formatFuelCost(row.fuelCost, fuelCurrency) : '—'} + + {row.daylightHours != null ? `${row.daylightHours.toFixed(1)}h` : '—'} +
+
+ )} + + {/* Speed adjustment note */} + {maxSpeed && parseFloat(maxSpeed) > 0 && ( +
+ {t('roadtrip.speedAdjusted', { + speed: `${maxSpeed} ${unitSystem === 'imperial' ? 'mph' : 'km/h'}`, + })} +
+ )} + + {/* Recalculate button */} + +
+ )} + + ) +} diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 74493361..f7003b23 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -145,6 +145,115 @@ const ar: Record = { 'settings.timeFormat': 'تنسيق الوقت', 'settings.routeCalculation': 'حساب المسار', 'settings.blurBookingCodes': 'إخفاء رموز الحجز', + 'settings.roadtrip.title': 'رحلة برية', + 'settings.roadtrip.unitSystem': 'نظام الوحدات', + 'settings.roadtrip.metric': 'متري', + 'settings.roadtrip.imperial': 'إمبريالي', + 'settings.roadtrip.vehicle': 'المركبة', + 'settings.roadtrip.fuelPricePerLitre': 'سعر الوقود (لكل لتر)', + 'settings.roadtrip.fuelPricePerGallon': 'سعر الوقود (لكل غالون)', + 'settings.roadtrip.fuelCurrency': 'عملة الوقود', + 'settings.roadtrip.fuelConsumptionMetric': 'استهلاك الوقود (لتر/100 كم)', + 'settings.roadtrip.fuelConsumptionImperial': 'كفاءة الوقود (MPG)', + 'settings.roadtrip.tankSizeLitres': 'حجم خزان الوقود (لترات)', + 'settings.roadtrip.tankSizeGallons': 'حجم خزان الوقود (غالونات)', + 'settings.roadtrip.drivingPreferences': 'تفضيلات القيادة', + 'settings.roadtrip.maxDrivingHours': 'أقصى ساعات قيادة', + 'settings.roadtrip.restInterval': 'فترة الراحة (ساعات)', + 'settings.roadtrip.restDuration': 'مدة الراحة (دقائق)', + 'settings.roadtrip.maxSpeed': 'السرعة القصوى', + 'settings.roadtrip.maxSpeedHelp': 'يحدّ من أوقات القيادة المقدّرة على الطرق ذات حدود السرعة الأعلى. اتركه فارغاً لاستخدام حدود السرعة المنشورة.', + 'settings.roadtrip.routePreferences': 'تفضيلات المسار', + 'settings.roadtrip.daylightOnly': 'القيادة نهارًا فقط', + 'settings.roadtrip.roadPreference': 'تفضيل الطريق', + 'settings.roadtrip.anyRoad': 'أي طريق', + 'settings.roadtrip.sealedOnly': 'طرق معبّدة فقط', + 'settings.roadtrip.fuelPreferences': 'تفضيلات الوقود', + 'settings.roadtrip.fuelType': 'نوع الوقود', + 'settings.roadtrip.fuelBrand': 'العلامة التجارية المفضلة', + 'settings.roadtrip.fuelTypeAny': 'أي وقود', + 'settings.roadtrip.fuelTypeDiesel': 'ديزل', + 'settings.roadtrip.fuelTypePetrol': 'بنزين', + 'settings.roadtrip.brandAny': 'أي علامة تجارية', + 'settings.roadtrip.brandPreferred': 'العلامة التجارية المفضلة — تظهر العلامات الأخرى عند الحاجة', + 'settings.roadtrip.avoidFerries': 'تجنب العبّارات', + 'settings.roadtrip.preferHighways': 'تفضيل الطرق السريعة', + 'settings.roadtrip.truckSafe': 'آمن للشاحنات / المنازل المتنقلة', + 'roadtrip.legActive': 'مسار الرحلة البرية نشط', + 'roadtrip.markLeg': 'وضع علامة كمرحلة رحلة برية', + 'roadtrip.toggleAll': 'تبديل جميع مراحل الرحلة البرية', + 'roadtrip.summary': 'ملخص الرحلة البرية', + 'roadtrip.totalDistance': 'المسافة الإجمالية', + 'roadtrip.totalTime': 'وقت القيادة', + 'roadtrip.totalFuel': 'تكلفة الوقود', + 'roadtrip.day': 'يوم', + 'roadtrip.distance': 'المسافة', + 'roadtrip.driveTime': 'وقت القيادة', + 'roadtrip.fuelCost': 'الوقود', + 'roadtrip.recalculateFuel': 'إعادة حساب تكاليف الوقود', + 'roadtrip.recalculate': 'إعادة الحساب', + 'roadtrip.speedAdjusted': 'تم تعديل أوقات القيادة للسرعة القصوى {speed}', + 'roadtrip.rangeWarning': 'هذه المرحلة ({distance}) تتجاوز مدى مركبتك ({range}). ستحتاج إلى التزوّد بالوقود.', + 'roadtrip.fuelStopsEstimate': 'محطات التزوّد المقدّرة: {count}', + 'roadtrip.exceedsRange': 'يتجاوز مدى المركبة', + 'roadtrip.refuelPoint': 'نقطة تزوّد تقريبية', + 'roadtrip.drivingTimeWarning': 'وقت القيادة ({actual}) يتجاوز الحد اليومي {limit}', + 'roadtrip.restBreaks': '{count} استراحات (+{minutes} دقيقة)', + 'roadtrip.restStop': 'استراحة موصى بها', + 'roadtrip.totalWithBreaks': 'الإجمالي مع الاستراحات', + 'roadtrip.daylightWindow': '{sunrise} – {sunset} ({hours} ساعة)', + 'roadtrip.daylightWarning': 'وقت القيادة ({driving}) يتجاوز ساعات النهار ({daylight})', + 'roadtrip.daylightMode': 'وضع القيادة النهارية نشط', + 'roadtrip.truckSafeHelp': 'يتطلب خادم توجيه مستضاف ذاتيًا بملف تعريف الشاحنات. الخادم الافتراضي يستخدم توجيه السيارات القياسي.', + 'roadtrip.sealedOnlyHelp': 'يتطلب ملف تعريف توجيه مخصصًا. قد يتضمن الخادم الافتراضي طرقًا غير معبّدة.', + 'roadtrip.exceedsDaylight': 'يتجاوز ساعات النهار المتاحة', + 'roadtrip.estimatedRange': 'المدى المقدّر: {range}', + 'roadtrip.driving': 'القيادة', + 'roadtrip.pdfDrivingSummary': 'ملخص القيادة', + 'roadtrip.pdfTripTotal': 'إجمالي الرحلة', + 'roadtrip.toggleAllTrip': 'تبديل جميع مراحل الرحلة البرية', + 'roadtrip.calculatingRoutes': 'جارٍ حساب المسارات…', + 'roadtrip.showFullRoute': 'عرض مسار الرحلة الكامل', + 'roadtrip.directions': 'الاتجاهات', + 'roadtrip.directionsOmitted': 'تم حذف الاتجاهات للمراحل التي تزيد عن 800 كم', + 'roadtrip.directionCount': '{count} اتجاهات', + 'roadtrip.arrive': 'الوصول إلى الوجهة', + 'roadtrip.depart': 'المغادرة', + 'roadtrip.findStops': 'البحث عن محطات', + 'roadtrip.stopsFound': 'تم العثور على {count} محطات', + 'roadtrip.stopSource': 'مصدر بيانات المحطات', + 'roadtrip.stopSourceOsm': 'OpenStreetMap (مجاني)', + 'roadtrip.stopSourceGoogle': 'Google Places (بيانات أغنى)', + 'roadtrip.stopSourceHelp': 'مصدر للبحث عن محطات الوقود ومناطق الاستراحة على طول الطريق.', + 'roadtrip.stopSourceGoogleDisabled': 'يتطلب Google Places مفتاح API لخرائط Google تم تكوينه بواسطة المسؤول.', + 'roadtrip.realFuelStop': 'محطة وقود', + 'roadtrip.realRestStop': 'استراحة', + 'roadtrip.openInMaps': 'فتح في خرائط Google', + 'roadtrip.approximateStop': 'محطة تقريبية', + 'roadtrip.refreshStops': 'تحديث', + 'roadtrip.findingStops': 'جارٍ البحث عن محطات…', + 'roadtrip.recommendedDeparture': 'المغادرة بعد', + 'roadtrip.arrivalBefore': 'الوصول قبل', + 'roadtrip.drivingWindow': 'نافذة القيادة', + 'roadtrip.departBy': 'المغادرة في {time} · الوصول في {arrival}', + 'roadtrip.insufficientDaylight': 'لا يوجد ضوء نهار كافٍ لهذه الرحلة. فكّر في تقسيمها على يومين.', + 'roadtrip.setCheckout': 'تعيين وقت المغادرة', + 'roadtrip.setCheckin': 'تعيين وقت الوصول', + 'roadtrip.checkoutUpdated': 'تم تحديث وقت المغادرة', + 'roadtrip.checkinUpdated': 'تم تحديث وقت الوصول', + 'roadtrip.daylightOk': 'القيادة النهارية موافق', + 'roadtrip.bookingsSafe': 'وقت المغادرة ({checkout}) ووقت الوصول ({checkin}) ضمن نافذة ضوء النهار الآمنة', + 'roadtrip.checkoutTooEarly': 'وقت المغادرة ({time}) قبل الانطلاق الآمن ({safe})', + 'roadtrip.checkinTooLate': 'وقت الوصول ({time}) بعد الوصول الآمن ({safe})', + 'roadtrip.criticalFuelStop': 'يجب التزود بالوقود', + 'roadtrip.optionalFuelStop': 'متاح', + 'roadtrip.mustRefuel': 'تزود بالوقود', + 'roadtrip.fuelAvailable': 'متاح', + 'roadtrip.tripFuelPrice': 'سعر الوقود', + 'roadtrip.tripFuelPriceHelp': 'تعيين سعر وقود خاص بهذه الرحلة', + 'roadtrip.tripOverride': 'سعر الرحلة', + 'roadtrip.noPreferredBrand': 'لم يتم العثور على محطات العلامة التجارية المفضلة', + 'settings.roadtrip.stopSource': 'بحث المحطات', 'settings.notifications': 'الإشعارات', 'settings.notifyTripInvite': 'دعوات الرحلات', 'settings.notifyBookingChange': 'تغييرات الحجز', @@ -699,6 +808,16 @@ const ar: Record = { 'atlas.tripPlural': 'رحلات', 'atlas.placeVisited': 'مكان تمت زيارته', 'atlas.placesVisited': 'أماكن تمت زيارتها', + 'atlas.roadTrips': 'رحلات بالسيارة', + 'atlas.totalDriven': 'إجمالي المسافة', + 'atlas.totalRoadTrips': 'رحلات', + 'atlas.totalDrivingTime': 'وقت القيادة', + 'atlas.totalFuelSpent': 'تكلفة الوقود', + 'atlas.longestTrip': 'أطول رحلة', + 'atlas.longestDay': 'أطول يوم', + 'atlas.tripLegend': 'رحلات', + 'atlas.viewTrip': 'عرض الرحلة', + 'atlas.noRoadTrips': 'لا توجد بيانات رحلات بالسيارة بعد. حدد المراحل كرحلات بالسيارة في خطط سفرك لرؤيتها هنا.', // Trip Planner 'trip.tabs.plan': 'الخطة', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index f00adb99..c1124e0f 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -140,6 +140,115 @@ const br: Record = { 'settings.timeFormat': 'Formato de hora', 'settings.routeCalculation': 'Cálculo de rota', 'settings.blurBookingCodes': 'Ocultar códigos de reserva', + 'settings.roadtrip.title': 'Viagem de carro', + 'settings.roadtrip.unitSystem': 'Sistema de unidades', + 'settings.roadtrip.metric': 'Métrico', + 'settings.roadtrip.imperial': 'Imperial', + 'settings.roadtrip.vehicle': 'Veículo', + 'settings.roadtrip.fuelPricePerLitre': 'Preço do combustível (por litro)', + 'settings.roadtrip.fuelPricePerGallon': 'Preço do combustível (por galão)', + 'settings.roadtrip.fuelCurrency': 'Moeda do combustível', + 'settings.roadtrip.fuelConsumptionMetric': 'Consumo de combustível (L/100km)', + 'settings.roadtrip.fuelConsumptionImperial': 'Rendimento de combustível (MPG)', + 'settings.roadtrip.tankSizeLitres': 'Capacidade do tanque (litros)', + 'settings.roadtrip.tankSizeGallons': 'Capacidade do tanque (galões)', + 'settings.roadtrip.drivingPreferences': 'Preferências de direção', + 'settings.roadtrip.maxDrivingHours': 'Horas máximas de direção', + 'settings.roadtrip.restInterval': 'Intervalo de descanso (horas)', + 'settings.roadtrip.restDuration': 'Duração do descanso (minutos)', + 'settings.roadtrip.maxSpeed': 'Velocidade máxima', + 'settings.roadtrip.maxSpeedHelp': 'Limita os tempos de condução estimados em estradas com limites de velocidade mais altos. Deixe vazio para usar os limites sinalizados.', + 'settings.roadtrip.routePreferences': 'Preferências de rota', + 'settings.roadtrip.daylightOnly': 'Dirigir somente de dia', + 'settings.roadtrip.roadPreference': 'Preferência de estrada', + 'settings.roadtrip.anyRoad': 'Qualquer estrada', + 'settings.roadtrip.sealedOnly': 'Somente pavimentada', + 'settings.roadtrip.fuelPreferences': 'Preferências de combustível', + 'settings.roadtrip.fuelType': 'Tipo de combustível', + 'settings.roadtrip.fuelBrand': 'Marca preferida', + 'settings.roadtrip.fuelTypeAny': 'Qualquer combustível', + 'settings.roadtrip.fuelTypeDiesel': 'Diesel', + 'settings.roadtrip.fuelTypePetrol': 'Gasolina', + 'settings.roadtrip.brandAny': 'Qualquer marca', + 'settings.roadtrip.brandPreferred': 'Marca preferida — outras exibidas se necessário', + 'settings.roadtrip.avoidFerries': 'Evitar balsas', + 'settings.roadtrip.preferHighways': 'Preferir rodovias', + 'settings.roadtrip.truckSafe': 'Seguro para caminhão / motorhome', + 'roadtrip.legActive': 'Rota da viagem ativa', + 'roadtrip.markLeg': 'Marcar como trecho da viagem', + 'roadtrip.toggleAll': 'Alternar todos os trechos da viagem', + 'roadtrip.summary': 'Resumo da viagem de carro', + 'roadtrip.totalDistance': 'Distância total', + 'roadtrip.totalTime': 'Tempo de direção', + 'roadtrip.totalFuel': 'Custo de combustível', + 'roadtrip.day': 'Dia', + 'roadtrip.distance': 'Distância', + 'roadtrip.driveTime': 'Tempo de direção', + 'roadtrip.fuelCost': 'Combustível', + 'roadtrip.recalculateFuel': 'Recalcular custos de combustível', + 'roadtrip.recalculate': 'Recalcular', + 'roadtrip.speedAdjusted': 'Tempos de condução ajustados para velocidade máxima de {speed}', + 'roadtrip.rangeWarning': 'Este trecho ({distance}) excede a autonomia do seu veículo ({range}). Será necessário reabastecer.', + 'roadtrip.fuelStopsEstimate': 'Paradas para abastecimento estimadas: {count}', + 'roadtrip.exceedsRange': 'Excede a autonomia do veículo', + 'roadtrip.refuelPoint': 'Ponto de abastecimento aproximado', + 'roadtrip.drivingTimeWarning': 'O tempo de direção ({actual}) excede o seu limite diário de {limit}', + 'roadtrip.restBreaks': '{count} paradas de descanso (+{minutes} min)', + 'roadtrip.restStop': 'Parada de descanso recomendada', + 'roadtrip.totalWithBreaks': 'Total com paradas', + 'roadtrip.daylightWindow': '{sunrise} – {sunset} ({hours}h)', + 'roadtrip.daylightWarning': 'A direção ({driving}) excede as horas de luz ({daylight})', + 'roadtrip.daylightMode': 'Modo direção diurna ativo', + 'roadtrip.truckSafeHelp': 'Requer um servidor de rotas próprio com perfil para caminhões. O servidor padrão usa rotas de carro convencional.', + 'roadtrip.sealedOnlyHelp': 'Requer um perfil de rotas personalizado. O servidor padrão pode incluir estradas não pavimentadas.', + 'roadtrip.exceedsDaylight': 'Excede as horas de luz disponíveis', + 'roadtrip.estimatedRange': 'Autonomia estimada: {range}', + 'roadtrip.driving': 'Direção', + 'roadtrip.pdfDrivingSummary': 'Resumo de direção', + 'roadtrip.pdfTripTotal': 'Total da viagem', + 'roadtrip.toggleAllTrip': 'Alternar todos os trechos da viagem', + 'roadtrip.calculatingRoutes': 'Calculando rotas…', + 'roadtrip.showFullRoute': 'Mostrar rota completa da viagem', + 'roadtrip.directions': 'Direções', + 'roadtrip.directionsOmitted': 'Direções omitidas para trechos acima de 800 km', + 'roadtrip.directionCount': '{count} direções', + 'roadtrip.arrive': 'Chegar ao destino', + 'roadtrip.depart': 'Partida', + 'roadtrip.findStops': 'Buscar paradas', + 'roadtrip.stopsFound': '{count} paradas encontradas', + 'roadtrip.stopSource': 'Fonte de dados de paradas', + 'roadtrip.stopSourceOsm': 'OpenStreetMap (gratuito)', + 'roadtrip.stopSourceGoogle': 'Google Places (dados mais ricos)', + 'roadtrip.stopSourceHelp': 'Fonte para busca de postos de combustível e áreas de descanso ao longo da rota.', + 'roadtrip.stopSourceGoogleDisabled': 'Google Places requer uma chave de API do Google Maps configurada pelo administrador.', + 'roadtrip.realFuelStop': 'posto', + 'roadtrip.realRestStop': 'descanso', + 'roadtrip.openInMaps': 'Abrir no Google Maps', + 'roadtrip.approximateStop': 'Parada aproximada', + 'roadtrip.refreshStops': 'Atualizar', + 'roadtrip.findingStops': 'Buscando paradas…', + 'roadtrip.recommendedDeparture': 'Partir depois de', + 'roadtrip.arrivalBefore': 'Chegar antes de', + 'roadtrip.drivingWindow': 'Janela de direção', + 'roadtrip.departBy': 'Partir às {time} · Chegar às {arrival}', + 'roadtrip.insufficientDaylight': 'Luz do dia insuficiente para esta viagem. Considere dividir em dois dias.', + 'roadtrip.setCheckout': 'Definir check-out', + 'roadtrip.setCheckin': 'Definir check-in', + 'roadtrip.checkoutUpdated': 'Horário de check-out atualizado', + 'roadtrip.checkinUpdated': 'Horário de check-in atualizado', + 'roadtrip.daylightOk': 'Condução diurna OK', + 'roadtrip.bookingsSafe': 'Seu check-out ({checkout}) e check-in ({checkin}) estão dentro da janela segura de luz do dia', + 'roadtrip.checkoutTooEarly': 'Check-out ({time}) é antes da partida segura ({safe})', + 'roadtrip.checkinTooLate': 'Check-in ({time}) é após a chegada segura ({safe})', + 'roadtrip.criticalFuelStop': 'Deve abastecer', + 'roadtrip.optionalFuelStop': 'Disponível', + 'roadtrip.mustRefuel': 'ABASTECER', + 'roadtrip.fuelAvailable': 'Disponível', + 'roadtrip.tripFuelPrice': 'Preço do combustível', + 'roadtrip.tripFuelPriceHelp': 'Preço de combustível específico para esta viagem', + 'roadtrip.tripOverride': 'preço da viagem', + 'roadtrip.noPreferredBrand': 'Nenhum posto da marca preferida encontrado', + 'settings.roadtrip.stopSource': 'Busca de paradas', 'settings.notifications': 'Notificações', 'settings.notifyTripInvite': 'Convites de viagem', 'settings.notifyBookingChange': 'Alterações de reserva', @@ -679,6 +788,16 @@ const br: Record = { 'atlas.tripPlural': 'Viagens', 'atlas.placeVisited': 'Lugar visitado', 'atlas.placesVisited': 'Lugares visitados', + 'atlas.roadTrips': 'Viagens de carro', + 'atlas.totalDriven': 'Total percorrido', + 'atlas.totalRoadTrips': 'Viagens', + 'atlas.totalDrivingTime': 'Tempo de direção', + 'atlas.totalFuelSpent': 'Gasto com combustível', + 'atlas.longestTrip': 'Maior viagem', + 'atlas.longestDay': 'Maior dia', + 'atlas.tripLegend': 'Viagens', + 'atlas.viewTrip': 'Ver viagem', + 'atlas.noRoadTrips': 'Ainda não há dados de viagens de carro. Marque trechos como viagens de carro nos seus planos para vê-los aqui.', // Trip Planner 'trip.tabs.plan': 'Plano', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 856098f8..8ade5e06 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -141,6 +141,115 @@ const cs: Record = { 'settings.timeFormat': 'Formát času', 'settings.routeCalculation': 'Výpočet trasy', 'settings.blurBookingCodes': 'Skrýt rezervační kódy', + 'settings.roadtrip.title': 'Výlet autem', + 'settings.roadtrip.unitSystem': 'Systém jednotek', + 'settings.roadtrip.metric': 'Metrický', + 'settings.roadtrip.imperial': 'Imperiální', + 'settings.roadtrip.vehicle': 'Vozidlo', + 'settings.roadtrip.fuelPricePerLitre': 'Cena paliva (za litr)', + 'settings.roadtrip.fuelPricePerGallon': 'Cena paliva (za galon)', + 'settings.roadtrip.fuelCurrency': 'Měna paliva', + 'settings.roadtrip.fuelConsumptionMetric': 'Spotřeba paliva (L/100km)', + 'settings.roadtrip.fuelConsumptionImperial': 'Spotřeba paliva (MPG)', + 'settings.roadtrip.tankSizeLitres': 'Objem nádrže (litry)', + 'settings.roadtrip.tankSizeGallons': 'Objem nádrže (galony)', + 'settings.roadtrip.drivingPreferences': 'Předvolby jízdy', + 'settings.roadtrip.maxDrivingHours': 'Max. hodin jízdy', + 'settings.roadtrip.restInterval': 'Interval odpočinku (hodiny)', + 'settings.roadtrip.restDuration': 'Délka odpočinku (minuty)', + 'settings.roadtrip.maxSpeed': 'Maximální rychlost', + 'settings.roadtrip.maxSpeedHelp': 'Omezuje odhadované časy jízdy na silnicích s vyššími rychlostními limity. Ponechte prázdné pro použití značených limitů.', + 'settings.roadtrip.routePreferences': 'Předvolby trasy', + 'settings.roadtrip.daylightOnly': 'Jízda pouze za denního světla', + 'settings.roadtrip.roadPreference': 'Preference silnic', + 'settings.roadtrip.anyRoad': 'Jakákoli silnice', + 'settings.roadtrip.sealedOnly': 'Pouze zpevněné', + 'settings.roadtrip.fuelPreferences': 'Preference paliva', + 'settings.roadtrip.fuelType': 'Typ paliva', + 'settings.roadtrip.fuelBrand': 'Preferovaná značka', + 'settings.roadtrip.fuelTypeAny': 'Jakékoli palivo', + 'settings.roadtrip.fuelTypeDiesel': 'Nafta', + 'settings.roadtrip.fuelTypePetrol': 'Benzín', + 'settings.roadtrip.brandAny': 'Jakákoli značka', + 'settings.roadtrip.brandPreferred': 'Preferovaná značka — ostatní zobrazeny v případě potřeby', + 'settings.roadtrip.avoidFerries': 'Vyhnout se trajektům', + 'settings.roadtrip.preferHighways': 'Preferovat dálnice', + 'settings.roadtrip.truckSafe': 'Bezpečné pro nákladní / obytné vozy', + 'roadtrip.legActive': 'Trasa výletu aktivní', + 'roadtrip.markLeg': 'Označit jako úsek výletu', + 'roadtrip.toggleAll': 'Přepnout všechny úseky výletu', + 'roadtrip.summary': 'Souhrn výletu autem', + 'roadtrip.totalDistance': 'Celková vzdálenost', + 'roadtrip.totalTime': 'Doba jízdy', + 'roadtrip.totalFuel': 'Náklady na palivo', + 'roadtrip.day': 'Den', + 'roadtrip.distance': 'Vzdálenost', + 'roadtrip.driveTime': 'Doba jízdy', + 'roadtrip.fuelCost': 'Palivo', + 'roadtrip.recalculateFuel': 'Přepočítat náklady na palivo', + 'roadtrip.recalculate': 'Přepočítat', + 'roadtrip.speedAdjusted': 'Časy jízdy upraveny pro maximální rychlost {speed}', + 'roadtrip.rangeWarning': 'Tento úsek ({distance}) přesahuje dojezd vašeho vozidla ({range}). Bude nutné doplnit palivo.', + 'roadtrip.fuelStopsEstimate': 'Odhadované zastávky na tankování: {count}', + 'roadtrip.exceedsRange': 'Přesahuje dojezd vozidla', + 'roadtrip.refuelPoint': 'Přibližný bod tankování', + 'roadtrip.drivingTimeWarning': 'Doba jízdy ({actual}) přesahuje váš denní limit {limit}', + 'roadtrip.restBreaks': '{count} přestávek (+{minutes} min)', + 'roadtrip.restStop': 'Doporučená přestávka', + 'roadtrip.totalWithBreaks': 'Celkem s přestávkami', + 'roadtrip.daylightWindow': '{sunrise} – {sunset} ({hours}h)', + 'roadtrip.daylightWarning': 'Doba jízdy ({driving}) přesahuje denní světlo ({daylight})', + 'roadtrip.daylightMode': 'Režim jízdy za denního světla aktivní', + 'roadtrip.truckSafeHelp': 'Vyžaduje vlastní routovací server s profilem pro nákladní vozy. Výchozí server používá běžné routování pro osobní auta.', + 'roadtrip.sealedOnlyHelp': 'Vyžaduje vlastní routovací profil. Výchozí server může zahrnovat nezpevněné cesty.', + 'roadtrip.exceedsDaylight': 'Přesahuje dostupné hodiny denního světla', + 'roadtrip.estimatedRange': 'Odhadovaný dojezd: {range}', + 'roadtrip.driving': 'Jízda', + 'roadtrip.pdfDrivingSummary': 'Souhrn jízdy', + 'roadtrip.pdfTripTotal': 'Celkový výlet', + 'roadtrip.toggleAllTrip': 'Přepnout všechny úseky výletu', + 'roadtrip.calculatingRoutes': 'Výpočet tras…', + 'roadtrip.showFullRoute': 'Zobrazit celou trasu výletu', + 'roadtrip.directions': 'Navigace', + 'roadtrip.directionsOmitted': 'Navigace vynechána pro úseky delší než 800 km', + 'roadtrip.directionCount': '{count} pokynů', + 'roadtrip.arrive': 'Příjezd do cíle', + 'roadtrip.depart': 'Odjezd', + 'roadtrip.findStops': 'Hledat zastávky', + 'roadtrip.stopsFound': '{count} zastávek nalezeno', + 'roadtrip.stopSource': 'Zdroj dat zastávek', + 'roadtrip.stopSourceOsm': 'OpenStreetMap (zdarma)', + 'roadtrip.stopSourceGoogle': 'Google Places (bohatší data)', + 'roadtrip.stopSourceHelp': 'Zdroj pro vyhledávání čerpacích stanic a odpočívadel podél trasy.', + 'roadtrip.stopSourceGoogleDisabled': 'Google Places vyžaduje klíč API Google Maps nakonfigurovaný administrátorem.', + 'roadtrip.realFuelStop': 'čerpací stanice', + 'roadtrip.realRestStop': 'odpočívadlo', + 'roadtrip.openInMaps': 'Otevřít v Google Maps', + 'roadtrip.approximateStop': 'Přibližná zastávka', + 'roadtrip.refreshStops': 'Obnovit', + 'roadtrip.findingStops': 'Hledání zastávek…', + 'roadtrip.recommendedDeparture': 'Odjet po', + 'roadtrip.arrivalBefore': 'Přijet před', + 'roadtrip.drivingWindow': 'Časové okno jízdy', + 'roadtrip.departBy': 'Odjezd v {time} · Příjezd v {arrival}', + 'roadtrip.insufficientDaylight': 'Nedostatek denního světla pro tuto cestu. Zvažte rozdělení na dva dny.', + 'roadtrip.setCheckout': 'Nastavit check-out', + 'roadtrip.setCheckin': 'Nastavit check-in', + 'roadtrip.checkoutUpdated': 'Čas check-outu aktualizován', + 'roadtrip.checkinUpdated': 'Čas check-inu aktualizován', + 'roadtrip.daylightOk': 'Denní jízda OK', + 'roadtrip.bookingsSafe': 'Váš check-out ({checkout}) a check-in ({checkin}) jsou v bezpečném denním okně', + 'roadtrip.checkoutTooEarly': 'Check-out ({time}) je před bezpečným odjezdem ({safe})', + 'roadtrip.checkinTooLate': 'Check-in ({time}) je po bezpečném příjezdu ({safe})', + 'roadtrip.criticalFuelStop': 'Nutné tankovat', + 'roadtrip.optionalFuelStop': 'Dostupné', + 'roadtrip.mustRefuel': 'TANKOVAT', + 'roadtrip.fuelAvailable': 'Dostupné', + 'roadtrip.tripFuelPrice': 'Cena paliva', + 'roadtrip.tripFuelPriceHelp': 'Cena paliva specifická pro tuto cestu', + 'roadtrip.tripOverride': 'cena cesty', + 'roadtrip.noPreferredBrand': 'Nenalezeny stanice preferované značky', + 'settings.roadtrip.stopSource': 'Hledání zastávek', 'settings.notifications': 'Oznámení', 'settings.notifyTripInvite': 'Pozvánky na cesty', 'settings.notifyBookingChange': 'Změny rezervací', @@ -700,6 +809,16 @@ const cs: Record = { 'atlas.tripPlural': 'Cesty', 'atlas.placeVisited': 'Navštívené místo', 'atlas.placesVisited': 'Navštívená místa', + 'atlas.roadTrips': 'Výlety autem', + 'atlas.totalDriven': 'Celkem najeto', + 'atlas.totalRoadTrips': 'Výlety', + 'atlas.totalDrivingTime': 'Doba jízdy', + 'atlas.totalFuelSpent': 'Náklady na palivo', + 'atlas.longestTrip': 'Nejdelší výlet', + 'atlas.longestDay': 'Nejdelší den', + 'atlas.tripLegend': 'Výlety', + 'atlas.viewTrip': 'Zobrazit výlet', + 'atlas.noRoadTrips': 'Zatím žádné údaje o výletech autem. Označte úseky jako výlety autem ve svých plánech, abyste je zde viděli.', // Plánovač cesty (Trip Planner) 'trip.tabs.plan': 'Plán', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 341a36d1..7be9d5f6 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -140,6 +140,115 @@ const de: Record = { 'settings.timeFormat': 'Zeitformat', 'settings.routeCalculation': 'Routenberechnung', 'settings.blurBookingCodes': 'Buchungscodes verbergen', + 'settings.roadtrip.title': 'Road Trip', + 'settings.roadtrip.unitSystem': 'Einheitensystem', + 'settings.roadtrip.metric': 'Metrisch', + 'settings.roadtrip.imperial': 'Imperial', + 'settings.roadtrip.vehicle': 'Fahrzeug', + 'settings.roadtrip.fuelPricePerLitre': 'Kraftstoffpreis (pro Liter)', + 'settings.roadtrip.fuelPricePerGallon': 'Kraftstoffpreis (pro Gallone)', + 'settings.roadtrip.fuelCurrency': 'Kraftstoffwährung', + 'settings.roadtrip.fuelConsumptionMetric': 'Kraftstoffverbrauch (L/100km)', + 'settings.roadtrip.fuelConsumptionImperial': 'Kraftstoffverbrauch (MPG)', + 'settings.roadtrip.tankSizeLitres': 'Tankgröße (Liter)', + 'settings.roadtrip.tankSizeGallons': 'Tankgröße (Gallonen)', + 'settings.roadtrip.drivingPreferences': 'Fahrpräferenzen', + 'settings.roadtrip.maxDrivingHours': 'Max. Fahrstunden', + 'settings.roadtrip.restInterval': 'Pausenintervall (Stunden)', + 'settings.roadtrip.restDuration': 'Pausendauer (Minuten)', + 'settings.roadtrip.maxSpeed': 'Höchstgeschwindigkeit', + 'settings.roadtrip.maxSpeedHelp': 'Begrenzt die geschätzte Fahrzeit auf Straßen mit höheren Geschwindigkeitsbegrenzungen. Leer lassen, um die ausgeschilderten Limits zu verwenden.', + 'settings.roadtrip.routePreferences': 'Routenpräferenzen', + 'settings.roadtrip.daylightOnly': 'Nur bei Tageslicht fahren', + 'settings.roadtrip.roadPreference': 'Straßenpräferenz', + 'settings.roadtrip.anyRoad': 'Alle Straßen', + 'settings.roadtrip.sealedOnly': 'Nur asphaltiert', + 'settings.roadtrip.fuelPreferences': 'Kraftstoff-Präferenzen', + 'settings.roadtrip.fuelType': 'Kraftstoffart', + 'settings.roadtrip.fuelBrand': 'Bevorzugte Marke', + 'settings.roadtrip.fuelTypeAny': 'Beliebig', + 'settings.roadtrip.fuelTypeDiesel': 'Diesel', + 'settings.roadtrip.fuelTypePetrol': 'Benzin', + 'settings.roadtrip.brandAny': 'Beliebige Marke', + 'settings.roadtrip.brandPreferred': 'Bevorzugte Marke — andere werden bei Bedarf angezeigt', + 'settings.roadtrip.avoidFerries': 'Fähren vermeiden', + 'settings.roadtrip.preferHighways': 'Autobahnen bevorzugen', + 'settings.roadtrip.truckSafe': 'LKW / Wohnmobil geeignet', + 'roadtrip.legActive': 'Road-Trip-Route aktiv', + 'roadtrip.markLeg': 'Als Road-Trip-Etappe markieren', + 'roadtrip.toggleAll': 'Alle Road-Trip-Etappen umschalten', + 'roadtrip.summary': 'Road-Trip-Zusammenfassung', + 'roadtrip.totalDistance': 'Gesamtstrecke', + 'roadtrip.totalTime': 'Fahrzeit', + 'roadtrip.totalFuel': 'Kraftstoffkosten', + 'roadtrip.day': 'Tag', + 'roadtrip.distance': 'Strecke', + 'roadtrip.driveTime': 'Fahrzeit', + 'roadtrip.fuelCost': 'Kraftstoff', + 'roadtrip.recalculateFuel': 'Kraftstoffkosten neu berechnen', + 'roadtrip.recalculate': 'Neu berechnen', + 'roadtrip.speedAdjusted': 'Fahrzeiten angepasst für {speed} Höchstgeschwindigkeit', + 'roadtrip.rangeWarning': 'Diese Etappe ({distance}) überschreitet die Fahrzeugreichweite ({range}). Tanken wird nötig sein.', + 'roadtrip.fuelStopsEstimate': 'Geschätzte Tankstopps: {count}', + 'roadtrip.exceedsRange': 'Überschreitet Fahrzeugreichweite', + 'roadtrip.refuelPoint': 'Ungefährer Tankpunkt', + 'roadtrip.drivingTimeWarning': 'Fahrzeit ({actual}) überschreitet das {limit}-Tageslimit', + 'roadtrip.restBreaks': '{count} Pausen (+{minutes} min)', + 'roadtrip.restStop': 'Empfohlene Rast', + 'roadtrip.totalWithBreaks': 'Gesamt mit Pausen', + 'roadtrip.daylightWindow': '{sunrise} – {sunset} ({hours}h)', + 'roadtrip.daylightWarning': 'Fahrzeit ({driving}) überschreitet Tageslicht ({daylight})', + 'roadtrip.daylightMode': 'Tageslicht-Fahrmodus aktiv', + 'roadtrip.truckSafeHelp': 'Erfordert einen selbst gehosteten Routingserver mit LKW-Profil. Der Standardserver nutzt PKW-Routing.', + 'roadtrip.sealedOnlyHelp': 'Erfordert ein benutzerdefiniertes Routingprofil. Der Standardserver kann unbefestigte Straßen einbeziehen.', + 'roadtrip.exceedsDaylight': 'Überschreitet verfügbare Tageslichtstunden', + 'roadtrip.estimatedRange': 'Geschätzte Reichweite: {range}', + 'roadtrip.driving': 'Fahrstrecke', + 'roadtrip.pdfDrivingSummary': 'Fahrtübersicht', + 'roadtrip.pdfTripTotal': 'Gesamtübersicht', + 'roadtrip.toggleAllTrip': 'Alle Road-Trip-Etappen umschalten', + 'roadtrip.calculatingRoutes': 'Routen werden berechnet…', + 'roadtrip.showFullRoute': 'Gesamte Reiseroute anzeigen', + 'roadtrip.directions': 'Wegbeschreibung', + 'roadtrip.directionsOmitted': 'Wegbeschreibung für Etappen über 800 km ausgelassen', + 'roadtrip.directionCount': '{count} Anweisungen', + 'roadtrip.arrive': 'Am Ziel ankommen', + 'roadtrip.depart': 'Abfahrt', + 'roadtrip.findStops': 'Stopps finden', + 'roadtrip.stopsFound': '{count} Stopps gefunden', + 'roadtrip.stopSource': 'Stopp-Datenquelle', + 'roadtrip.stopSourceOsm': 'OpenStreetMap (kostenlos)', + 'roadtrip.stopSourceGoogle': 'Google Places (mehr Daten)', + 'roadtrip.stopSourceHelp': 'Quelle für Tankstellen- und Rastplatz-Suche entlang der Route.', + 'roadtrip.stopSourceGoogleDisabled': 'Google Places erfordert einen Google Maps API-Schlüssel, der vom Administrator konfiguriert wurde.', + 'roadtrip.realFuelStop': 'Tankstelle', + 'roadtrip.realRestStop': 'Rastplatz', + 'roadtrip.openInMaps': 'In Google Maps öffnen', + 'roadtrip.approximateStop': 'Ungefährer Stopp', + 'roadtrip.refreshStops': 'Aktualisieren', + 'roadtrip.findingStops': 'Stopps werden gesucht…', + 'roadtrip.recommendedDeparture': 'Abfahrt nach', + 'roadtrip.arrivalBefore': 'Ankunft vor', + 'roadtrip.drivingWindow': 'Fahrzeitfenster', + 'roadtrip.departBy': 'Abfahrt um {time} · Ankunft um {arrival}', + 'roadtrip.insufficientDaylight': 'Nicht genug Tageslicht für diese Fahrt. Erwägen Sie, die Strecke auf zwei Tage aufzuteilen.', + 'roadtrip.setCheckout': 'Check-out festlegen', + 'roadtrip.setCheckin': 'Check-in festlegen', + 'roadtrip.checkoutUpdated': 'Check-out-Zeit aktualisiert', + 'roadtrip.checkinUpdated': 'Check-in-Zeit aktualisiert', + 'roadtrip.daylightOk': 'Tageslichtfahrt OK', + 'roadtrip.bookingsSafe': 'Ihr Check-out ({checkout}) und Check-in ({checkin}) liegen im sicheren Tageslichtzeitfenster', + 'roadtrip.checkoutTooEarly': 'Check-out ({time}) ist vor der sicheren Abfahrt ({safe})', + 'roadtrip.checkinTooLate': 'Check-in ({time}) ist nach der sicheren Ankunft ({safe})', + 'roadtrip.criticalFuelStop': 'Muss tanken', + 'roadtrip.optionalFuelStop': 'Verfügbar', + 'roadtrip.mustRefuel': 'TANKEN', + 'roadtrip.fuelAvailable': 'Verfügbar', + 'roadtrip.tripFuelPrice': 'Kraftstoffpreis', + 'roadtrip.tripFuelPriceHelp': 'Kraftstoffpreis für diese Reise', + 'roadtrip.tripOverride': 'Reisepreis', + 'roadtrip.noPreferredBrand': 'Keine Tankstellen der bevorzugten Marke gefunden', + 'settings.roadtrip.stopSource': 'Stopp-Suche', 'settings.notifications': 'Benachrichtigungen', 'settings.notifyTripInvite': 'Trip-Einladungen', 'settings.notifyBookingChange': 'Buchungsänderungen', @@ -697,6 +806,16 @@ const de: Record = { 'atlas.tripPlural': 'Reisen', 'atlas.placeVisited': 'Ort besucht', 'atlas.placesVisited': 'Orte besucht', + 'atlas.roadTrips': 'Roadtrips', + 'atlas.totalDriven': 'Gesamtstrecke', + 'atlas.totalRoadTrips': 'Roadtrips', + 'atlas.totalDrivingTime': 'Fahrzeit', + 'atlas.totalFuelSpent': 'Kraftstoffkosten', + 'atlas.longestTrip': 'Längste Reise', + 'atlas.longestDay': 'Längster Tag', + 'atlas.tripLegend': 'Reisen', + 'atlas.viewTrip': 'Reise ansehen', + 'atlas.noRoadTrips': 'Noch keine Roadtrip-Daten. Markieren Sie Etappen als Roadtrips in Ihren Reiseplänen, um sie hier zu sehen.', // Trip Planner 'trip.tabs.plan': 'Karte', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index ea8ebe36..1d4e16ac 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -140,6 +140,115 @@ const en: Record = { 'settings.timeFormat': 'Time Format', 'settings.routeCalculation': 'Route Calculation', 'settings.blurBookingCodes': 'Blur Booking Codes', + 'settings.roadtrip.title': 'Road Trip', + 'settings.roadtrip.unitSystem': 'Unit System', + 'settings.roadtrip.metric': 'Metric', + 'settings.roadtrip.imperial': 'Imperial', + 'settings.roadtrip.vehicle': 'Vehicle', + 'settings.roadtrip.fuelPricePerLitre': 'Fuel price (per litre)', + 'settings.roadtrip.fuelPricePerGallon': 'Fuel price (per gallon)', + 'settings.roadtrip.fuelCurrency': 'Fuel currency', + 'settings.roadtrip.fuelConsumptionMetric': 'Fuel consumption (L/100km)', + 'settings.roadtrip.fuelConsumptionImperial': 'Fuel economy (MPG)', + 'settings.roadtrip.tankSizeLitres': 'Fuel tank size (litres)', + 'settings.roadtrip.tankSizeGallons': 'Fuel tank size (gallons)', + 'settings.roadtrip.drivingPreferences': 'Driving Preferences', + 'settings.roadtrip.maxDrivingHours': 'Max driving hours', + 'settings.roadtrip.restInterval': 'Rest interval (hours)', + 'settings.roadtrip.restDuration': 'Rest duration (minutes)', + 'settings.roadtrip.maxSpeed': 'Maximum speed', + 'settings.roadtrip.maxSpeedHelp': 'Limits estimated drive times on roads with higher speed limits. Leave empty to use posted speed limits.', + 'settings.roadtrip.routePreferences': 'Route Preferences', + 'settings.roadtrip.daylightOnly': 'Daylight driving only', + 'settings.roadtrip.roadPreference': 'Road preference', + 'settings.roadtrip.anyRoad': 'Any road', + 'settings.roadtrip.sealedOnly': 'Sealed only', + 'settings.roadtrip.fuelPreferences': 'Fuel Preferences', + 'settings.roadtrip.fuelType': 'Fuel type', + 'settings.roadtrip.fuelBrand': 'Brand preference', + 'settings.roadtrip.fuelTypeAny': 'Any fuel', + 'settings.roadtrip.fuelTypeDiesel': 'Diesel', + 'settings.roadtrip.fuelTypePetrol': 'Petrol', + 'settings.roadtrip.brandAny': 'Any brand', + 'settings.roadtrip.brandPreferred': 'Preferred brand — others still shown if needed', + 'settings.roadtrip.avoidFerries': 'Avoid ferries', + 'settings.roadtrip.preferHighways': 'Prefer highways', + 'settings.roadtrip.truckSafe': 'Truck / RV safe', + 'roadtrip.legActive': 'Road trip route active', + 'roadtrip.markLeg': 'Mark as road trip leg', + 'roadtrip.toggleAll': 'Toggle all road trip legs', + 'roadtrip.summary': 'Road Trip Summary', + 'roadtrip.totalDistance': 'Total distance', + 'roadtrip.totalTime': 'Drive time', + 'roadtrip.totalFuel': 'Fuel cost', + 'roadtrip.day': 'Day', + 'roadtrip.distance': 'Distance', + 'roadtrip.driveTime': 'Drive time', + 'roadtrip.fuelCost': 'Fuel', + 'roadtrip.recalculateFuel': 'Recalculate fuel costs', + 'roadtrip.recalculate': 'Recalculate', + 'roadtrip.speedAdjusted': 'Drive times adjusted for {speed} max speed', + 'roadtrip.rangeWarning': 'This leg ({distance}) exceeds your vehicle range ({range}). Refuelling will be needed.', + 'roadtrip.fuelStopsEstimate': 'Estimated fuel stops: {count}', + 'roadtrip.exceedsRange': 'Exceeds vehicle range', + 'roadtrip.refuelPoint': 'Approximate refuel point', + 'roadtrip.drivingTimeWarning': 'Driving time ({actual}) exceeds your {limit} daily limit', + 'roadtrip.restBreaks': '{count} rest breaks (+{minutes} min)', + 'roadtrip.restStop': 'Recommended rest stop', + 'roadtrip.totalWithBreaks': 'Total with breaks', + 'roadtrip.daylightWindow': '{sunrise} – {sunset} ({hours}h)', + 'roadtrip.daylightWarning': 'Driving ({driving}) exceeds daylight ({daylight})', + 'roadtrip.daylightMode': 'Daylight driving mode active', + 'roadtrip.truckSafeHelp': 'Requires a self-hosted routing server with truck profile. Default server uses standard car routing.', + 'roadtrip.sealedOnlyHelp': 'Requires a custom routing profile. Default server may include unsealed roads.', + 'roadtrip.exceedsDaylight': 'Exceeds available daylight hours', + 'roadtrip.estimatedRange': 'Estimated range: {range}', + 'roadtrip.driving': 'Driving', + 'roadtrip.pdfDrivingSummary': 'Driving Summary', + 'roadtrip.pdfTripTotal': 'Trip Total', + 'roadtrip.toggleAllTrip': 'Toggle all road trip legs', + 'roadtrip.calculatingRoutes': 'Calculating routes…', + 'roadtrip.showFullRoute': 'Show full trip route', + 'roadtrip.directions': 'Directions', + 'roadtrip.directionsOmitted': 'Directions omitted for legs over 800 km', + 'roadtrip.directionCount': '{count} directions', + 'roadtrip.arrive': 'Arrive at destination', + 'roadtrip.depart': 'Depart', + 'roadtrip.findStops': 'Find stops', + 'roadtrip.stopsFound': '{count} stops found', + 'roadtrip.stopSource': 'Stop data source', + 'roadtrip.stopSourceOsm': 'OpenStreetMap (free)', + 'roadtrip.stopSourceGoogle': 'Google Places (richer data)', + 'roadtrip.stopSourceHelp': 'Source for fuel station and rest area lookups along routes.', + 'roadtrip.stopSourceGoogleDisabled': 'Google Places requires a Google Maps API key configured by the administrator.', + 'roadtrip.realFuelStop': 'fuel', + 'roadtrip.realRestStop': 'rest', + 'roadtrip.openInMaps': 'Open in Google Maps', + 'roadtrip.approximateStop': 'Approximate stop', + 'roadtrip.refreshStops': 'Refresh', + 'roadtrip.findingStops': 'Finding stops…', + 'roadtrip.recommendedDeparture': 'Depart after', + 'roadtrip.arrivalBefore': 'Arrive before', + 'roadtrip.drivingWindow': 'Driving window', + 'roadtrip.departBy': 'Depart by {time} · Arrive by {arrival}', + 'roadtrip.insufficientDaylight': 'Not enough daylight for this drive. Consider splitting across two days.', + 'roadtrip.setCheckout': 'Set check-out', + 'roadtrip.setCheckin': 'Set check-in', + 'roadtrip.checkoutUpdated': 'Check-out time updated', + 'roadtrip.checkinUpdated': 'Check-in time updated', + 'roadtrip.daylightOk': 'Daylight driving OK', + 'roadtrip.bookingsSafe': 'Your check-out ({checkout}) and check-in ({checkin}) are within the safe daylight window', + 'roadtrip.checkoutTooEarly': 'Check-out ({time}) is before safe departure ({safe})', + 'roadtrip.checkinTooLate': 'Check-in ({time}) is after safe arrival ({safe})', + 'roadtrip.criticalFuelStop': 'Must refuel', + 'roadtrip.optionalFuelStop': 'Available', + 'roadtrip.mustRefuel': 'REFUEL', + 'roadtrip.fuelAvailable': 'Available', + 'roadtrip.tripFuelPrice': 'Fuel price', + 'roadtrip.tripFuelPriceHelp': 'Set a fuel price specific to this trip', + 'roadtrip.tripOverride': 'trip override', + 'roadtrip.noPreferredBrand': 'No preferred brand stations found, showing all', + 'settings.roadtrip.stopSource': 'Stop Search', 'settings.notifications': 'Notifications', 'settings.notifyTripInvite': 'Trip invitations', 'settings.notifyBookingChange': 'Booking changes', @@ -694,6 +803,16 @@ const en: Record = { 'atlas.tripPlural': 'Trips', 'atlas.placeVisited': 'Place visited', 'atlas.placesVisited': 'Places visited', + 'atlas.roadTrips': 'Road Trips', + 'atlas.totalDriven': 'Total driven', + 'atlas.totalRoadTrips': 'Road trips', + 'atlas.totalDrivingTime': 'Drive time', + 'atlas.totalFuelSpent': 'Fuel spent', + 'atlas.longestTrip': 'Longest trip', + 'atlas.longestDay': 'Longest day', + 'atlas.tripLegend': 'Trips', + 'atlas.viewTrip': 'View trip', + 'atlas.noRoadTrips': 'No road trip data yet. Mark legs as road trips in your trip plans to see them here.', // Trip Planner 'trip.tabs.plan': 'Plan', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index f1b0e1ef..25326073 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -141,6 +141,115 @@ const es: Record = { 'settings.timeFormat': 'Formato de hora', 'settings.routeCalculation': 'Cálculo de ruta', 'settings.blurBookingCodes': 'Difuminar códigos de reserva', + 'settings.roadtrip.title': 'Viaje por carretera', + 'settings.roadtrip.unitSystem': 'Sistema de unidades', + 'settings.roadtrip.metric': 'Métrico', + 'settings.roadtrip.imperial': 'Imperial', + 'settings.roadtrip.vehicle': 'Vehículo', + 'settings.roadtrip.fuelPricePerLitre': 'Precio del combustible (por litro)', + 'settings.roadtrip.fuelPricePerGallon': 'Precio del combustible (por galón)', + 'settings.roadtrip.fuelCurrency': 'Moneda del combustible', + 'settings.roadtrip.fuelConsumptionMetric': 'Consumo de combustible (L/100km)', + 'settings.roadtrip.fuelConsumptionImperial': 'Rendimiento de combustible (MPG)', + 'settings.roadtrip.tankSizeLitres': 'Tamaño del tanque (litros)', + 'settings.roadtrip.tankSizeGallons': 'Tamaño del tanque (galones)', + 'settings.roadtrip.drivingPreferences': 'Preferencias de conducción', + 'settings.roadtrip.maxDrivingHours': 'Horas máximas de conducción', + 'settings.roadtrip.restInterval': 'Intervalo de descanso (horas)', + 'settings.roadtrip.restDuration': 'Duración del descanso (minutos)', + 'settings.roadtrip.maxSpeed': 'Velocidad máxima', + 'settings.roadtrip.maxSpeedHelp': 'Limita los tiempos de conducción estimados en carreteras con límites de velocidad más altos. Dejar vacío para usar los límites publicados.', + 'settings.roadtrip.routePreferences': 'Preferencias de ruta', + 'settings.roadtrip.daylightOnly': 'Conducir solo de día', + 'settings.roadtrip.roadPreference': 'Preferencia de camino', + 'settings.roadtrip.anyRoad': 'Cualquier camino', + 'settings.roadtrip.sealedOnly': 'Solo pavimentado', + 'settings.roadtrip.fuelPreferences': 'Preferencias de combustible', + 'settings.roadtrip.fuelType': 'Tipo de combustible', + 'settings.roadtrip.fuelBrand': 'Marca preferida', + 'settings.roadtrip.fuelTypeAny': 'Cualquier combustible', + 'settings.roadtrip.fuelTypeDiesel': 'Diésel', + 'settings.roadtrip.fuelTypePetrol': 'Gasolina', + 'settings.roadtrip.brandAny': 'Cualquier marca', + 'settings.roadtrip.brandPreferred': 'Marca preferida — otras se muestran si es necesario', + 'settings.roadtrip.avoidFerries': 'Evitar ferris', + 'settings.roadtrip.preferHighways': 'Preferir autopistas', + 'settings.roadtrip.truckSafe': 'Apto para camiones / autocaravanas', + 'roadtrip.legActive': 'Ruta de viaje activa', + 'roadtrip.markLeg': 'Marcar como tramo del viaje', + 'roadtrip.toggleAll': 'Alternar todos los tramos del viaje', + 'roadtrip.summary': 'Resumen del viaje por carretera', + 'roadtrip.totalDistance': 'Distancia total', + 'roadtrip.totalTime': 'Tiempo de conducción', + 'roadtrip.totalFuel': 'Coste de combustible', + 'roadtrip.day': 'Día', + 'roadtrip.distance': 'Distancia', + 'roadtrip.driveTime': 'Tiempo de conducción', + 'roadtrip.fuelCost': 'Combustible', + 'roadtrip.recalculateFuel': 'Recalcular costes de combustible', + 'roadtrip.recalculate': 'Recalcular', + 'roadtrip.speedAdjusted': 'Tiempos de conducción ajustados para velocidad máxima de {speed}', + 'roadtrip.rangeWarning': 'Este tramo ({distance}) supera la autonomía de tu vehículo ({range}). Será necesario repostar.', + 'roadtrip.fuelStopsEstimate': 'Paradas de repostaje estimadas: {count}', + 'roadtrip.exceedsRange': 'Supera la autonomía del vehículo', + 'roadtrip.refuelPoint': 'Punto de repostaje aproximado', + 'roadtrip.drivingTimeWarning': 'El tiempo de conducción ({actual}) supera tu límite diario de {limit}', + 'roadtrip.restBreaks': '{count} paradas de descanso (+{minutes} min)', + 'roadtrip.restStop': 'Parada de descanso recomendada', + 'roadtrip.totalWithBreaks': 'Total con descansos', + 'roadtrip.daylightWindow': '{sunrise} – {sunset} ({hours}h)', + 'roadtrip.daylightWarning': 'La conducción ({driving}) supera las horas de luz ({daylight})', + 'roadtrip.daylightMode': 'Modo de conducción diurna activo', + 'roadtrip.truckSafeHelp': 'Requiere un servidor de rutas propio con perfil para camiones. El servidor predeterminado usa rutas estándar para coches.', + 'roadtrip.sealedOnlyHelp': 'Requiere un perfil de rutas personalizado. El servidor predeterminado puede incluir caminos sin pavimentar.', + 'roadtrip.exceedsDaylight': 'Supera las horas de luz disponibles', + 'roadtrip.estimatedRange': 'Autonomía estimada: {range}', + 'roadtrip.driving': 'Conducción', + 'roadtrip.pdfDrivingSummary': 'Resumen de conducción', + 'roadtrip.pdfTripTotal': 'Total del viaje', + 'roadtrip.toggleAllTrip': 'Alternar todos los tramos del viaje', + 'roadtrip.calculatingRoutes': 'Calculando rutas…', + 'roadtrip.showFullRoute': 'Mostrar ruta completa del viaje', + 'roadtrip.directions': 'Indicaciones', + 'roadtrip.directionsOmitted': 'Indicaciones omitidas para tramos de más de 800 km', + 'roadtrip.directionCount': '{count} indicaciones', + 'roadtrip.arrive': 'Llegar al destino', + 'roadtrip.depart': 'Salida', + 'roadtrip.findStops': 'Buscar paradas', + 'roadtrip.stopsFound': '{count} paradas encontradas', + 'roadtrip.stopSource': 'Fuente de datos de paradas', + 'roadtrip.stopSourceOsm': 'OpenStreetMap (gratis)', + 'roadtrip.stopSourceGoogle': 'Google Places (más datos)', + 'roadtrip.stopSourceHelp': 'Fuente para búsqueda de gasolineras y áreas de descanso a lo largo de la ruta.', + 'roadtrip.stopSourceGoogleDisabled': 'Google Places requiere una clave API de Google Maps configurada por el administrador.', + 'roadtrip.realFuelStop': 'gasolinera', + 'roadtrip.realRestStop': 'descanso', + 'roadtrip.openInMaps': 'Abrir en Google Maps', + 'roadtrip.approximateStop': 'Parada aproximada', + 'roadtrip.refreshStops': 'Actualizar', + 'roadtrip.findingStops': 'Buscando paradas…', + 'roadtrip.recommendedDeparture': 'Salir después de', + 'roadtrip.arrivalBefore': 'Llegar antes de', + 'roadtrip.drivingWindow': 'Ventana de conducción', + 'roadtrip.departBy': 'Salir a las {time} · Llegar a las {arrival}', + 'roadtrip.insufficientDaylight': 'No hay suficiente luz del día para este viaje. Considera dividirlo en dos días.', + 'roadtrip.setCheckout': 'Establecer check-out', + 'roadtrip.setCheckin': 'Establecer check-in', + 'roadtrip.checkoutUpdated': 'Hora de check-out actualizada', + 'roadtrip.checkinUpdated': 'Hora de check-in actualizada', + 'roadtrip.daylightOk': 'Conducción diurna OK', + 'roadtrip.bookingsSafe': 'Tu check-out ({checkout}) y check-in ({checkin}) están dentro de la ventana segura de luz', + 'roadtrip.checkoutTooEarly': 'Check-out ({time}) es antes de la salida segura ({safe})', + 'roadtrip.checkinTooLate': 'Check-in ({time}) es después de la llegada segura ({safe})', + 'roadtrip.criticalFuelStop': 'Debe repostar', + 'roadtrip.optionalFuelStop': 'Disponible', + 'roadtrip.mustRefuel': 'REPOSTAR', + 'roadtrip.fuelAvailable': 'Disponible', + 'roadtrip.tripFuelPrice': 'Precio del combustible', + 'roadtrip.tripFuelPriceHelp': 'Precio de combustible específico para este viaje', + 'roadtrip.tripOverride': 'precio del viaje', + 'roadtrip.noPreferredBrand': 'No se encontraron estaciones de la marca preferida', + 'settings.roadtrip.stopSource': 'Búsqueda de paradas', 'settings.notifications': 'Notificaciones', 'settings.notifyTripInvite': 'Invitaciones de viaje', 'settings.notifyBookingChange': 'Cambios en reservas', @@ -654,6 +763,16 @@ const es: Record = { 'atlas.tripPlural': 'Viajes', 'atlas.placeVisited': 'Lugar visitado', 'atlas.placesVisited': 'Lugares visitados', + 'atlas.roadTrips': 'Viajes por carretera', + 'atlas.totalDriven': 'Total conducido', + 'atlas.totalRoadTrips': 'Viajes', + 'atlas.totalDrivingTime': 'Tiempo de conducción', + 'atlas.totalFuelSpent': 'Gasto en combustible', + 'atlas.longestTrip': 'Viaje más largo', + 'atlas.longestDay': 'Día más largo', + 'atlas.tripLegend': 'Viajes', + 'atlas.viewTrip': 'Ver viaje', + 'atlas.noRoadTrips': 'Aún no hay datos de viajes por carretera. Marca etapas como viajes por carretera en tus planes para verlos aquí.', 'atlas.statsTab': 'Estadísticas', 'atlas.bucketTab': 'Lista de deseos', 'atlas.addBucket': 'Añadir a lista de deseos', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 6ff77930..ab72b27b 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -140,6 +140,115 @@ const fr: Record = { 'settings.timeFormat': 'Format de l\'heure', 'settings.routeCalculation': 'Calcul d\'itinéraire', 'settings.blurBookingCodes': 'Masquer les codes de réservation', + 'settings.roadtrip.title': 'Road Trip', + 'settings.roadtrip.unitSystem': 'Système d\'unités', + 'settings.roadtrip.metric': 'Métrique', + 'settings.roadtrip.imperial': 'Impérial', + 'settings.roadtrip.vehicle': 'Véhicule', + 'settings.roadtrip.fuelPricePerLitre': 'Prix du carburant (par litre)', + 'settings.roadtrip.fuelPricePerGallon': 'Prix du carburant (par gallon)', + 'settings.roadtrip.fuelCurrency': 'Devise du carburant', + 'settings.roadtrip.fuelConsumptionMetric': 'Consommation de carburant (L/100km)', + 'settings.roadtrip.fuelConsumptionImperial': 'Rendement énergétique (MPG)', + 'settings.roadtrip.tankSizeLitres': 'Taille du réservoir (litres)', + 'settings.roadtrip.tankSizeGallons': 'Taille du réservoir (gallons)', + 'settings.roadtrip.drivingPreferences': 'Préférences de conduite', + 'settings.roadtrip.maxDrivingHours': 'Heures de conduite max.', + 'settings.roadtrip.restInterval': 'Intervalle de repos (heures)', + 'settings.roadtrip.restDuration': 'Durée du repos (minutes)', + 'settings.roadtrip.maxSpeed': 'Vitesse maximale', + 'settings.roadtrip.maxSpeedHelp': 'Limite les temps de conduite estimés sur les routes avec des limites de vitesse plus élevées. Laisser vide pour utiliser les limites affichées.', + 'settings.roadtrip.routePreferences': 'Préférences d\'itinéraire', + 'settings.roadtrip.daylightOnly': 'Conduite de jour uniquement', + 'settings.roadtrip.roadPreference': 'Préférence de route', + 'settings.roadtrip.anyRoad': 'Toute route', + 'settings.roadtrip.sealedOnly': 'Routes goudronnées uniquement', + 'settings.roadtrip.fuelPreferences': 'Préférences carburant', + 'settings.roadtrip.fuelType': 'Type de carburant', + 'settings.roadtrip.fuelBrand': 'Marque préférée', + 'settings.roadtrip.fuelTypeAny': 'Tout carburant', + 'settings.roadtrip.fuelTypeDiesel': 'Diesel', + 'settings.roadtrip.fuelTypePetrol': 'Essence', + 'settings.roadtrip.brandAny': 'Toute marque', + 'settings.roadtrip.brandPreferred': 'Marque préférée — autres affichées si nécessaire', + 'settings.roadtrip.avoidFerries': 'Éviter les ferries', + 'settings.roadtrip.preferHighways': 'Préférer les autoroutes', + 'settings.roadtrip.truckSafe': 'Adapté camions / camping-cars', + 'roadtrip.legActive': 'Itinéraire road trip actif', + 'roadtrip.markLeg': 'Marquer comme étape du road trip', + 'roadtrip.toggleAll': 'Basculer toutes les étapes du road trip', + 'roadtrip.summary': 'Résumé du road trip', + 'roadtrip.totalDistance': 'Distance totale', + 'roadtrip.totalTime': 'Temps de conduite', + 'roadtrip.totalFuel': 'Coût du carburant', + 'roadtrip.day': 'Jour', + 'roadtrip.distance': 'Distance', + 'roadtrip.driveTime': 'Temps de conduite', + 'roadtrip.fuelCost': 'Carburant', + 'roadtrip.recalculateFuel': 'Recalculer les coûts de carburant', + 'roadtrip.recalculate': 'Recalculer', + 'roadtrip.speedAdjusted': 'Temps de conduite ajustés pour une vitesse maximale de {speed}', + 'roadtrip.rangeWarning': 'Cette étape ({distance}) dépasse l\'autonomie de votre véhicule ({range}). Un ravitaillement sera nécessaire.', + 'roadtrip.fuelStopsEstimate': 'Arrêts carburant estimés : {count}', + 'roadtrip.exceedsRange': 'Dépasse l\'autonomie du véhicule', + 'roadtrip.refuelPoint': 'Point de ravitaillement approximatif', + 'roadtrip.drivingTimeWarning': 'Le temps de conduite ({actual}) dépasse votre limite quotidienne de {limit}', + 'roadtrip.restBreaks': '{count} pauses repos (+{minutes} min)', + 'roadtrip.restStop': 'Pause repos recommandée', + 'roadtrip.totalWithBreaks': 'Total avec pauses', + 'roadtrip.daylightWindow': '{sunrise} – {sunset} ({hours}h)', + 'roadtrip.daylightWarning': 'La conduite ({driving}) dépasse les heures de jour ({daylight})', + 'roadtrip.daylightMode': 'Mode conduite de jour actif', + 'roadtrip.truckSafeHelp': 'Nécessite un serveur de routage auto-hébergé avec profil camion. Le serveur par défaut utilise le routage voiture standard.', + 'roadtrip.sealedOnlyHelp': 'Nécessite un profil de routage personnalisé. Le serveur par défaut peut inclure des routes non goudronnées.', + 'roadtrip.exceedsDaylight': 'Dépasse les heures de jour disponibles', + 'roadtrip.estimatedRange': 'Autonomie estimée : {range}', + 'roadtrip.driving': 'Conduite', + 'roadtrip.pdfDrivingSummary': 'Résumé de conduite', + 'roadtrip.pdfTripTotal': 'Total du voyage', + 'roadtrip.toggleAllTrip': 'Basculer toutes les étapes du road trip', + 'roadtrip.calculatingRoutes': 'Calcul des itinéraires…', + 'roadtrip.showFullRoute': 'Afficher l\'itinéraire complet', + 'roadtrip.directions': 'Itinéraire', + 'roadtrip.directionsOmitted': 'Itinéraire omis pour les étapes de plus de 800 km', + 'roadtrip.directionCount': '{count} directions', + 'roadtrip.arrive': 'Arrivée à destination', + 'roadtrip.depart': 'Départ', + 'roadtrip.findStops': 'Chercher des arrêts', + 'roadtrip.stopsFound': '{count} arrêts trouvés', + 'roadtrip.stopSource': 'Source des données d\'arrêts', + 'roadtrip.stopSourceOsm': 'OpenStreetMap (gratuit)', + 'roadtrip.stopSourceGoogle': 'Google Places (données enrichies)', + 'roadtrip.stopSourceHelp': 'Source pour la recherche de stations-service et aires de repos le long de l\'itinéraire.', + 'roadtrip.stopSourceGoogleDisabled': 'Google Places nécessite une clé API Google Maps configurée par l\'administrateur.', + 'roadtrip.realFuelStop': 'station-service', + 'roadtrip.realRestStop': 'aire de repos', + 'roadtrip.openInMaps': 'Ouvrir dans Google Maps', + 'roadtrip.approximateStop': 'Arrêt approximatif', + 'roadtrip.refreshStops': 'Actualiser', + 'roadtrip.findingStops': 'Recherche d\'arrêts…', + 'roadtrip.recommendedDeparture': 'Partir après', + 'roadtrip.arrivalBefore': 'Arriver avant', + 'roadtrip.drivingWindow': 'Créneau de conduite', + 'roadtrip.departBy': 'Partir à {time} · Arriver à {arrival}', + 'roadtrip.insufficientDaylight': 'Pas assez de lumière du jour pour ce trajet. Envisagez de le répartir sur deux jours.', + 'roadtrip.setCheckout': 'Définir le check-out', + 'roadtrip.setCheckin': 'Définir le check-in', + 'roadtrip.checkoutUpdated': 'Heure de check-out mise à jour', + 'roadtrip.checkinUpdated': 'Heure de check-in mise à jour', + 'roadtrip.daylightOk': 'Conduite de jour OK', + 'roadtrip.bookingsSafe': 'Votre check-out ({checkout}) et check-in ({checkin}) sont dans la fenêtre de jour sûre', + 'roadtrip.checkoutTooEarly': 'Check-out ({time}) est avant le départ sûr ({safe})', + 'roadtrip.checkinTooLate': 'Check-in ({time}) est après l\'arrivée sûre ({safe})', + 'roadtrip.criticalFuelStop': 'Ravitaillement obligatoire', + 'roadtrip.optionalFuelStop': 'Disponible', + 'roadtrip.mustRefuel': 'RAVITAILLER', + 'roadtrip.fuelAvailable': 'Disponible', + 'roadtrip.tripFuelPrice': 'Prix du carburant', + 'roadtrip.tripFuelPriceHelp': 'Prix du carburant spécifique à ce voyage', + 'roadtrip.tripOverride': 'prix du voyage', + 'roadtrip.noPreferredBrand': 'Aucune station de la marque préférée trouvée', + 'settings.roadtrip.stopSource': 'Recherche d\'arrêts', 'settings.notifications': 'Notifications', 'settings.notifyTripInvite': 'Invitations de voyage', 'settings.notifyBookingChange': 'Modifications de réservation', @@ -677,6 +786,16 @@ const fr: Record = { 'atlas.tripPlural': 'Voyages', 'atlas.placeVisited': 'Lieu visité', 'atlas.placesVisited': 'Lieux visités', + 'atlas.roadTrips': 'Road trips', + 'atlas.totalDriven': 'Total parcouru', + 'atlas.totalRoadTrips': 'Road trips', + 'atlas.totalDrivingTime': 'Temps de conduite', + 'atlas.totalFuelSpent': 'Dépenses carburant', + 'atlas.longestTrip': 'Plus long voyage', + 'atlas.longestDay': 'Plus longue journée', + 'atlas.tripLegend': 'Voyages', + 'atlas.viewTrip': 'Voir le voyage', + 'atlas.noRoadTrips': 'Pas encore de données de road trip. Marquez des étapes comme road trips dans vos plans pour les voir ici.', 'atlas.statsTab': 'Statistiques', 'atlas.bucketTab': 'Bucket List', 'atlas.addBucket': 'Ajouter à la bucket list', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index ca68de1a..309f6108 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -140,6 +140,115 @@ const hu: Record = { 'settings.timeFormat': 'Időformátum', 'settings.routeCalculation': 'Útvonalszámítás', 'settings.blurBookingCodes': 'Foglalási kódok elrejtése', + 'settings.roadtrip.title': 'Autós túra', + 'settings.roadtrip.unitSystem': 'Mértékegység-rendszer', + 'settings.roadtrip.metric': 'Metrikus', + 'settings.roadtrip.imperial': 'Angolszász', + 'settings.roadtrip.vehicle': 'Jármű', + 'settings.roadtrip.fuelPricePerLitre': 'Üzemanyagár (literenként)', + 'settings.roadtrip.fuelPricePerGallon': 'Üzemanyagár (gallononként)', + 'settings.roadtrip.fuelCurrency': 'Üzemanyag pénzneme', + 'settings.roadtrip.fuelConsumptionMetric': 'Üzemanyag-fogyasztás (L/100km)', + 'settings.roadtrip.fuelConsumptionImperial': 'Üzemanyag-hatékonyság (MPG)', + 'settings.roadtrip.tankSizeLitres': 'Üzemanyagtank mérete (liter)', + 'settings.roadtrip.tankSizeGallons': 'Üzemanyagtank mérete (gallon)', + 'settings.roadtrip.drivingPreferences': 'Vezetési beállítások', + 'settings.roadtrip.maxDrivingHours': 'Maximális vezetési idő', + 'settings.roadtrip.restInterval': 'Pihenő időköze (óra)', + 'settings.roadtrip.restDuration': 'Pihenő hossza (perc)', + 'settings.roadtrip.maxSpeed': 'Maximális sebesség', + 'settings.roadtrip.maxSpeedHelp': 'Korlátozza a becsült menetidőt a magasabb sebességkorlátozású utakon. Hagyja üresen a kiírt sebességhatárok használatához.', + 'settings.roadtrip.routePreferences': 'Útvonal-beállítások', + 'settings.roadtrip.daylightOnly': 'Csak nappali vezetés', + 'settings.roadtrip.roadPreference': 'Útpreferencia', + 'settings.roadtrip.anyRoad': 'Bármilyen út', + 'settings.roadtrip.sealedOnly': 'Csak burkolt út', + 'settings.roadtrip.fuelPreferences': 'Üzemanyag beállítások', + 'settings.roadtrip.fuelType': 'Üzemanyag típus', + 'settings.roadtrip.fuelBrand': 'Kedvelt márka', + 'settings.roadtrip.fuelTypeAny': 'Bármely üzemanyag', + 'settings.roadtrip.fuelTypeDiesel': 'Dízel', + 'settings.roadtrip.fuelTypePetrol': 'Benzin', + 'settings.roadtrip.brandAny': 'Bármely márka', + 'settings.roadtrip.brandPreferred': 'Kedvelt márka — más márkák is megjelennek szükség esetén', + 'settings.roadtrip.avoidFerries': 'Kompok elkerülése', + 'settings.roadtrip.preferHighways': 'Autópályák előnyben', + 'settings.roadtrip.truckSafe': 'Tehergépjármű / lakóautó biztos', + 'roadtrip.legActive': 'Autós túra útvonal aktív', + 'roadtrip.markLeg': 'Megjelölés autós túra szakaszként', + 'roadtrip.toggleAll': 'Összes autós túra szakasz váltása', + 'roadtrip.summary': 'Autós túra összefoglalója', + 'roadtrip.totalDistance': 'Össztávolság', + 'roadtrip.totalTime': 'Vezetési idő', + 'roadtrip.totalFuel': 'Üzemanyagköltség', + 'roadtrip.day': 'Nap', + 'roadtrip.distance': 'Távolság', + 'roadtrip.driveTime': 'Vezetési idő', + 'roadtrip.fuelCost': 'Üzemanyag', + 'roadtrip.recalculateFuel': 'Üzemanyagköltségek újraszámítása', + 'roadtrip.recalculate': 'Újraszámítás', + 'roadtrip.speedAdjusted': 'Menetidők {speed} maximális sebességhez igazítva', + 'roadtrip.rangeWarning': 'Ez a szakasz ({distance}) meghaladja a jármű hatótávolságát ({range}). Tankolás szükséges.', + 'roadtrip.fuelStopsEstimate': 'Becsült tankolási megállók: {count}', + 'roadtrip.exceedsRange': 'Meghaladja a jármű hatótávolságát', + 'roadtrip.refuelPoint': 'Hozzávetőleges tankolási pont', + 'roadtrip.drivingTimeWarning': 'A vezetési idő ({actual}) meghaladja a napi {limit} korlátot', + 'roadtrip.restBreaks': '{count} pihenő (+{minutes} perc)', + 'roadtrip.restStop': 'Javasolt pihenő', + 'roadtrip.totalWithBreaks': 'Összesen pihenőkkel', + 'roadtrip.daylightWindow': '{sunrise} – {sunset} ({hours} óra)', + 'roadtrip.daylightWarning': 'A vezetési idő ({driving}) meghaladja a nappali órákat ({daylight})', + 'roadtrip.daylightMode': 'Nappali vezetési mód aktív', + 'roadtrip.truckSafeHelp': 'Saját üzemeltetésű útválasztó szerver szükséges tehergépjármű profillal. Az alapértelmezett szerver hagyományos személyautó útvonalat használ.', + 'roadtrip.sealedOnlyHelp': 'Egyéni útvonalprofil szükséges. Az alapértelmezett szerver tartalmazhat burkolatlan utakat.', + 'roadtrip.exceedsDaylight': 'Meghaladja a rendelkezésre álló nappali órákat', + 'roadtrip.estimatedRange': 'Becsült hatótávolság: {range}', + 'roadtrip.driving': 'Vezetés', + 'roadtrip.pdfDrivingSummary': 'Vezetési összefoglaló', + 'roadtrip.pdfTripTotal': 'Túra összesen', + 'roadtrip.toggleAllTrip': 'Összes autós túra szakasz váltása', + 'roadtrip.calculatingRoutes': 'Útvonalak számítása…', + 'roadtrip.showFullRoute': 'Teljes útvonal megjelenítése', + 'roadtrip.directions': 'Útvonalterv', + 'roadtrip.directionsOmitted': 'Útvonalterv kihagyva 800 km feletti szakaszokhoz', + 'roadtrip.directionCount': '{count} utasítás', + 'roadtrip.arrive': 'Megérkezés a célhoz', + 'roadtrip.depart': 'Indulás', + 'roadtrip.findStops': 'Megállók keresése', + 'roadtrip.stopsFound': '{count} megálló találva', + 'roadtrip.stopSource': 'Megálló adatforrás', + 'roadtrip.stopSourceOsm': 'OpenStreetMap (ingyenes)', + 'roadtrip.stopSourceGoogle': 'Google Places (gazdagabb adatok)', + 'roadtrip.stopSourceHelp': 'Forrás benzinkutak és pihenőhelyek kereséséhez az útvonal mentén.', + 'roadtrip.stopSourceGoogleDisabled': 'A Google Places használatához az adminisztrátornak konfigurált Google Maps API kulcs szükséges.', + 'roadtrip.realFuelStop': 'benzinkút', + 'roadtrip.realRestStop': 'pihenő', + 'roadtrip.openInMaps': 'Megnyitás Google Maps-ben', + 'roadtrip.approximateStop': 'Hozzávetőleges megálló', + 'roadtrip.refreshStops': 'Frissítés', + 'roadtrip.findingStops': 'Megállók keresése…', + 'roadtrip.recommendedDeparture': 'Indulás után', + 'roadtrip.arrivalBefore': 'Érkezés előtt', + 'roadtrip.drivingWindow': 'Vezetési időablak', + 'roadtrip.departBy': 'Indulás: {time} · Érkezés: {arrival}', + 'roadtrip.insufficientDaylight': 'Nincs elég nappali fény ehhez az úthoz. Fontolja meg a két napra való felosztást.', + 'roadtrip.setCheckout': 'Kijelentkezés beállítása', + 'roadtrip.setCheckin': 'Bejelentkezés beállítása', + 'roadtrip.checkoutUpdated': 'Kijelentkezési idő frissítve', + 'roadtrip.checkinUpdated': 'Bejelentkezési idő frissítve', + 'roadtrip.daylightOk': 'Nappali vezetés OK', + 'roadtrip.bookingsSafe': 'A check-out ({checkout}) és check-in ({checkin}) a biztonságos nappali időablakon belül van', + 'roadtrip.checkoutTooEarly': 'Check-out ({time}) a biztonságos indulás előtt van ({safe})', + 'roadtrip.checkinTooLate': 'Check-in ({time}) a biztonságos érkezés után van ({safe})', + 'roadtrip.criticalFuelStop': 'Tankolás szükséges', + 'roadtrip.optionalFuelStop': 'Elérhető', + 'roadtrip.mustRefuel': 'TANKOLÁS', + 'roadtrip.fuelAvailable': 'Elérhető', + 'roadtrip.tripFuelPrice': 'Üzemanyag ár', + 'roadtrip.tripFuelPriceHelp': 'Üzemanyag ár ehhez az úthoz', + 'roadtrip.tripOverride': 'egyedi ár', + 'roadtrip.noPreferredBrand': 'Nem található kedvelt márkájú állomás', + 'settings.roadtrip.stopSource': 'Megálló keresés', 'settings.notifications': 'Értesítések', 'settings.notifyTripInvite': 'Utazási meghívók', 'settings.notifyBookingChange': 'Foglalási változások', @@ -695,6 +804,16 @@ const hu: Record = { 'atlas.tripPlural': 'Utazások', 'atlas.placeVisited': 'Meglátogatott hely', 'atlas.placesVisited': 'Meglátogatott helyek', + 'atlas.roadTrips': 'Autós utak', + 'atlas.totalDriven': 'Összes megtett út', + 'atlas.totalRoadTrips': 'Autós utak', + 'atlas.totalDrivingTime': 'Vezetési idő', + 'atlas.totalFuelSpent': 'Üzemanyag költség', + 'atlas.longestTrip': 'Leghosszabb út', + 'atlas.longestDay': 'Leghosszabb nap', + 'atlas.tripLegend': 'Utak', + 'atlas.viewTrip': 'Út megtekintése', + 'atlas.noRoadTrips': 'Még nincsenek autós út adatok. Jelöljön meg szakaszokat autós útként az útitervekben, hogy itt lássa őket.', // Utazástervező 'trip.tabs.plan': 'Terv', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index a7ae949e..1e7ca21d 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -140,6 +140,115 @@ const it: Record = { 'settings.timeFormat': 'Formato Ora', 'settings.routeCalculation': 'Calcolo Percorso', 'settings.blurBookingCodes': 'Nascondi codici di prenotazione', + 'settings.roadtrip.title': 'Viaggio in auto', + 'settings.roadtrip.unitSystem': 'Sistema di unità', + 'settings.roadtrip.metric': 'Metrico', + 'settings.roadtrip.imperial': 'Imperiale', + 'settings.roadtrip.vehicle': 'Veicolo', + 'settings.roadtrip.fuelPricePerLitre': 'Prezzo carburante (al litro)', + 'settings.roadtrip.fuelPricePerGallon': 'Prezzo carburante (al gallone)', + 'settings.roadtrip.fuelCurrency': 'Valuta carburante', + 'settings.roadtrip.fuelConsumptionMetric': 'Consumo carburante (L/100km)', + 'settings.roadtrip.fuelConsumptionImperial': 'Efficienza carburante (MPG)', + 'settings.roadtrip.tankSizeLitres': 'Capacità serbatoio (litri)', + 'settings.roadtrip.tankSizeGallons': 'Capacità serbatoio (galloni)', + 'settings.roadtrip.drivingPreferences': 'Preferenze di guida', + 'settings.roadtrip.maxDrivingHours': 'Ore di guida massime', + 'settings.roadtrip.restInterval': 'Intervallo di riposo (ore)', + 'settings.roadtrip.restDuration': 'Durata del riposo (minuti)', + 'settings.roadtrip.maxSpeed': 'Velocità massima', + 'settings.roadtrip.maxSpeedHelp': 'Limita i tempi di guida stimati sulle strade con limiti di velocità più alti. Lasciare vuoto per utilizzare i limiti pubblicati.', + 'settings.roadtrip.routePreferences': 'Preferenze di percorso', + 'settings.roadtrip.daylightOnly': 'Guida solo diurna', + 'settings.roadtrip.roadPreference': 'Preferenza stradale', + 'settings.roadtrip.anyRoad': 'Qualsiasi strada', + 'settings.roadtrip.sealedOnly': 'Solo asfaltate', + 'settings.roadtrip.fuelPreferences': 'Preferenze carburante', + 'settings.roadtrip.fuelType': 'Tipo di carburante', + 'settings.roadtrip.fuelBrand': 'Marca preferita', + 'settings.roadtrip.fuelTypeAny': 'Qualsiasi carburante', + 'settings.roadtrip.fuelTypeDiesel': 'Diesel', + 'settings.roadtrip.fuelTypePetrol': 'Benzina', + 'settings.roadtrip.brandAny': 'Qualsiasi marca', + 'settings.roadtrip.brandPreferred': 'Marca preferita — altre mostrate se necessario', + 'settings.roadtrip.avoidFerries': 'Evita traghetti', + 'settings.roadtrip.preferHighways': 'Preferisci autostrade', + 'settings.roadtrip.truckSafe': 'Adatto a camion / camper', + 'roadtrip.legActive': 'Percorso viaggio in auto attivo', + 'roadtrip.markLeg': 'Segna come tappa del viaggio', + 'roadtrip.toggleAll': 'Attiva/disattiva tutte le tappe del viaggio', + 'roadtrip.summary': 'Riepilogo viaggio in auto', + 'roadtrip.totalDistance': 'Distanza totale', + 'roadtrip.totalTime': 'Tempo di guida', + 'roadtrip.totalFuel': 'Costo carburante', + 'roadtrip.day': 'Giorno', + 'roadtrip.distance': 'Distanza', + 'roadtrip.driveTime': 'Tempo di guida', + 'roadtrip.fuelCost': 'Carburante', + 'roadtrip.recalculateFuel': 'Ricalcola costi carburante', + 'roadtrip.recalculate': 'Ricalcola', + 'roadtrip.speedAdjusted': 'Tempi di guida adeguati per velocità massima di {speed}', + 'roadtrip.rangeWarning': 'Questa tappa ({distance}) supera l\'autonomia del veicolo ({range}). Sarà necessario fare rifornimento.', + 'roadtrip.fuelStopsEstimate': 'Soste rifornimento stimate: {count}', + 'roadtrip.exceedsRange': 'Supera l\'autonomia del veicolo', + 'roadtrip.refuelPoint': 'Punto di rifornimento approssimativo', + 'roadtrip.drivingTimeWarning': 'Il tempo di guida ({actual}) supera il limite giornaliero di {limit}', + 'roadtrip.restBreaks': '{count} pause di riposo (+{minutes} min)', + 'roadtrip.restStop': 'Sosta di riposo consigliata', + 'roadtrip.totalWithBreaks': 'Totale con pause', + 'roadtrip.daylightWindow': '{sunrise} – {sunset} ({hours}h)', + 'roadtrip.daylightWarning': 'La guida ({driving}) supera le ore di luce ({daylight})', + 'roadtrip.daylightMode': 'Modalità guida diurna attiva', + 'roadtrip.truckSafeHelp': 'Richiede un server di routing self-hosted con profilo camion. Il server predefinito usa il routing auto standard.', + 'roadtrip.sealedOnlyHelp': 'Richiede un profilo di routing personalizzato. Il server predefinito può includere strade sterrate.', + 'roadtrip.exceedsDaylight': 'Supera le ore di luce disponibili', + 'roadtrip.estimatedRange': 'Autonomia stimata: {range}', + 'roadtrip.driving': 'Guida', + 'roadtrip.pdfDrivingSummary': 'Riepilogo di guida', + 'roadtrip.pdfTripTotal': 'Totale viaggio', + 'roadtrip.toggleAllTrip': 'Attiva/disattiva tutte le tappe del viaggio', + 'roadtrip.calculatingRoutes': 'Calcolo percorsi…', + 'roadtrip.showFullRoute': 'Mostra percorso completo del viaggio', + 'roadtrip.directions': 'Indicazioni', + 'roadtrip.directionsOmitted': 'Indicazioni omesse per tratte oltre 800 km', + 'roadtrip.directionCount': '{count} indicazioni', + 'roadtrip.arrive': 'Arrivo a destinazione', + 'roadtrip.depart': 'Partenza', + 'roadtrip.findStops': 'Cerca soste', + 'roadtrip.stopsFound': '{count} soste trovate', + 'roadtrip.stopSource': 'Fonte dati soste', + 'roadtrip.stopSourceOsm': 'OpenStreetMap (gratuito)', + 'roadtrip.stopSourceGoogle': 'Google Places (dati più ricchi)', + 'roadtrip.stopSourceHelp': 'Fonte per la ricerca di stazioni di servizio e aree di sosta lungo il percorso.', + 'roadtrip.stopSourceGoogleDisabled': 'Google Places richiede una chiave API Google Maps configurata dall\'amministratore.', + 'roadtrip.realFuelStop': 'distributore', + 'roadtrip.realRestStop': 'area di sosta', + 'roadtrip.openInMaps': 'Apri in Google Maps', + 'roadtrip.approximateStop': 'Sosta approssimativa', + 'roadtrip.refreshStops': 'Aggiorna', + 'roadtrip.findingStops': 'Ricerca soste in corso…', + 'roadtrip.recommendedDeparture': 'Partenza dopo', + 'roadtrip.arrivalBefore': 'Arrivo entro', + 'roadtrip.drivingWindow': 'Finestra di guida', + 'roadtrip.departBy': 'Partenza alle {time} · Arrivo alle {arrival}', + 'roadtrip.insufficientDaylight': 'Luce diurna insufficiente per questo viaggio. Considera di suddividerlo in due giorni.', + 'roadtrip.setCheckout': 'Imposta check-out', + 'roadtrip.setCheckin': 'Imposta check-in', + 'roadtrip.checkoutUpdated': 'Orario di check-out aggiornato', + 'roadtrip.checkinUpdated': 'Orario di check-in aggiornato', + 'roadtrip.daylightOk': 'Guida diurna OK', + 'roadtrip.bookingsSafe': 'Il check-out ({checkout}) e il check-in ({checkin}) sono nella finestra sicura di luce diurna', + 'roadtrip.checkoutTooEarly': 'Check-out ({time}) è prima della partenza sicura ({safe})', + 'roadtrip.checkinTooLate': 'Check-in ({time}) è dopo l\'arrivo sicuro ({safe})', + 'roadtrip.criticalFuelStop': 'Rifornimento necessario', + 'roadtrip.optionalFuelStop': 'Disponibile', + 'roadtrip.mustRefuel': 'RIFORNIRE', + 'roadtrip.fuelAvailable': 'Disponibile', + 'roadtrip.tripFuelPrice': 'Prezzo carburante', + 'roadtrip.tripFuelPriceHelp': 'Prezzo carburante specifico per questo viaggio', + 'roadtrip.tripOverride': 'prezzo viaggio', + 'roadtrip.noPreferredBrand': 'Nessuna stazione del marchio preferito trovata', + 'settings.roadtrip.stopSource': 'Ricerca soste', 'settings.notifications': 'Notifiche', 'settings.notifyTripInvite': 'Inviti di viaggio', 'settings.notifyBookingChange': 'Modifiche alle prenotazioni', @@ -696,6 +805,16 @@ const it: Record = { 'atlas.tripPlural': 'Viaggi', 'atlas.placeVisited': 'Luogo visitato', 'atlas.placesVisited': 'Luoghi visitati', + 'atlas.roadTrips': 'Viaggi su strada', + 'atlas.totalDriven': 'Totale percorso', + 'atlas.totalRoadTrips': 'Viaggi', + 'atlas.totalDrivingTime': 'Tempo di guida', + 'atlas.totalFuelSpent': 'Spesa carburante', + 'atlas.longestTrip': 'Viaggio più lungo', + 'atlas.longestDay': 'Giornata più lunga', + 'atlas.tripLegend': 'Viaggi', + 'atlas.viewTrip': 'Vedi viaggio', + 'atlas.noRoadTrips': 'Nessun dato sui viaggi su strada. Contrassegna le tappe come viaggi su strada nei tuoi piani per vederli qui.', // Trip Planner 'trip.tabs.plan': 'Programma', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 43a657c8..e4eb26fc 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -140,6 +140,115 @@ const nl: Record = { 'settings.timeFormat': 'Tijdnotatie', 'settings.routeCalculation': 'Routeberekening', 'settings.blurBookingCodes': 'Boekingscodes vervagen', + 'settings.roadtrip.title': 'Roadtrip', + 'settings.roadtrip.unitSystem': 'Eenhedenstelsel', + 'settings.roadtrip.metric': 'Metrisch', + 'settings.roadtrip.imperial': 'Imperiaal', + 'settings.roadtrip.vehicle': 'Voertuig', + 'settings.roadtrip.fuelPricePerLitre': 'Brandstofprijs (per liter)', + 'settings.roadtrip.fuelPricePerGallon': 'Brandstofprijs (per gallon)', + 'settings.roadtrip.fuelCurrency': 'Brandstofvaluta', + 'settings.roadtrip.fuelConsumptionMetric': 'Brandstofverbruik (L/100km)', + 'settings.roadtrip.fuelConsumptionImperial': 'Brandstofverbruik (MPG)', + 'settings.roadtrip.tankSizeLitres': 'Tankinhoud (liter)', + 'settings.roadtrip.tankSizeGallons': 'Tankinhoud (gallon)', + 'settings.roadtrip.drivingPreferences': 'Rijvoorkeuren', + 'settings.roadtrip.maxDrivingHours': 'Max. rijuren', + 'settings.roadtrip.restInterval': 'Rustinterval (uren)', + 'settings.roadtrip.restDuration': 'Rustduur (minuten)', + 'settings.roadtrip.maxSpeed': 'Maximumsnelheid', + 'settings.roadtrip.maxSpeedHelp': 'Beperkt de geschatte rijtijden op wegen met hogere snelheidslimieten. Laat leeg om de aangegeven snelheidslimieten te gebruiken.', + 'settings.roadtrip.routePreferences': 'Routevoorkeuren', + 'settings.roadtrip.daylightOnly': 'Alleen bij daglicht rijden', + 'settings.roadtrip.roadPreference': 'Wegvoorkeur', + 'settings.roadtrip.anyRoad': 'Elke weg', + 'settings.roadtrip.sealedOnly': 'Alleen verharde wegen', + 'settings.roadtrip.fuelPreferences': 'Brandstofvoorkeuren', + 'settings.roadtrip.fuelType': 'Brandstoftype', + 'settings.roadtrip.fuelBrand': 'Voorkeursmerk', + 'settings.roadtrip.fuelTypeAny': 'Elke brandstof', + 'settings.roadtrip.fuelTypeDiesel': 'Diesel', + 'settings.roadtrip.fuelTypePetrol': 'Benzine', + 'settings.roadtrip.brandAny': 'Elk merk', + 'settings.roadtrip.brandPreferred': 'Voorkeursmerk — andere worden getoond indien nodig', + 'settings.roadtrip.avoidFerries': 'Veerboten vermijden', + 'settings.roadtrip.preferHighways': 'Snelwegen voorkeur', + 'settings.roadtrip.truckSafe': 'Geschikt voor vrachtwagen / camper', + 'roadtrip.legActive': 'Roadtrip-route actief', + 'roadtrip.markLeg': 'Markeer als roadtrip-etappe', + 'roadtrip.toggleAll': 'Alle roadtrip-etappes wisselen', + 'roadtrip.summary': 'Roadtrip-overzicht', + 'roadtrip.totalDistance': 'Totale afstand', + 'roadtrip.totalTime': 'Rijtijd', + 'roadtrip.totalFuel': 'Brandstofkosten', + 'roadtrip.day': 'Dag', + 'roadtrip.distance': 'Afstand', + 'roadtrip.driveTime': 'Rijtijd', + 'roadtrip.fuelCost': 'Brandstof', + 'roadtrip.recalculateFuel': 'Brandstofkosten herberekenen', + 'roadtrip.recalculate': 'Herberekenen', + 'roadtrip.speedAdjusted': 'Rijtijden aangepast voor maximaal {speed}', + 'roadtrip.rangeWarning': 'Deze etappe ({distance}) overschrijdt het bereik van je voertuig ({range}). Tanken is noodzakelijk.', + 'roadtrip.fuelStopsEstimate': 'Geschatte tankstops: {count}', + 'roadtrip.exceedsRange': 'Overschrijdt voertuigbereik', + 'roadtrip.refuelPoint': 'Geschat tankpunt', + 'roadtrip.drivingTimeWarning': 'Rijtijd ({actual}) overschrijdt je dagelijkse limiet van {limit}', + 'roadtrip.restBreaks': '{count} rustpauzes (+{minutes} min)', + 'roadtrip.restStop': 'Aanbevolen rustpauze', + 'roadtrip.totalWithBreaks': 'Totaal met pauzes', + 'roadtrip.daylightWindow': '{sunrise} – {sunset} ({hours}u)', + 'roadtrip.daylightWarning': 'Rijtijd ({driving}) overschrijdt daglichturen ({daylight})', + 'roadtrip.daylightMode': 'Daglichtrijmodus actief', + 'roadtrip.truckSafeHelp': 'Vereist een zelf gehoste routeserver met vrachtwagenprofiel. De standaardserver gebruikt gewone autorouting.', + 'roadtrip.sealedOnlyHelp': 'Vereist een aangepast routeprofiel. De standaardserver kan onverharde wegen bevatten.', + 'roadtrip.exceedsDaylight': 'Overschrijdt beschikbare daglichturen', + 'roadtrip.estimatedRange': 'Geschat bereik: {range}', + 'roadtrip.driving': 'Rijden', + 'roadtrip.pdfDrivingSummary': 'Rijoverzicht', + 'roadtrip.pdfTripTotal': 'Totaal reis', + 'roadtrip.toggleAllTrip': 'Alle roadtrip-etappes wisselen', + 'roadtrip.calculatingRoutes': 'Routes berekenen…', + 'roadtrip.showFullRoute': 'Volledige reisroute tonen', + 'roadtrip.directions': 'Routebeschrijving', + 'roadtrip.directionsOmitted': 'Routebeschrijving weggelaten voor etappes langer dan 800 km', + 'roadtrip.directionCount': '{count} aanwijzingen', + 'roadtrip.arrive': 'Aankomst op bestemming', + 'roadtrip.depart': 'Vertrek', + 'roadtrip.findStops': 'Stops zoeken', + 'roadtrip.stopsFound': '{count} stops gevonden', + 'roadtrip.stopSource': 'Stopgegevensbron', + 'roadtrip.stopSourceOsm': 'OpenStreetMap (gratis)', + 'roadtrip.stopSourceGoogle': 'Google Places (rijkere gegevens)', + 'roadtrip.stopSourceHelp': 'Bron voor het zoeken van tankstations en rustplaatsen langs de route.', + 'roadtrip.stopSourceGoogleDisabled': 'Google Places vereist een Google Maps API-sleutel die door de beheerder is geconfigureerd.', + 'roadtrip.realFuelStop': 'tankstation', + 'roadtrip.realRestStop': 'rustplaats', + 'roadtrip.openInMaps': 'Openen in Google Maps', + 'roadtrip.approximateStop': 'Geschatte stop', + 'roadtrip.refreshStops': 'Vernieuwen', + 'roadtrip.findingStops': 'Stops zoeken…', + 'roadtrip.recommendedDeparture': 'Vertrek na', + 'roadtrip.arrivalBefore': 'Aankomst voor', + 'roadtrip.drivingWindow': 'Rijvenster', + 'roadtrip.departBy': 'Vertrek om {time} · Aankomst om {arrival}', + 'roadtrip.insufficientDaylight': 'Niet genoeg daglicht voor deze rit. Overweeg om het over twee dagen te verdelen.', + 'roadtrip.setCheckout': 'Check-out instellen', + 'roadtrip.setCheckin': 'Check-in instellen', + 'roadtrip.checkoutUpdated': 'Check-out tijd bijgewerkt', + 'roadtrip.checkinUpdated': 'Check-in tijd bijgewerkt', + 'roadtrip.daylightOk': 'Daglichttijd OK', + 'roadtrip.bookingsSafe': 'Uw check-out ({checkout}) en check-in ({checkin}) vallen binnen het veilige daglichtvenster', + 'roadtrip.checkoutTooEarly': 'Check-out ({time}) is vóór veilig vertrek ({safe})', + 'roadtrip.checkinTooLate': 'Check-in ({time}) is na veilige aankomst ({safe})', + 'roadtrip.criticalFuelStop': 'Moet tanken', + 'roadtrip.optionalFuelStop': 'Beschikbaar', + 'roadtrip.mustRefuel': 'TANKEN', + 'roadtrip.fuelAvailable': 'Beschikbaar', + 'roadtrip.tripFuelPrice': 'Brandstofprijs', + 'roadtrip.tripFuelPriceHelp': 'Brandstofprijs specifiek voor deze reis', + 'roadtrip.tripOverride': 'reisprijs', + 'roadtrip.noPreferredBrand': 'Geen stations van voorkeursmerk gevonden', + 'settings.roadtrip.stopSource': 'Stop zoeken', 'settings.notifications': 'Meldingen', 'settings.notifyTripInvite': 'Reisuitnodigingen', 'settings.notifyBookingChange': 'Boekingswijzigingen', @@ -677,6 +786,16 @@ const nl: Record = { 'atlas.tripPlural': 'Reizen', 'atlas.placeVisited': 'Bezochte plaats', 'atlas.placesVisited': 'Bezochte plaatsen', + 'atlas.roadTrips': 'Roadtrips', + 'atlas.totalDriven': 'Totaal gereden', + 'atlas.totalRoadTrips': 'Roadtrips', + 'atlas.totalDrivingTime': 'Rijtijd', + 'atlas.totalFuelSpent': 'Brandstofkosten', + 'atlas.longestTrip': 'Langste reis', + 'atlas.longestDay': 'Langste dag', + 'atlas.tripLegend': 'Reizen', + 'atlas.viewTrip': 'Reis bekijken', + 'atlas.noRoadTrips': 'Nog geen roadtripgegevens. Markeer etappes als roadtrips in je reisplannen om ze hier te zien.', 'atlas.statsTab': 'Statistieken', 'atlas.bucketTab': 'Bucket List', 'atlas.addBucket': 'Toevoegen aan bucket list', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 15854e92..f1cccf00 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -140,6 +140,115 @@ const ru: Record = { 'settings.timeFormat': 'Формат времени', 'settings.routeCalculation': 'Расчёт маршрута', 'settings.blurBookingCodes': 'Скрыть коды бронирования', + 'settings.roadtrip.title': 'Автопутешествие', + 'settings.roadtrip.unitSystem': 'Система единиц', + 'settings.roadtrip.metric': 'Метрическая', + 'settings.roadtrip.imperial': 'Имперская', + 'settings.roadtrip.vehicle': 'Транспорт', + 'settings.roadtrip.fuelPricePerLitre': 'Цена топлива (за литр)', + 'settings.roadtrip.fuelPricePerGallon': 'Цена топлива (за галлон)', + 'settings.roadtrip.fuelCurrency': 'Валюта топлива', + 'settings.roadtrip.fuelConsumptionMetric': 'Расход топлива (л/100км)', + 'settings.roadtrip.fuelConsumptionImperial': 'Расход топлива (MPG)', + 'settings.roadtrip.tankSizeLitres': 'Объём бака (литры)', + 'settings.roadtrip.tankSizeGallons': 'Объём бака (галлоны)', + 'settings.roadtrip.drivingPreferences': 'Настройки вождения', + 'settings.roadtrip.maxDrivingHours': 'Макс. часов вождения', + 'settings.roadtrip.restInterval': 'Интервал отдыха (часы)', + 'settings.roadtrip.restDuration': 'Длительность отдыха (минуты)', + 'settings.roadtrip.maxSpeed': 'Максимальная скорость', + 'settings.roadtrip.maxSpeedHelp': 'Ограничивает расчётное время в пути на дорогах с более высокими ограничениями скорости. Оставьте пустым, чтобы использовать установленные ограничения.', + 'settings.roadtrip.routePreferences': 'Настройки маршрута', + 'settings.roadtrip.daylightOnly': 'Только в светлое время суток', + 'settings.roadtrip.roadPreference': 'Тип дороги', + 'settings.roadtrip.anyRoad': 'Любая дорога', + 'settings.roadtrip.sealedOnly': 'Только асфальтированные', + 'settings.roadtrip.fuelPreferences': 'Настройки топлива', + 'settings.roadtrip.fuelType': 'Тип топлива', + 'settings.roadtrip.fuelBrand': 'Предпочитаемый бренд', + 'settings.roadtrip.fuelTypeAny': 'Любое топливо', + 'settings.roadtrip.fuelTypeDiesel': 'Дизель', + 'settings.roadtrip.fuelTypePetrol': 'Бензин', + 'settings.roadtrip.brandAny': 'Любой бренд', + 'settings.roadtrip.brandPreferred': 'Предпочитаемый бренд — другие показываются при необходимости', + 'settings.roadtrip.avoidFerries': 'Избегать паромов', + 'settings.roadtrip.preferHighways': 'Предпочитать автомагистрали', + 'settings.roadtrip.truckSafe': 'Подходит для грузовиков / автодомов', + 'roadtrip.legActive': 'Маршрут автопутешествия активен', + 'roadtrip.markLeg': 'Отметить как этап автопутешествия', + 'roadtrip.toggleAll': 'Переключить все этапы автопутешествия', + 'roadtrip.summary': 'Сводка автопутешествия', + 'roadtrip.totalDistance': 'Общее расстояние', + 'roadtrip.totalTime': 'Время в пути', + 'roadtrip.totalFuel': 'Расходы на топливо', + 'roadtrip.day': 'День', + 'roadtrip.distance': 'Расстояние', + 'roadtrip.driveTime': 'Время в пути', + 'roadtrip.fuelCost': 'Топливо', + 'roadtrip.recalculateFuel': 'Пересчитать расходы на топливо', + 'roadtrip.recalculate': 'Пересчитать', + 'roadtrip.speedAdjusted': 'Время в пути скорректировано для максимальной скорости {speed}', + 'roadtrip.rangeWarning': 'Этот этап ({distance}) превышает запас хода вашего транспорта ({range}). Потребуется дозаправка.', + 'roadtrip.fuelStopsEstimate': 'Ожидаемые остановки для заправки: {count}', + 'roadtrip.exceedsRange': 'Превышает запас хода', + 'roadtrip.refuelPoint': 'Примерная точка заправки', + 'roadtrip.drivingTimeWarning': 'Время вождения ({actual}) превышает ваш дневной лимит {limit}', + 'roadtrip.restBreaks': '{count} перерывов (+{minutes} мин)', + 'roadtrip.restStop': 'Рекомендуемая остановка для отдыха', + 'roadtrip.totalWithBreaks': 'Итого с перерывами', + 'roadtrip.daylightWindow': '{sunrise} – {sunset} ({hours}ч)', + 'roadtrip.daylightWarning': 'Время вождения ({driving}) превышает световой день ({daylight})', + 'roadtrip.daylightMode': 'Режим дневного вождения активен', + 'roadtrip.truckSafeHelp': 'Требуется собственный сервер маршрутизации с профилем для грузовиков. Сервер по умолчанию использует маршрутизацию для легковых автомобилей.', + 'roadtrip.sealedOnlyHelp': 'Требуется пользовательский профиль маршрутизации. Сервер по умолчанию может включать грунтовые дороги.', + 'roadtrip.exceedsDaylight': 'Превышает доступные часы светлого времени', + 'roadtrip.estimatedRange': 'Расчётный запас хода: {range}', + 'roadtrip.driving': 'Вождение', + 'roadtrip.pdfDrivingSummary': 'Сводка вождения', + 'roadtrip.pdfTripTotal': 'Итого по поездке', + 'roadtrip.toggleAllTrip': 'Переключить все этапы автопутешествия', + 'roadtrip.calculatingRoutes': 'Расчёт маршрутов…', + 'roadtrip.showFullRoute': 'Показать весь маршрут поездки', + 'roadtrip.directions': 'Маршрут', + 'roadtrip.directionsOmitted': 'Маршрут опущен для этапов свыше 800 км', + 'roadtrip.directionCount': '{count} указаний', + 'roadtrip.arrive': 'Прибытие в пункт назначения', + 'roadtrip.depart': 'Отправление', + 'roadtrip.findStops': 'Найти остановки', + 'roadtrip.stopsFound': '{count} остановок найдено', + 'roadtrip.stopSource': 'Источник данных остановок', + 'roadtrip.stopSourceOsm': 'OpenStreetMap (бесплатно)', + 'roadtrip.stopSourceGoogle': 'Google Places (больше данных)', + 'roadtrip.stopSourceHelp': 'Источник для поиска заправок и зон отдыха вдоль маршрута.', + 'roadtrip.stopSourceGoogleDisabled': 'Google Places требует ключ API Google Maps, настроенный администратором.', + 'roadtrip.realFuelStop': 'заправка', + 'roadtrip.realRestStop': 'отдых', + 'roadtrip.openInMaps': 'Открыть в Google Maps', + 'roadtrip.approximateStop': 'Примерная остановка', + 'roadtrip.refreshStops': 'Обновить', + 'roadtrip.findingStops': 'Поиск остановок…', + 'roadtrip.recommendedDeparture': 'Выехать после', + 'roadtrip.arrivalBefore': 'Прибыть до', + 'roadtrip.drivingWindow': 'Окно для вождения', + 'roadtrip.departBy': 'Выезд в {time} · Прибытие в {arrival}', + 'roadtrip.insufficientDaylight': 'Недостаточно светового дня для этой поездки. Рассмотрите возможность разделить маршрут на два дня.', + 'roadtrip.setCheckout': 'Установить время выезда', + 'roadtrip.setCheckin': 'Установить время заезда', + 'roadtrip.checkoutUpdated': 'Время выезда обновлено', + 'roadtrip.checkinUpdated': 'Время заезда обновлено', + 'roadtrip.daylightOk': 'Дневное вождение ОК', + 'roadtrip.bookingsSafe': 'Ваш выезд ({checkout}) и заезд ({checkin}) находятся в безопасном дневном окне', + 'roadtrip.checkoutTooEarly': 'Выезд ({time}) раньше безопасного отъезда ({safe})', + 'roadtrip.checkinTooLate': 'Заезд ({time}) позже безопасного прибытия ({safe})', + 'roadtrip.criticalFuelStop': 'Необходима заправка', + 'roadtrip.optionalFuelStop': 'Доступно', + 'roadtrip.mustRefuel': 'ЗАПРАВИТЬ', + 'roadtrip.fuelAvailable': 'Доступно', + 'roadtrip.tripFuelPrice': 'Цена топлива', + 'roadtrip.tripFuelPriceHelp': 'Цена топлива для этой поездки', + 'roadtrip.tripOverride': 'цена поездки', + 'roadtrip.noPreferredBrand': 'Станции предпочитаемого бренда не найдены', + 'settings.roadtrip.stopSource': 'Поиск остановок', 'settings.notifications': 'Уведомления', 'settings.notifyTripInvite': 'Приглашения в поездку', 'settings.notifyBookingChange': 'Изменения бронирований', @@ -677,6 +786,16 @@ const ru: Record = { 'atlas.tripPlural': 'Поездки', 'atlas.placeVisited': 'Посещённое место', 'atlas.placesVisited': 'Посещённые места', + 'atlas.roadTrips': 'Автопутешествия', + 'atlas.totalDriven': 'Всего проехано', + 'atlas.totalRoadTrips': 'Поездки', + 'atlas.totalDrivingTime': 'Время в пути', + 'atlas.totalFuelSpent': 'Расходы на топливо', + 'atlas.longestTrip': 'Самая длинная поездка', + 'atlas.longestDay': 'Самый длинный день', + 'atlas.tripLegend': 'Поездки', + 'atlas.viewTrip': 'Открыть поездку', + 'atlas.noRoadTrips': 'Пока нет данных об автопутешествиях. Отметьте участки как автопутешествия в планах поездок, чтобы увидеть их здесь.', 'atlas.statsTab': 'Статистика', 'atlas.bucketTab': 'Список желаний', 'atlas.addBucket': 'Добавить в список желаний', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 65c05407..072a9b5b 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -140,6 +140,115 @@ const zh: Record = { 'settings.timeFormat': '时间格式', 'settings.routeCalculation': '路线计算', 'settings.blurBookingCodes': '模糊预订代码', + 'settings.roadtrip.title': '自驾游', + 'settings.roadtrip.unitSystem': '单位制', + 'settings.roadtrip.metric': '公制', + 'settings.roadtrip.imperial': '英制', + 'settings.roadtrip.vehicle': '车辆', + 'settings.roadtrip.fuelPricePerLitre': '油价(每升)', + 'settings.roadtrip.fuelPricePerGallon': '油价(每加仑)', + 'settings.roadtrip.fuelCurrency': '燃油货币', + 'settings.roadtrip.fuelConsumptionMetric': '油耗(升/100公里)', + 'settings.roadtrip.fuelConsumptionImperial': '油耗(英里/加仑)', + 'settings.roadtrip.tankSizeLitres': '油箱容量(升)', + 'settings.roadtrip.tankSizeGallons': '油箱容量(加仑)', + 'settings.roadtrip.drivingPreferences': '驾驶偏好', + 'settings.roadtrip.maxDrivingHours': '最大驾驶时长', + 'settings.roadtrip.restInterval': '休息间隔(小时)', + 'settings.roadtrip.restDuration': '休息时长(分钟)', + 'settings.roadtrip.maxSpeed': '最高速度', + 'settings.roadtrip.maxSpeedHelp': '限制高速路段的预计行驶时间。留空则使用道路限速。', + 'settings.roadtrip.routePreferences': '路线偏好', + 'settings.roadtrip.daylightOnly': '仅白天行驶', + 'settings.roadtrip.roadPreference': '道路偏好', + 'settings.roadtrip.anyRoad': '任意道路', + 'settings.roadtrip.sealedOnly': '仅铺装路面', + 'settings.roadtrip.fuelPreferences': '燃油偏好', + 'settings.roadtrip.fuelType': '燃油类型', + 'settings.roadtrip.fuelBrand': '品牌偏好', + 'settings.roadtrip.fuelTypeAny': '任何燃油', + 'settings.roadtrip.fuelTypeDiesel': '柴油', + 'settings.roadtrip.fuelTypePetrol': '汽油', + 'settings.roadtrip.brandAny': '任何品牌', + 'settings.roadtrip.brandPreferred': '首选品牌 — 需要时仍显示其他品牌', + 'settings.roadtrip.avoidFerries': '避开渡轮', + 'settings.roadtrip.preferHighways': '优先走高速', + 'settings.roadtrip.truckSafe': '适合卡车/房车', + 'roadtrip.legActive': '自驾游路线已激活', + 'roadtrip.markLeg': '标记为自驾游路段', + 'roadtrip.toggleAll': '切换所有自驾游路段', + 'roadtrip.summary': '自驾游摘要', + 'roadtrip.totalDistance': '总距离', + 'roadtrip.totalTime': '行驶时间', + 'roadtrip.totalFuel': '燃油费用', + 'roadtrip.day': '天', + 'roadtrip.distance': '距离', + 'roadtrip.driveTime': '行驶时间', + 'roadtrip.fuelCost': '燃油', + 'roadtrip.recalculateFuel': '重新计算燃油费用', + 'roadtrip.recalculate': '重新计算', + 'roadtrip.speedAdjusted': '行驶时间已按最高速度 {speed} 调整', + 'roadtrip.rangeWarning': '此路段({distance})超出车辆续航里程({range}),需要加油。', + 'roadtrip.fuelStopsEstimate': '预计加油次数:{count}', + 'roadtrip.exceedsRange': '超出车辆续航里程', + 'roadtrip.refuelPoint': '大约加油点', + 'roadtrip.drivingTimeWarning': '行驶时间({actual})超出您每日 {limit} 的限制', + 'roadtrip.restBreaks': '{count} 次休息(+{minutes} 分钟)', + 'roadtrip.restStop': '建议休息站', + 'roadtrip.totalWithBreaks': '含休息总计', + 'roadtrip.daylightWindow': '{sunrise} – {sunset}({hours}小时)', + 'roadtrip.daylightWarning': '行驶时间({driving})超出白天时长({daylight})', + 'roadtrip.daylightMode': '白天行驶模式已启用', + 'roadtrip.truckSafeHelp': '需要自建路由服务器并配置卡车模式。默认服务器使用标准汽车路由。', + 'roadtrip.sealedOnlyHelp': '需要自定义路由模式。默认服务器可能包含非铺装道路。', + 'roadtrip.exceedsDaylight': '超出可用白天时长', + 'roadtrip.estimatedRange': '预计续航:{range}', + 'roadtrip.driving': '驾驶', + 'roadtrip.pdfDrivingSummary': '驾驶摘要', + 'roadtrip.pdfTripTotal': '行程总计', + 'roadtrip.toggleAllTrip': '切换所有自驾游路段', + 'roadtrip.calculatingRoutes': '正在计算路线…', + 'roadtrip.showFullRoute': '显示完整行程路线', + 'roadtrip.directions': '导航指引', + 'roadtrip.directionsOmitted': '超过800公里的路段已省略导航指引', + 'roadtrip.directionCount': '{count} 条指引', + 'roadtrip.arrive': '到达目的地', + 'roadtrip.depart': '出发', + 'roadtrip.findStops': '查找停靠点', + 'roadtrip.stopsFound': '找到 {count} 个停靠点', + 'roadtrip.stopSource': '停靠点数据来源', + 'roadtrip.stopSourceOsm': 'OpenStreetMap(免费)', + 'roadtrip.stopSourceGoogle': 'Google Places(更丰富的数据)', + 'roadtrip.stopSourceHelp': '沿路线搜索加油站和休息区的数据来源。', + 'roadtrip.stopSourceGoogleDisabled': 'Google Places 需要管理员配置的 Google Maps API 密钥。', + 'roadtrip.realFuelStop': '加油站', + 'roadtrip.realRestStop': '休息区', + 'roadtrip.openInMaps': '在 Google Maps 中打开', + 'roadtrip.approximateStop': '大致停靠点', + 'roadtrip.refreshStops': '刷新', + 'roadtrip.findingStops': '正在查找停靠点…', + 'roadtrip.recommendedDeparture': '出发时间不早于', + 'roadtrip.arrivalBefore': '到达时间不晚于', + 'roadtrip.drivingWindow': '行驶时间窗口', + 'roadtrip.departBy': '{time} 出发 · {arrival} 到达', + 'roadtrip.insufficientDaylight': '日照时间不足以完成此行程。建议分两天行驶。', + 'roadtrip.setCheckout': '设置退房时间', + 'roadtrip.setCheckin': '设置入住时间', + 'roadtrip.checkoutUpdated': '退房时间已更新', + 'roadtrip.checkinUpdated': '入住时间已更新', + 'roadtrip.daylightOk': '日间驾驶正常', + 'roadtrip.bookingsSafe': '您的退房时间 ({checkout}) 和入住时间 ({checkin}) 在安全日光窗口内', + 'roadtrip.checkoutTooEarly': '退房时间 ({time}) 早于安全出发时间 ({safe})', + 'roadtrip.checkinTooLate': '入住时间 ({time}) 晚于安全到达时间 ({safe})', + 'roadtrip.criticalFuelStop': '必须加油', + 'roadtrip.optionalFuelStop': '可用', + 'roadtrip.mustRefuel': '加油', + 'roadtrip.fuelAvailable': '可用', + 'roadtrip.tripFuelPrice': '燃油价格', + 'roadtrip.tripFuelPriceHelp': '设置此行程的燃油价格', + 'roadtrip.tripOverride': '行程价格', + 'roadtrip.noPreferredBrand': '未找到首选品牌加油站', + 'settings.roadtrip.stopSource': '停靠点搜索', 'settings.notifications': '通知', 'settings.notifyTripInvite': '旅行邀请', 'settings.notifyBookingChange': '预订变更', @@ -677,6 +786,16 @@ const zh: Record = { 'atlas.tripPlural': '次旅行', 'atlas.placeVisited': '个地点已访问', 'atlas.placesVisited': '个地点已访问', + 'atlas.roadTrips': '自驾游', + 'atlas.totalDriven': '总行驶距离', + 'atlas.totalRoadTrips': '自驾游', + 'atlas.totalDrivingTime': '驾驶时间', + 'atlas.totalFuelSpent': '燃油费用', + 'atlas.longestTrip': '最长旅程', + 'atlas.longestDay': '最长一天', + 'atlas.tripLegend': '旅程', + 'atlas.viewTrip': '查看旅程', + 'atlas.noRoadTrips': '暂无自驾游数据。在旅行计划中将路段标记为自驾游即可在此查看。', 'atlas.statsTab': '统计', 'atlas.bucketTab': '心愿单', 'atlas.addBucket': '添加到心愿单', diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx index 11296671..9a367add 100644 --- a/client/src/pages/AtlasPage.tsx +++ b/client/src/pages/AtlasPage.tsx @@ -5,9 +5,12 @@ import { useSettingsStore } from '../store/settingsStore' import Navbar from '../components/Layout/Navbar' import apiClient, { mapsApi } from '../api/client' import CustomSelect from '../components/shared/CustomSelect' -import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react' +import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search, Car } from 'lucide-react' import L from 'leaflet' import type { AtlasPlace, GeoJsonFeatureCollection, TranslationFn } from '../types' +import { useAddonStore } from '../store/addonStore' +import { decodePolyline } from '../components/Map/RoadTripRoute' +import { formatDistance, formatDuration, formatFuelCost } from '../utils/roadtripFormatters' // Convert country code to flag emoji interface AtlasCountry { @@ -44,6 +47,38 @@ interface CountryDetail { manually_marked?: boolean } +interface RoadTripLeg { + route_geometry: string + distance_meters: number | null + duration_seconds: number | null + from_name: string | null + to_name: string | null + day_index: number +} + +interface RoadTripData { + trip_id: number + trip_title: string + start_date: string | null + end_date: string | null + total_distance_meters: number + total_duration_seconds: number + total_fuel_cost: number + legs: RoadTripLeg[] +} + +interface RoadTripResponse { + trips: RoadTripData[] + totals: { + total_trips: number + total_distance_meters: number + total_fuel_cost: number + total_driving_seconds: number + } +} + +const TRIP_COLORS = ['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'] + function MobileStats({ data, stats, countries, resolveName, t, dark }: { data: AtlasData | null; stats: AtlasStats; countries: AtlasCountry[]; resolveName: (code: string) => string; t: TranslationFn; dark: boolean }): React.ReactElement { const tp = dark ? '#f1f5f9' : '#0f172a' const tf = dark ? '#475569' : '#94a3b8' @@ -167,8 +202,14 @@ export default function AtlasPage(): React.ReactElement { const [bucketSearching, setBucketSearching] = useState(false) const [bucketPoiMonth, setBucketPoiMonth] = useState(0) const [bucketPoiYear, setBucketPoiYear] = useState(0) - const [bucketTab, setBucketTab] = useState<'stats' | 'bucket'>('stats') + const [bucketTab, setBucketTab] = useState<'stats' | 'bucket' | 'roadtrips'>('stats') const bucketMarkersRef = useRef(null) + const roadtripEnabled = useAddonStore(s => s.isEnabled('roadtrip')) + const [roadTripData, setRoadTripData] = useState(null) + const [highlightedTrip, setHighlightedTrip] = useState(null) + const roadtripLayerRef = useRef(null) + const rtUnitSystem = useSettingsStore(s => s.settings.roadtrip_unit_system) || 'metric' + const rtFuelCurrency = useSettingsStore(s => s.settings.roadtrip_fuel_currency) || useSettingsStore(s => s.settings.default_currency) || 'USD' // Load atlas data + bucket list useEffect(() => { @@ -182,6 +223,12 @@ export default function AtlasPage(): React.ReactElement { }).catch(() => setLoading(false)) }, []) + // Load road trip data when addon is enabled + useEffect(() => { + if (!roadtripEnabled) return + apiClient.get('/addons/atlas/road-trips').then(r => setRoadTripData(r.data)).catch(() => {}) + }, [roadtripEnabled]) + // Load GeoJSON world data (direct GeoJSON, no conversion needed) useEffect(() => { fetch('https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson') @@ -466,6 +513,52 @@ export default function AtlasPage(): React.ReactElement { bucketMarkersRef.current = L.layerGroup(markers).addTo(mapInstance.current) }, [bucketList]) + // Render road trip polylines on map + useEffect(() => { + if (!mapInstance.current) return + if (roadtripLayerRef.current) { + mapInstance.current.removeLayer(roadtripLayerRef.current) + roadtripLayerRef.current = null + } + if (bucketTab !== 'roadtrips' || !roadTripData?.trips?.length) return + + const layers: L.Layer[] = [] + roadTripData.trips.forEach((trip, ti) => { + const color = TRIP_COLORS[ti % TRIP_COLORS.length] + const isHighlighted = highlightedTrip === null || highlightedTrip === trip.trip_id + trip.legs.forEach(leg => { + const positions = decodePolyline(leg.route_geometry) + if (positions.length < 2) return + const polyline = L.polyline(positions, { + color, + weight: isHighlighted ? 4 : 2, + opacity: isHighlighted ? 0.85 : 0.2, + }) + const tooltipHtml = `
${trip.trip_title}
` + + (leg.from_name && leg.to_name ? `
${leg.from_name} → ${leg.to_name}
` : '') + + (leg.distance_meters ? `
${formatDistance(leg.distance_meters, rtUnitSystem)}
` : '') + + (trip.start_date ? `
${trip.start_date}
` : '') + polyline.bindTooltip(tooltipHtml, { sticky: true, className: 'atlas-tooltip', direction: 'top' }) + polyline.on('click', () => navigate(`/trips/${trip.trip_id}`)) + polyline.setStyle({ cursor: 'pointer' } as any) + layers.push(polyline) + }) + }) + roadtripLayerRef.current = L.layerGroup(layers).addTo(mapInstance.current) + + // Fit bounds if highlighting a specific trip + if (highlightedTrip !== null) { + const trip = roadTripData.trips.find(t => t.trip_id === highlightedTrip) + if (trip) { + const allPositions = trip.legs.flatMap(l => decodePolyline(l.route_geometry)) + if (allPositions.length > 0) { + const bounds = L.latLngBounds(allPositions.map(p => L.latLng(p[0], p[1]))) + mapInstance.current.fitBounds(bounds, { padding: [40, 40], maxZoom: 10 }) + } + } + } + }, [bucketTab, roadTripData, highlightedTrip, dark, rtUnitSystem]) + const loadCountryDetail = async (code: string): Promise => { setSelectedCountry(code) try { @@ -554,6 +647,10 @@ export default function AtlasPage(): React.ReactElement { bucketSearchResults={bucketSearchResults} bucketPoiMonth={bucketPoiMonth} setBucketPoiMonth={setBucketPoiMonth} bucketPoiYear={bucketPoiYear} setBucketPoiYear={setBucketPoiYear} bucketSearching={bucketSearching} bucketSearch={bucketSearch} setBucketSearch={setBucketSearch} + roadtripEnabled={roadtripEnabled} roadTripData={roadTripData} + highlightedTrip={highlightedTrip} setHighlightedTrip={setHighlightedTrip} + rtUnitSystem={rtUnitSystem} rtFuelCurrency={rtFuelCurrency} + onTripNavigate={(id) => navigate(`/trips/${id}`)} t={t} dark={dark} /> @@ -706,8 +803,8 @@ interface SidebarContentProps { onTripClick: (id: number) => void onUnmarkCountry?: (code: string) => void bucketList: any[] - bucketTab: 'stats' | 'bucket' - setBucketTab: (tab: 'stats' | 'bucket') => void + bucketTab: 'stats' | 'bucket' | 'roadtrips' + setBucketTab: (tab: 'stats' | 'bucket' | 'roadtrips') => void showBucketAdd: boolean setShowBucketAdd: (v: boolean) => void bucketForm: { name: string; notes: string; lat: string; lng: string; target_date: string } @@ -724,11 +821,18 @@ interface SidebarContentProps { bucketSearching: boolean bucketSearch: string setBucketSearch: (v: string) => void + roadtripEnabled: boolean + roadTripData: RoadTripResponse | null + highlightedTrip: number | null + setHighlightedTrip: (id: number | null) => void + rtUnitSystem: string + rtFuelCurrency: string + onTripNavigate: (id: number) => void t: TranslationFn dark: boolean } -function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onCountryClick, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement { +function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onCountryClick, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, roadtripEnabled, roadTripData, highlightedTrip, setHighlightedTrip, rtUnitSystem, rtFuelCurrency, onTripNavigate, t, dark }: SidebarContentProps): React.ReactElement { const { language } = useTranslation() const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})` const tp = dark ? '#f1f5f9' : '#0f172a' @@ -745,7 +849,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail // Tab switcher const tabBar = (
- {[{ id: 'stats', label: t('atlas.statsTab'), icon: Globe }, { id: 'bucket', label: t('atlas.bucketTab'), icon: Star }].map(tab => ( + {[{ id: 'stats', label: t('atlas.statsTab'), icon: Globe }, { id: 'bucket', label: t('atlas.bucketTab'), icon: Star }, ...(roadtripEnabled ? [{ id: 'roadtrips', label: t('atlas.roadTrips'), icon: Car }] : [])].map(tab => ( + {longestDay.distance > 0 && ( +
+

{t('atlas.longestDay')}

+

{longestDay.tripTitle}

+

Day {longestDay.dayIndex} · {formatDistance(longestDay.distance, rtUnitSystem)}

+
+ )} +
+ + {/* Divider */} +
+ + {/* Legend */} +
+ {t('atlas.tripLegend')} + {trips.map((trip, i) => { + const color = TRIP_COLORS[i % TRIP_COLORS.length] + const isActive = highlightedTrip === trip.trip_id + return ( + + ) + })} +
+
+ ) +} diff --git a/client/src/pages/SettingsPage.tsx b/client/src/pages/SettingsPage.tsx index 98bae0f5..050d3ac4 100644 --- a/client/src/pages/SettingsPage.tsx +++ b/client/src/pages/SettingsPage.tsx @@ -6,7 +6,7 @@ import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n' import Navbar from '../components/Layout/Navbar' import CustomSelect from '../components/shared/CustomSelect' import { useToast } from '../components/shared/Toast' -import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound, AlertTriangle, Copy, Download, Printer, Terminal, Plus, Check } from 'lucide-react' +import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock, KeyRound, AlertTriangle, Copy, Download, Printer, Terminal, Plus, Check, Car } from 'lucide-react' import { authApi, adminApi, notificationsApi } from '../api/client' import apiClient from '../api/client' import { useAddonStore } from '../store/addonStore' @@ -111,7 +111,7 @@ function NotificationPreferences({ t, memoriesEnabled }: { t: any; memoriesEnabl } export default function SettingsPage(): React.ReactElement { - const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode, appRequireMfa } = useAuthStore() + const { user, updateProfile, uploadAvatar, deleteAvatar, logout, loadUser, demoMode, appRequireMfa, hasMapsKey } = useAuthStore() const [searchParams] = useSearchParams() const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const avatarInputRef = React.useRef(null) @@ -126,6 +126,7 @@ export default function SettingsPage(): React.ReactElement { // Addon gating (derived from store) const memoriesEnabled = addonEnabled('memories') const mcpEnabled = addonEnabled('mcp') + const roadtripEnabled = addonEnabled('roadtrip') const [immichUrl, setImmichUrl] = useState('') const [immichApiKey, setImmichApiKey] = useState('') const [immichConnected, setImmichConnected] = useState(false) @@ -667,6 +668,361 @@ export default function SettingsPage(): React.ReactElement { + {/* Road Trip — only when Road Trip addon is enabled */} + {roadtripEnabled && ( +
+ {/* Unit System */} +
+ +
+ {[ + { value: 'metric', label: t('settings.roadtrip.metric') }, + { value: 'imperial', label: t('settings.roadtrip.imperial') }, + ].map(opt => ( + + ))} +
+
+ + {/* Vehicle section */} +
+ +
+
+ + { + try { await updateSetting('roadtrip_fuel_price', e.target.value) } + catch {} + }} + placeholder="0.00" + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +
+
+ + { + try { await updateSetting('roadtrip_fuel_currency', e.target.value) } + catch {} + }} + placeholder="AUD" + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +
+
+ + { + try { await updateSetting('roadtrip_fuel_consumption', e.target.value) } + catch {} + }} + placeholder={(settings.roadtrip_unit_system || 'metric') === 'metric' ? '8.0' : '30'} + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +
+
+ + { + try { await updateSetting('roadtrip_tank_size', e.target.value) } + catch {} + }} + placeholder={(settings.roadtrip_unit_system || 'metric') === 'metric' ? '60' : '16'} + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> + {settings.roadtrip_tank_size && settings.roadtrip_fuel_consumption && (() => { + const us = (settings.roadtrip_unit_system || 'metric') as 'metric' | 'imperial' + const tank = parseFloat(settings.roadtrip_tank_size!) + const consumption = parseFloat(settings.roadtrip_fuel_consumption!) + if (!tank || !consumption) return null + const rangeVal = us === 'imperial' ? tank * consumption : (tank / consumption) * 100 + const unit = us === 'imperial' ? 'mi' : 'km' + return
{t('roadtrip.estimatedRange', { range: `${Math.round(rangeVal)} ${unit}` })}
+ })()} +
+
+
+ + {/* Fuel Preferences */} +
+ +
+
+ + +
+
+ +
+ {[ + { value: 'any', label: t('settings.roadtrip.brandAny') }, + { value: 'Mobil', label: 'Mobil' }, + { value: 'Ampol', label: 'Ampol' }, + { value: 'BP', label: 'BP' }, + { value: 'Shell', label: 'Shell' }, + { value: '7-Eleven', label: '7-Eleven' }, + ].map(opt => { + const current = settings.roadtrip_fuel_brand || 'any'; + const selectedBrands = current === 'any' ? [] : current.split(','); + const isSelected = opt.value === 'any' ? current === 'any' : selectedBrands.includes(opt.value); + return ( + + ); + })} +
+ {settings.roadtrip_fuel_brand && settings.roadtrip_fuel_brand !== 'any' && ( +

{t('settings.roadtrip.brandPreferred')}

+ )} +
+
+
+ + {/* Driving Preferences */} +
+ +
+
+ + { + try { await updateSetting('roadtrip_max_driving_hours', e.target.value) } + catch {} + }} + placeholder="8" + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +
+
+ + { + try { await updateSetting('roadtrip_rest_interval_hours', e.target.value) } + catch {} + }} + placeholder="2" + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +
+
+ + { + try { await updateSetting('roadtrip_rest_duration_minutes', e.target.value) } + catch {} + }} + placeholder="15" + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + /> +
+
+
+ + { + try { await updateSetting('roadtrip_max_speed', e.target.value) } + catch {} + }} + placeholder={(settings.roadtrip_unit_system || 'metric') === 'metric' ? 'km/h' : 'mph'} + className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent" + style={{ maxWidth: 200 }} + /> +

{t('settings.roadtrip.maxSpeedHelp')}

+
+
+ +
+ {[ + { value: 'osm', label: t('roadtrip.stopSourceOsm') }, + { value: 'google', label: t('roadtrip.stopSourceGoogle') }, + ].map(opt => { + const disabled = opt.value === 'google' && !hasMapsKey + return ( + + ) + })} +
+

{t('roadtrip.stopSourceHelp')}

+
+
+ + {/* Route Preferences */} +
+ +
+ {/* Daylight Only */} +
+ {t('settings.roadtrip.daylightOnly')} + +
+ + {/* Road Preference */} +
+ {t('settings.roadtrip.roadPreference')} +
+ {[ + { value: 'any', label: t('settings.roadtrip.anyRoad') }, + { value: 'sealed_only', label: t('settings.roadtrip.sealedOnly') }, + ].map(opt => ( + + ))} +
+
+ {settings.roadtrip_road_preference === 'sealed_only' && ( +
{t('roadtrip.sealedOnlyHelp')}
+ )} + +
+
+
+ )} + {/* Notifications */}
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 7636a86c..ff96a2fa 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -28,6 +28,8 @@ import { useResizablePanels } from '../hooks/useResizablePanels' import { useTripWebSocket } from '../hooks/useTripWebSocket' import { useRouteCalculation } from '../hooks/useRouteCalculation' import { usePlaceSelection } from '../hooks/usePlaceSelection' +import { useRoadtripStore } from '../store/roadtripStore' +import { useAddonStore } from '../store/addonStore' import type { Accommodation, TripMember, Day, Place, Reservation } from '../types' export default function TripPlannerPage(): React.ReactElement | null { @@ -98,6 +100,9 @@ export default function TripPlannerPage(): React.ReactElement | null { const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null) const [deletePlaceId, setDeletePlaceId] = useState(null) + const roadtripStore = useRoadtripStore() + const roadtripEnabled = useAddonStore(s => s.isEnabled('roadtrip')) + // Load trip + files (needed for place inspector file section) useEffect(() => { if (tripId) { @@ -109,8 +114,15 @@ export default function TripPlannerPage(): React.ReactElement | null { const all = [d.owner, ...(d.members || [])].filter(Boolean) setTripMembers(all) }).catch(() => {}) + // Load road trip route legs + if (roadtripEnabled) { + roadtripStore.loadRouteLegs(tripId) + } } - }, [tripId]) + return () => { + if (tripId) roadtripStore.clearTrip(tripId) + } + }, [tripId, roadtripEnabled]) useEffect(() => { if (tripId) tripStore.loadReservations(tripId) @@ -239,6 +251,8 @@ export default function TripPlannerPage(): React.ReactElement | null { catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }, [tripId, tripStore, toast, updateRouteForDay]) + const reorderTimerRef = React.useRef | null>(null) + const handleReorder = useCallback((dayId, orderedIds) => { try { tripStore.reorderAssignments(tripId, dayId, orderedIds).catch(() => {}) @@ -249,9 +263,23 @@ export default function TripPlannerPage(): React.ReactElement | null { if (waypoints.length >= 2) setRoute(waypoints.map(p => [p.lat, p.lng])) else setRoute(null) setRouteInfo(null) + + // Recalculate road trip legs after reorder (debounced) + if (roadtripEnabled && tripId && waypoints.length >= 2) { + if (reorderTimerRef.current) clearTimeout(reorderTimerRef.current) + reorderTimerRef.current = setTimeout(() => { + const dayIndex = days.findIndex(d => d.id === dayId) + if (dayIndex < 0) return + const pairs = waypoints.slice(0, -1).map((w, i) => ({ + from: String(ordered.find(a => a.place?.id === w.id)?.place?.id || ''), + to: String(ordered.find(a => a.place?.id === waypoints[i + 1]?.id)?.place?.id || ''), + })).filter(p => p.from && p.to) + roadtripStore.recalculateDay(tripId, dayIndex, pairs) + }, 2000) + } } catch { toast.error(t('trip.toast.reorderError')) } - }, [tripId, tripStore, toast]) + }, [tripId, tripStore, toast, roadtripEnabled, days, roadtripStore]) const handleUpdateDayTitle = useCallback(async (dayId, title) => { try { await tripStore.updateDayTitle(tripId, dayId, title) } @@ -394,6 +422,9 @@ export default function TripPlannerPage(): React.ReactElement | null { leftWidth={leftCollapsed ? 0 : leftWidth} rightWidth={rightCollapsed ? 0 : rightWidth} hasInspector={!!selectedPlace} + tripId={tripId} + selectedDayId={selectedDayId} + days={days} /> diff --git a/client/src/store/roadtripStore.ts b/client/src/store/roadtripStore.ts new file mode 100644 index 00000000..aa6141dc --- /dev/null +++ b/client/src/store/roadtripStore.ts @@ -0,0 +1,318 @@ +import { create } from 'zustand' +import { roadtripApi } from '../api/client' +import { useSettingsStore } from './settingsStore' +import { calculateVehicleRange } from '../utils/roadtripFormatters' +import type { RouteLeg } from '../types' + +interface RoadtripState { + routeLegs: Record + loading: boolean + error: string | null + batchProgress: { current: number; total: number } | null + _batchCancelled: boolean + findingStopsLegIds: Set + + loadRouteLegs: (tripId: string) => Promise + toggleRoadTrip: (tripId: string, legId: number, isRoadTrip: boolean, dayIndex?: number, fromPlaceId?: string, toPlaceId?: string) => Promise + toggleAllTrip: (tripId: string, days: { id: number; assignments: { id: number; place: { id: number; lat: number | null; lng: number | null } | null }[] }[]) => Promise + cancelBatch: () => void + calculateRoute: (tripId: string, dayIndex: number, fromPlaceId: string, toPlaceId: string) => Promise + deleteRouteLeg: (tripId: string, legId: number) => Promise + recalculateDay: (tripId: string, dayIndex: number, placePairs: { from: string; to: string }[]) => Promise + recalculate: (tripId: string) => Promise + findStops: (tripId: string, legId: number, stopType: string, searchPoints: { lat: number; lng: number; distance_along_route_meters: number }[], corridor?: boolean) => Promise + autoFindStops: (tripId: string, leg: RouteLeg) => Promise + clearTrip: (tripId: string) => void + + getLegsForDay: (tripId: string, dayIndex: number) => RouteLeg[] + getLegBetween: (tripId: string, dayIndex: number, fromPlaceId: string, toPlaceId: string) => RouteLeg | undefined + getTripTotals: (tripId: string, restIntervalHours?: number | null, restDurationMinutes?: number | null) => { totalDistanceMeters: number; totalDurationSeconds: number; totalFuelCost: number; totalRestBreaks: number; totalRestTimeSeconds: number; totalTravelTimeWithBreaks: number } + getDayTotals: (tripId: string, dayIndex: number) => { distanceMeters: number; durationSeconds: number; fuelCost: number } +} + +export const useRoadtripStore = create((set, get) => ({ + routeLegs: {}, + loading: false, + error: null, + batchProgress: null, + _batchCancelled: false, + findingStopsLegIds: new Set(), + + loadRouteLegs: async (tripId) => { + set({ loading: true, error: null }) + try { + const data = await roadtripApi.listLegs(tripId) + const legs = (data.legs || []).map((l: RouteLeg) => ({ ...l, is_road_trip: !!l.is_road_trip })) + set(s => ({ routeLegs: { ...s.routeLegs, [tripId]: legs }, loading: false })) + } catch (err) { + console.error('Failed to load route legs:', err) + set({ loading: false }) + } + }, + + toggleRoadTrip: async (tripId, legId, isRoadTrip, dayIndex, fromPlaceId, toPlaceId) => { + if (isRoadTrip && dayIndex !== undefined && fromPlaceId && toPlaceId) { + // Enabling — if no leg exists, calculate one; otherwise update existing + let leg: RouteLeg | null = null + if (legId <= 0) { + leg = await get().calculateRoute(tripId, dayIndex, fromPlaceId, toPlaceId) + } else { + try { + await roadtripApi.updateLeg(tripId, legId, { is_road_trip: true }) + leg = await get().calculateRoute(tripId, dayIndex, fromPlaceId, toPlaceId) + } catch (err) { console.error('Failed to enable road trip leg:', err) } + } + // Auto-find stops after route calculation + if (leg?.route_geometry && leg.distance_meters) { + get().autoFindStops(tripId, leg) + } + return + } else if (!isRoadTrip && legId > 0) { + // Disabling + try { + await roadtripApi.updateLeg(tripId, legId, { is_road_trip: false }) + set(s => ({ + routeLegs: { + ...s.routeLegs, + [tripId]: (s.routeLegs[tripId] || []).map(l => + l.id === legId ? { ...l, is_road_trip: false } : l + ), + } + })) + } catch (err) { console.error('Failed to disable road trip leg:', err) } + } + }, + + toggleAllTrip: async (tripId, days) => { + const tripIdStr = String(tripId) + const allLegs = get().routeLegs[tripIdStr] || [] + + // Build all place pairs across all days + const allPairs: { dayIndex: number; from: string; to: string }[] = [] + for (let di = 0; di < days.length; di++) { + const places = days[di].assignments + .filter(a => a.place?.lat && a.place?.lng) + .map(a => a.place!) + for (let i = 0; i < places.length - 1; i++) { + allPairs.push({ dayIndex: di, from: String(places[i].id), to: String(places[i + 1].id) }) + } + } + if (allPairs.length === 0) return + + // Determine if all are active → toggle off, else toggle on + const allActive = allPairs.every(p => { + const leg = allLegs.find(l => l.day_index === p.dayIndex && String(l.from_place_id) === p.from && String(l.to_place_id) === p.to) + return leg?.is_road_trip + }) + + set({ _batchCancelled: false, batchProgress: { current: 0, total: allPairs.length } }) + + if (allActive) { + // Disable all — no OSRM calls needed, fast + for (const p of allPairs) { + const leg = allLegs.find(l => l.day_index === p.dayIndex && String(l.from_place_id) === p.from && String(l.to_place_id) === p.to) + if (leg) { + await get().toggleRoadTrip(tripIdStr, leg.id, false) + } + } + set({ batchProgress: null }) + } else { + // Enable all — sequential OSRM calls (rate limited server-side) + for (let i = 0; i < allPairs.length; i++) { + if (get()._batchCancelled) break + const p = allPairs[i] + const leg = allLegs.find(l => l.day_index === p.dayIndex && String(l.from_place_id) === p.from && String(l.to_place_id) === p.to) + await get().toggleRoadTrip(tripIdStr, leg?.id ?? -1, true, p.dayIndex, p.from, p.to) + set({ batchProgress: { current: i + 1, total: allPairs.length } }) + } + set({ batchProgress: null, _batchCancelled: false }) + } + }, + + cancelBatch: () => { + set({ _batchCancelled: true }) + }, + + calculateRoute: async (tripId, dayIndex, fromPlaceId, toPlaceId) => { + try { + const data = await roadtripApi.calculateLeg(tripId, { day_index: dayIndex, from_place_id: fromPlaceId, to_place_id: toPlaceId }) + const leg = { ...data.leg, is_road_trip: !!data.leg.is_road_trip } + set(s => { + const existing = s.routeLegs[tripId] || [] + const idx = existing.findIndex(l => l.id === leg.id) + const updated = idx >= 0 + ? existing.map(l => l.id === leg.id ? leg : l) + : [...existing, leg] + return { routeLegs: { ...s.routeLegs, [tripId]: updated } } + }) + return leg + } catch (err) { + console.error('Failed to calculate route:', err) + return null + } + }, + + deleteRouteLeg: async (tripId, legId) => { + try { + await roadtripApi.deleteLeg(tripId, legId) + set(s => ({ + routeLegs: { + ...s.routeLegs, + [tripId]: (s.routeLegs[tripId] || []).filter(l => l.id !== legId), + } + })) + } catch (err) { console.error('Failed to delete route leg:', err) } + }, + + recalculateDay: async (tripId, dayIndex, placePairs) => { + const legs = get().getLegsForDay(tripId, dayIndex) + const roadTripFromIds = new Set(legs.filter(l => l.is_road_trip).map(l => `${l.from_place_id}-${l.to_place_id}`)) + + for (const pair of placePairs) { + const key = `${pair.from}-${pair.to}` + if (roadTripFromIds.has(key)) { + await get().calculateRoute(tripId, dayIndex, pair.from, pair.to) + } + } + }, + + recalculate: async (tripId) => { + try { + const data = await roadtripApi.recalculate(tripId) + const legs = (data.legs || []).map((l: RouteLeg) => ({ ...l, is_road_trip: !!l.is_road_trip })) + set(s => ({ routeLegs: { ...s.routeLegs, [tripId]: legs } })) + } catch (err) { console.error('Failed to recalculate routes:', err) } + }, + + findStops: async (tripId, legId, stopType, searchPoints, corridor) => { + try { + set(s => ({ findingStopsLegIds: new Set([...s.findingStopsLegIds, legId]) })) + await roadtripApi.findStops(tripId, legId, { stop_type: stopType, search_points: searchPoints, corridor }) + await get().loadRouteLegs(tripId) + } catch (err) { console.error('Failed to find stops:', err) } finally { + set(s => { + const next = new Set(s.findingStopsLegIds) + next.delete(legId) + return { findingStopsLegIds: next } + }) + } + }, + + autoFindStops: async (tripId, leg) => { + if (!leg.route_geometry || !leg.distance_meters || !leg.duration_seconds) return + try { + const { decodePolyline, getRefuelPoints, haversine } = await import('../components/Map/RoadTripRoute') + const settings = useSettingsStore.getState().settings + const unitSystem = (settings.roadtrip_unit_system || 'metric') as 'metric' | 'imperial' + const tankSize = parseFloat(settings.roadtrip_tank_size || '0') + const fuelConsumption = parseFloat(settings.roadtrip_fuel_consumption || '0') + const restIntervalHours = parseFloat(settings.roadtrip_rest_interval_hours || '0') || null + const restDurationMinutes = parseFloat(settings.roadtrip_rest_duration_minutes || '0') || null + + const vehicleRangeMeters = tankSize && fuelConsumption + ? calculateVehicleRange(tankSize, fuelConsumption, unitSystem) * (unitSystem === 'imperial' ? 1609.344 : 1000) + : null + + const positions = decodePolyline(leg.route_geometry!) + const totalDist = leg.distance_meters + const exceedsRange = vehicleRangeMeters ? totalDist > vehicleRangeMeters : false + const legHours = leg.duration_seconds / 3600 + const restBreaks = restIntervalHours && restDurationMinutes && legHours > restIntervalHours + ? Math.floor(legHours / restIntervalHours) : 0 + + const needsFuel = exceedsRange && vehicleRangeMeters + const needsRest = restBreaks > 0 && restIntervalHours + + if (!needsFuel && !needsRest) return + + // Fuel: corridor search — sample every ~80km along the route to find ALL fuel stations + if (needsFuel) { + const corridorInterval = 150000 // 150km between sample points + const corridorPts = getRefuelPoints(positions, corridorInterval) + // Also add start and end points for coverage + const corridorPoints: { lat: number; lng: number; distance_along_route_meters: number }[] = [ + { lat: positions[0][0], lng: positions[0][1], distance_along_route_meters: 0 }, + ] + let cumDist = corridorInterval + for (const pt of corridorPts) { + corridorPoints.push({ lat: pt[0], lng: pt[1], distance_along_route_meters: cumDist }) + cumDist += corridorInterval + } + corridorPoints.push({ lat: positions[positions.length - 1][0], lng: positions[positions.length - 1][1], distance_along_route_meters: totalDist }) + await get().findStops(tripId, leg.id, 'fuel', corridorPoints, true) + } + + // Rest: point search at rest intervals (unchanged — rest stops are needed at specific intervals) + if (needsRest) { + const avgSpeedMs = totalDist / leg.duration_seconds + const restIntervalMeters = restIntervalHours! * 3600 * avgSpeedMs + if (restIntervalMeters > 0 && totalDist > restIntervalMeters) { + const restPts = getRefuelPoints(positions, restIntervalMeters) + const restSearchPoints: { lat: number; lng: number; distance_along_route_meters: number }[] = [] + let cumDist = restIntervalMeters + for (const pt of restPts) { + restSearchPoints.push({ lat: pt[0], lng: pt[1], distance_along_route_meters: cumDist }) + cumDist += restIntervalMeters + } + if (restSearchPoints.length > 0) { + await get().findStops(tripId, leg.id, 'rest', restSearchPoints) + } + } + } + } catch (err) { console.error('Failed to auto-find stops:', err) } + }, + + clearTrip: (tripId) => { + set(s => { + const next = { ...s.routeLegs } + delete next[tripId] + return { routeLegs: next } + }) + }, + + getLegsForDay: (tripId, dayIndex) => { + return (get().routeLegs[tripId] || []).filter(l => l.day_index === dayIndex) + }, + + getLegBetween: (tripId, dayIndex, fromPlaceId, toPlaceId) => { + return (get().routeLegs[tripId] || []).find( + l => l.day_index === dayIndex && String(l.from_place_id) === String(fromPlaceId) && String(l.to_place_id) === String(toPlaceId) + ) + }, + + getTripTotals: (tripId, restIntervalHours, restDurationMinutes) => { + const legs = (get().routeLegs[tripId] || []).filter(l => l.is_road_trip) + const totalDistanceMeters = legs.reduce((sum, l) => sum + (l.distance_meters || 0), 0) + const totalDurationSeconds = legs.reduce((sum, l) => sum + (l.duration_seconds || 0), 0) + const totalFuelCost = legs.reduce((sum, l) => sum + (l.fuel_cost || 0), 0) + + let totalRestBreaks = 0 + if (restIntervalHours && restDurationMinutes) { + for (const leg of legs) { + const legHours = (leg.duration_seconds || 0) / 3600 + if (legHours > restIntervalHours) { + totalRestBreaks += Math.floor(legHours / restIntervalHours) + } + } + } + const totalRestTimeSeconds = totalRestBreaks * (restDurationMinutes || 0) * 60 + + return { + totalDistanceMeters, + totalDurationSeconds, + totalFuelCost, + totalRestBreaks, + totalRestTimeSeconds, + totalTravelTimeWithBreaks: totalDurationSeconds + totalRestTimeSeconds, + } + }, + + getDayTotals: (tripId, dayIndex) => { + const legs = (get().routeLegs[tripId] || []).filter(l => l.is_road_trip && l.day_index === dayIndex) + return { + distanceMeters: legs.reduce((sum, l) => sum + (l.distance_meters || 0), 0), + durationSeconds: legs.reduce((sum, l) => sum + (l.duration_seconds || 0), 0), + fuelCost: legs.reduce((sum, l) => sum + (l.fuel_cost || 0), 0), + } + }, +})) diff --git a/client/src/types.ts b/client/src/types.ts index b216b63c..979da232 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -23,6 +23,8 @@ export interface Trip { owner_id: number created_at: string updated_at: string + roadtrip_fuel_price?: string | null + roadtrip_fuel_currency?: string | null } export interface Day { @@ -172,6 +174,23 @@ export interface Settings { show_place_description: boolean route_calculation?: boolean blur_booking_codes?: boolean + roadtrip_unit_system?: string + roadtrip_fuel_price?: string + roadtrip_fuel_currency?: string + roadtrip_fuel_consumption?: string + roadtrip_tank_size?: string + roadtrip_max_driving_hours?: string + roadtrip_rest_interval_hours?: string + roadtrip_rest_duration_minutes?: string + roadtrip_daylight_only?: string + roadtrip_road_preference?: string + roadtrip_avoid_ferries?: string + roadtrip_prefer_highways?: string + roadtrip_truck_safe?: string + roadtrip_max_speed?: string + roadtrip_stop_source?: string + roadtrip_fuel_type?: string + roadtrip_fuel_brand?: string } export interface AssignmentsMap { @@ -350,6 +369,54 @@ export interface HolidaysMap { [date: string]: HolidayInfo } +// Road Trip found stop (real POI) +export interface FoundStop { + name: string + lat: number + lng: number + type: 'fuel' | 'rest' + distance_along_route_meters: number + distance_from_route_meters: number + source: 'osm' | 'google' + brand?: string | null + rating?: number | null + opening_hours?: string | null + osm_id?: string | null + place_id?: string | null +} + +// Road Trip direction step +export interface RouteDirection { + instruction: string + distance_meters: number + duration_seconds: number + maneuver: string + road_name: string +} + +// Road Trip route leg +export interface RouteLeg { + id: number + trip_id: string + day_index: number + from_place_id: string + to_place_id: string + from_place_name?: string + to_place_name?: string + from_lat?: number + from_lng?: number + to_lat?: number + to_lng?: number + is_road_trip: boolean + route_geometry: string | null + distance_meters: number | null + duration_seconds: number | null + fuel_cost: number | null + route_metadata: string | null + route_profile: string + calculated_at: string | null +} + // API error shape from axios export interface ApiError { response?: { diff --git a/client/src/utils/directionFormatters.ts b/client/src/utils/directionFormatters.ts new file mode 100644 index 00000000..5185f798 --- /dev/null +++ b/client/src/utils/directionFormatters.ts @@ -0,0 +1,25 @@ +import type { RouteDirection } from '../types' + +export function parseDirections(routeMetadata: string | null): RouteDirection[] { + if (!routeMetadata) return [] + try { + const meta = JSON.parse(routeMetadata) + return meta.directions || [] + } catch { + return [] + } +} + +export function dirSymbol(maneuver: string, instruction: string): string { + if (maneuver === 'depart') return '→' + if (maneuver === 'arrive') return '●' + if (maneuver === 'roundabout' || maneuver === 'rotary') return '↻' + if (maneuver === 'merge') return '⇢' + if (maneuver === 'on ramp') return '↗' + if (maneuver === 'off ramp') return '↳' + if (maneuver === 'new name') return '↑' + if (maneuver === 'fork') return instruction.includes('left') ? '⤵' : '⤴' + if (instruction.includes('left')) return '↰' + if (instruction.includes('right')) return '↱' + return '↑' +} diff --git a/client/src/utils/roadtripFormatters.ts b/client/src/utils/roadtripFormatters.ts new file mode 100644 index 00000000..a40ad32b --- /dev/null +++ b/client/src/utils/roadtripFormatters.ts @@ -0,0 +1,127 @@ +import { calculateSunriseSunset } from './solarCalculation' + +export function formatDistance(meters: number, unitSystem: string): string { + if (unitSystem === 'imperial') { + const miles = meters / 1609.344 + return miles < 0.1 ? `${Math.round(meters * 3.28084)} ft` : `${miles.toFixed(1)} mi` + } + return meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(1)} km` +} + +export function formatDuration(seconds: number): string { + if (seconds < 60) return `${Math.round(seconds)}s` + const d = Math.floor(seconds / 86400) + const h = Math.floor((seconds % 86400) / 3600) + const m = Math.round((seconds % 3600) / 60) + if (d > 0) return `${d}d ${h}h ${m}m` + return h > 0 ? `${h}h ${m}m` : `${m} min` +} + +export function calculateVehicleRange(tankSize: number, fuelConsumption: number, unitSystem: 'metric' | 'imperial'): number { + if (unitSystem === 'imperial') { + // Imperial: range_miles = tank_size_gallons * mpg + return tankSize * fuelConsumption + } + // Metric: range_km = (tank_size_litres / consumption_l_per_100km) * 100 + return (tankSize / fuelConsumption) * 100 +} + +export interface DaylightDrivingResult { + sunrise: Date + sunset: Date + recommendedDeparture: Date + latestArrival: Date + latestDepartureForArrival: Date + availableHours: number + hasSufficientDaylight: boolean +} + +export function calculateDaylightDriving( + originLat: number, originLng: number, + destLat: number, destLng: number, + date: Date, + totalDrivingSeconds: number, + totalRestSeconds: number, + safetyMarginMinutes: number = 30 +): DaylightDrivingResult { + const originSolar = calculateSunriseSunset(originLat, originLng, date) + const destSolar = calculateSunriseSunset(destLat, destLng, date) + + const marginMs = safetyMarginMinutes * 60000 + const recommendedDeparture = new Date(originSolar.sunrise.getTime() + marginMs) + const latestArrival = new Date(destSolar.sunset.getTime() - marginMs) + const totalTravelMs = (totalDrivingSeconds + totalRestSeconds) * 1000 + const latestDepartureForArrival = new Date(latestArrival.getTime() - totalTravelMs) + const availableHours = (latestArrival.getTime() - recommendedDeparture.getTime()) / 3600000 + const totalTravelHours = (totalDrivingSeconds + totalRestSeconds) / 3600 + + return { + sunrise: originSolar.sunrise, + sunset: destSolar.sunset, + recommendedDeparture, + latestArrival, + latestDepartureForArrival, + availableHours: Math.max(0, availableHours), + hasSufficientDaylight: totalTravelHours <= availableHours, + } +} + +export interface DaylightBookingCheck { + originSafe: boolean | null // null = no booking + destSafe: boolean | null // null = no booking + allSafe: boolean // true only if both exist and are safe + originCheckout: string | null + destCheckin: string | null +} + +/** Check if existing booking times are within the safe daylight window */ +export function checkDaylightBookings( + daylight: DaylightDrivingResult, + originCheckout: string | null | undefined, + destCheckin: string | null | undefined, +): DaylightBookingCheck { + const parseTime = (t: string | null | undefined): Date | null => { + if (!t) return null + const match = t.match(/^(\d{1,2}):(\d{2})/) + if (!match) return null + const d = new Date(daylight.recommendedDeparture) + d.setHours(parseInt(match[1], 10), parseInt(match[2], 10), 0, 0) + return d + } + + const checkoutTime = parseTime(originCheckout) + const checkinTime = parseTime(destCheckin) + + // Check-out should be AT or AFTER recommended departure (sunrise + margin) + const originSafe = checkoutTime + ? checkoutTime.getTime() >= daylight.recommendedDeparture.getTime() + : null + + // Check-in should be AT or BEFORE latest arrival (sunset - margin) + const destSafe = checkinTime + ? checkinTime.getTime() <= daylight.latestArrival.getTime() + : null + + const allSafe = originSafe === true && destSafe === true + + return { + originSafe, + destSafe, + allSafe, + originCheckout: originCheckout || null, + destCheckin: destCheckin || null, + } +} + +export function formatFuelCost(cost: number, currency: string): string { + try { + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency: currency || 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(cost) + } catch { /* expected for unsupported currency codes — fall back to plain format */ + return `${cost.toFixed(2)} ${currency || 'USD'}` + } +} diff --git a/client/src/utils/solarCalculation.ts b/client/src/utils/solarCalculation.ts new file mode 100644 index 00000000..77a687f2 --- /dev/null +++ b/client/src/utils/solarCalculation.ts @@ -0,0 +1,81 @@ +/** + * Simple sunrise/sunset calculator using the standard solar position algorithm. + * Accuracy: ±15 minutes, sufficient for driving time estimation. + */ + +interface SolarResult { + sunrise: Date + sunset: Date + daylightHours: number +} + +export function calculateSunriseSunset(lat: number, lng: number, date: Date): SolarResult { + const rad = Math.PI / 180 + const deg = 180 / Math.PI + + // Day of year + const start = new Date(date.getFullYear(), 0, 0) + const diff = date.getTime() - start.getTime() + const dayOfYear = Math.floor(diff / 86400000) + + // Solar declination (simplified) + const declination = -23.45 * Math.cos(rad * (360 / 365) * (dayOfYear + 10)) + + // Hour angle for sunrise/sunset + const latRad = lat * rad + const declRad = declination * rad + const cosHourAngle = (-Math.sin(-0.833 * rad) - Math.sin(latRad) * Math.sin(declRad)) / + (Math.cos(latRad) * Math.cos(declRad)) + + // Handle polar regions + if (cosHourAngle > 1) { + // Sun never rises (polar night) + const noon = new Date(date) + noon.setHours(12, 0, 0, 0) + return { sunrise: noon, sunset: noon, daylightHours: 0 } + } + if (cosHourAngle < -1) { + // Sun never sets (midnight sun) + const dayStart = new Date(date) + dayStart.setHours(0, 0, 0, 0) + const dayEnd = new Date(date) + dayEnd.setHours(23, 59, 59, 0) + return { sunrise: dayStart, sunset: dayEnd, daylightHours: 24 } + } + + const hourAngle = Math.acos(cosHourAngle) * deg + + // Equation of time (minutes) — simplified + const B = (360 / 365) * (dayOfYear - 81) * rad + const eot = 9.87 * Math.sin(2 * B) - 7.53 * Math.cos(B) - 1.5 * Math.sin(B) + + // Solar noon in minutes from midnight UTC + const solarNoonMinutes = 720 - 4 * lng - eot + + // Sunrise and sunset in minutes from midnight UTC + const sunriseMinutes = solarNoonMinutes - 4 * hourAngle + const sunsetMinutes = solarNoonMinutes + 4 * hourAngle + + // Convert to local Date objects (using the date's timezone offset) + const baseDate = new Date(date) + baseDate.setHours(0, 0, 0, 0) + const tzOffset = baseDate.getTimezoneOffset() // minutes behind UTC + + const sunrise = new Date(baseDate.getTime() + (sunriseMinutes + tzOffset) * 60000) + const sunset = new Date(baseDate.getTime() + (sunsetMinutes + tzOffset) * 60000) + const daylightHours = (sunsetMinutes - sunriseMinutes) / 60 + + return { sunrise, sunset, daylightHours: Math.max(0, daylightHours) } +} + +/** Format sunrise/sunset time for display */ +export function formatSolarTime(date: Date, timeFormat: string): string { + if (timeFormat === '12h') { + let h = date.getHours() + const m = date.getMinutes() + const period = h >= 12 ? 'PM' : 'AM' + h = h === 0 ? 12 : h > 12 ? h - 12 : h + return `${h}:${String(m).padStart(2, '0')} ${period}` + } + return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}` +} diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index c6a5d231..8d57fa20 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -353,6 +353,27 @@ function createTables(db: Database.Database): void { CREATE INDEX IF NOT EXISTS idx_collab_notes_trip ON collab_notes(trip_id); CREATE INDEX IF NOT EXISTS idx_collab_polls_trip ON collab_polls(trip_id); CREATE INDEX IF NOT EXISTS idx_collab_messages_trip ON collab_messages(trip_id); + + -- Road Trip addon tables + CREATE TABLE IF NOT EXISTS trip_route_legs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + day_index INTEGER NOT NULL, + from_place_id INTEGER NOT NULL, + to_place_id INTEGER NOT NULL, + is_road_trip INTEGER DEFAULT 0, + route_geometry TEXT, + distance_meters REAL, + duration_seconds REAL, + fuel_cost REAL, + route_metadata TEXT, + route_profile TEXT DEFAULT 'driving', + calculated_at TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + UNIQUE(trip_id, day_index, from_place_id, to_place_id) + ); + CREATE INDEX IF NOT EXISTS idx_trip_route_legs_trip ON trip_route_legs(trip_id); `); db.exec(` diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts index 9c51ffcb..0c4b1040 100644 --- a/server/src/db/seeds.ts +++ b/server/src/db/seeds.ts @@ -35,6 +35,7 @@ function seedAddons(db: Database.Database): void { { id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 }, { id: 'mcp', name: 'MCP', description: 'Model Context Protocol for AI assistant integration', type: 'integration', icon: 'Terminal', enabled: 0, sort_order: 12 }, { id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 }, + { id: 'roadtrip', name: 'Road Trip', description: 'Road routing on maps, fuel cost tracking, range estimation, and driving time planning for vehicle-based travel', type: 'trip', icon: 'Car', enabled: 0, sort_order: 7 }, ]; const insertAddon = db.prepare('INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)'); for (const a of defaultAddons) insertAddon.run(a.id, a.name, a.description, a.type, a.icon, a.enabled, a.sort_order); diff --git a/server/src/index.ts b/server/src/index.ts index db4bba7f..907bc658 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -60,7 +60,7 @@ app.use(helmet({ scriptSrc: ["'self'", "'unsafe-inline'"], styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://unpkg.com"], imgSrc: ["'self'", "data:", "blob:", "https:", "http:"], - connectSrc: ["'self'", "ws:", "wss:", "https:", "http:"], + connectSrc: ["'self'", "ws:", "wss:", "https:", "http:", "https://router.project-osrm.org", "https://overpass-api.de", "https://raw.githubusercontent.com"], fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"], objectSrc: ["'self'"], frameSrc: ["'self'"], @@ -167,6 +167,8 @@ app.use('/api/trips/:tripId/budget', budgetRoutes); app.use('/api/trips/:tripId/collab', collabRoutes); app.use('/api/trips/:tripId/reservations', reservationsRoutes); app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes); +import roadtripRoutes from './routes/roadtrip'; +app.use('/api/trips/:tripId/route-legs', roadtripRoutes); app.get('/api/health', (req: Request, res: Response) => res.json({ status: 'ok' })); app.use('/api', assignmentsRoutes); app.use('/api/tags', tagsRoutes); diff --git a/server/src/routes/atlas.ts b/server/src/routes/atlas.ts index a34c0faa..e9125d90 100644 --- a/server/src/routes/atlas.ts +++ b/server/src/routes/atlas.ts @@ -1,7 +1,7 @@ import express, { Request, Response } from 'express'; import { db } from '../db/database'; import { authenticate } from '../middleware/auth'; -import { AuthRequest, Trip, Place } from '../types'; +import { AuthRequest, Trip, Place, Addon } from '../types'; const router = express.Router(); router.use(authenticate); @@ -318,4 +318,90 @@ router.delete('/bucket-list/:id', (req: Request, res: Response) => { res.json({ success: true }); }); +// ── Road Trips Layer ──────────────────────────────────────────────────────── + +router.get('/road-trips', (req: Request, res: Response) => { + // Require both Atlas and Road Trip addons to be enabled + const rtAddon = db.prepare('SELECT enabled FROM addons WHERE id = ?').get('roadtrip') as Pick | undefined; + if (!rtAddon?.enabled) return res.status(403).json({ error: 'Road Trip addon is not enabled' }); + + const authReq = req as AuthRequest; + const userId = authReq.user.id; + + // Get all trips the user owns or has member access to + const trips = db.prepare(` + SELECT DISTINCT t.id, t.title, t.start_date, t.end_date + FROM trips t + LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? + WHERE t.user_id = ? OR m.user_id = ? + ORDER BY t.start_date DESC + `).all(userId, userId, userId) as { id: number; title: string; start_date: string | null; end_date: string | null }[]; + + const tripIds = trips.map(t => t.id); + if (tripIds.length === 0) { + return res.json({ trips: [], totals: { total_trips: 0, total_distance_meters: 0, total_fuel_cost: 0, total_driving_seconds: 0 } }); + } + + const placeholders = tripIds.map(() => '?').join(','); + const legs = db.prepare(` + SELECT rl.trip_id, rl.day_index, rl.route_geometry, rl.distance_meters, rl.duration_seconds, rl.fuel_cost, + pf.name as from_name, pt.name as to_name + FROM trip_route_legs rl + LEFT JOIN places pf ON pf.id = rl.from_place_id AND pf.trip_id = rl.trip_id + LEFT JOIN places pt ON pt.id = rl.to_place_id AND pt.trip_id = rl.trip_id + WHERE rl.trip_id IN (${placeholders}) AND rl.is_road_trip = 1 AND rl.route_geometry IS NOT NULL + ORDER BY rl.trip_id, rl.day_index + `).all(...tripIds) as { trip_id: number; day_index: number; route_geometry: string; distance_meters: number | null; duration_seconds: number | null; fuel_cost: number | null; from_name: string | null; to_name: string | null }[]; + + // Group legs by trip + const legsByTrip = new Map(); + for (const leg of legs) { + if (!legsByTrip.has(leg.trip_id)) legsByTrip.set(leg.trip_id, []); + legsByTrip.get(leg.trip_id)!.push(leg); + } + + let grandTotalDistance = 0; + let grandTotalDuration = 0; + let grandTotalFuel = 0; + + const tripResults = trips + .filter(t => legsByTrip.has(t.id)) + .map(t => { + const tripLegs = legsByTrip.get(t.id)!; + const totalDistance = tripLegs.reduce((s, l) => s + (l.distance_meters || 0), 0); + const totalDuration = tripLegs.reduce((s, l) => s + (l.duration_seconds || 0), 0); + const totalFuel = tripLegs.reduce((s, l) => s + (l.fuel_cost || 0), 0); + grandTotalDistance += totalDistance; + grandTotalDuration += totalDuration; + grandTotalFuel += totalFuel; + return { + trip_id: t.id, + trip_title: t.title, + start_date: t.start_date, + end_date: t.end_date, + total_distance_meters: totalDistance, + total_duration_seconds: totalDuration, + total_fuel_cost: totalFuel, + legs: tripLegs.map(l => ({ + route_geometry: l.route_geometry, + distance_meters: l.distance_meters, + duration_seconds: l.duration_seconds, + from_name: l.from_name, + to_name: l.to_name, + day_index: l.day_index, + })), + }; + }); + + res.json({ + trips: tripResults, + totals: { + total_trips: tripResults.length, + total_distance_meters: grandTotalDistance, + total_fuel_cost: grandTotalFuel, + total_driving_seconds: grandTotalDuration, + }, + }); +}); + export default router; diff --git a/server/src/routes/roadtrip.ts b/server/src/routes/roadtrip.ts new file mode 100644 index 00000000..aad0d797 --- /dev/null +++ b/server/src/routes/roadtrip.ts @@ -0,0 +1,329 @@ +import express, { Request, Response } from 'express'; +import { db, canAccessTrip } from '../db/database'; +import { authenticate } from '../middleware/auth'; +import { AuthRequest } from '../types'; +import { + fetchOsrmRoute, extractDirections, calculateSpeedCappedDuration, + isValidLatitude, isValidLongitude, +} from '../services/routingService'; +import { + isRoadtripEnabled, getUserSetting, getMaxSpeedMs, + calculateFuelCost, syncFuelBudget, recalculateLegs, +} from '../services/fuelService'; +import { + findStopsForLeg, deduplicateAndFilterStops, checkDebounce, + isValidStopType, + type SearchPoint, +} from '../services/stopSearchService'; + +const router = express.Router({ mergeParams: true }); + +function requireAddon(_req: Request, res: Response, next: () => void): void { + if (!isRoadtripEnabled()) { + res.status(403).json({ error: 'Road Trip addon is not enabled' }); + return; + } + next(); +} + +function verifyTripAccess(tripId: string | number, userId: number) { + return canAccessTrip(tripId, userId); +} + +function parseIntParam(value: string): number | null { + const n = parseInt(value, 10); + return isNaN(n) ? null : n; +} + +function validateTripId(tripId: string | undefined): boolean { + return typeof tripId === 'string' && tripId.length > 0; +} + +// GET /api/trips/:tripId/route-legs +router.get('/', authenticate, requireAddon, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + if (!validateTripId(tripId)) return res.status(400).json({ error: 'Invalid trip ID' }); + + const trip = verifyTripAccess(tripId, authReq.user.id); + if (!trip) return res.status(404).json({ error: 'Trip not found' }); + + const legs = db.prepare(` + SELECT rl.*, + pf.name as from_place_name, pf.lat as from_lat, pf.lng as from_lng, + pt.name as to_place_name, pt.lat as to_lat, pt.lng as to_lng + FROM trip_route_legs rl + LEFT JOIN places pf ON rl.from_place_id = pf.id + LEFT JOIN places pt ON rl.to_place_id = pt.id + WHERE rl.trip_id = ? + ORDER BY rl.day_index, rl.id + `).all(tripId); + + res.json({ legs }); +}); + +// PUT /api/trips/:tripId/route-legs/:legId +router.put('/:legId', authenticate, requireAddon, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + if (!validateTripId(tripId)) return res.status(400).json({ error: 'Invalid trip ID' }); + + const legId = parseIntParam(req.params.legId); + if (legId === null) return res.status(400).json({ error: 'Invalid leg ID' }); + + const { is_road_trip, route_profile } = req.body; + + const trip = verifyTripAccess(tripId, authReq.user.id); + if (!trip) return res.status(404).json({ error: 'Trip not found' }); + + const leg = db.prepare('SELECT id FROM trip_route_legs WHERE id = ? AND trip_id = ?').get(legId, tripId); + if (!leg) return res.status(404).json({ error: 'Route leg not found' }); + + const updates: string[] = []; + const params: unknown[] = []; + + if (is_road_trip !== undefined) { + updates.push('is_road_trip = ?'); + params.push(is_road_trip ? 1 : 0); + } + if (route_profile !== undefined) { + updates.push('route_profile = ?'); + params.push(route_profile); + } + + if (updates.length === 0) return res.status(400).json({ error: 'No fields to update' }); + + updates.push("updated_at = datetime('now')"); + params.push(legId, tripId); + + db.prepare(`UPDATE trip_route_legs SET ${updates.join(', ')} WHERE id = ? AND trip_id = ?`).run(...params); + + const updated = db.prepare('SELECT * FROM trip_route_legs WHERE id = ?').get(legId); + res.json({ leg: updated }); +}); + +// POST /api/trips/:tripId/route-legs/calculate +router.post('/calculate', authenticate, requireAddon, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + if (!validateTripId(tripId)) return res.status(400).json({ error: 'Invalid trip ID' }); + + const { day_index, from_place_id, to_place_id } = req.body; + + const trip = verifyTripAccess(tripId, authReq.user.id); + if (!trip) return res.status(404).json({ error: 'Trip not found' }); + + if (day_index === undefined || !from_place_id || !to_place_id) { + return res.status(400).json({ error: 'day_index, from_place_id, and to_place_id are required' }); + } + + const fromPlace = db.prepare('SELECT id, name, lat, lng FROM places WHERE id = ? AND trip_id = ?').get(from_place_id, tripId) as { id: number; name: string; lat: number | null; lng: number | null } | undefined; + const toPlace = db.prepare('SELECT id, name, lat, lng FROM places WHERE id = ? AND trip_id = ?').get(to_place_id, tripId) as { id: number; name: string; lat: number | null; lng: number | null } | undefined; + + if (!fromPlace || !toPlace) return res.status(404).json({ error: 'One or both places not found in this trip' }); + if (!fromPlace.lat || !fromPlace.lng || !toPlace.lat || !toPlace.lng) { + return res.status(400).json({ error: 'Both places must have coordinates' }); + } + + if (!isValidLatitude(fromPlace.lat) || !isValidLongitude(fromPlace.lng) || + !isValidLatitude(toPlace.lat) || !isValidLongitude(toPlace.lng)) { + return res.status(400).json({ error: 'Invalid coordinates' }); + } + + try { + const data = await fetchOsrmRoute(fromPlace.lng, fromPlace.lat, toPlace.lng, toPlace.lat); + + if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) { + return res.status(422).json({ error: 'No route found between these places' }); + } + + const route = data.routes[0]; + const geometry = route.geometry; + const distance_meters = route.distance; + const osrm_duration_seconds = route.duration; + + const leg0 = route.legs?.[0]; + const annotationSpeed: number[] = leg0?.annotation?.speed || []; + const annotationDistance: number[] = leg0?.annotation?.distance || []; + + const maxSpeedMs = getMaxSpeedMs(authReq.user.id); + let duration_seconds = osrm_duration_seconds; + let speedCapped = false; + + if (maxSpeedMs && annotationSpeed.length > 0 && annotationDistance.length > 0) { + duration_seconds = calculateSpeedCappedDuration( + { speed: annotationSpeed, distance: annotationDistance }, + maxSpeedMs + ); + speedCapped = true; + } + + const steps = leg0?.steps || []; + const directions = extractDirections(steps); + + const route_metadata = JSON.stringify({ + annotations: { speed: annotationSpeed, distance: annotationDistance }, + osrm_duration_seconds, + speed_capped: speedCapped, + max_speed_ms: maxSpeedMs || null, + directions, + direction_count: directions.length, + }); + + const fuel_cost = calculateFuelCost(distance_meters, authReq.user.id, tripId); + + db.prepare(` + INSERT INTO trip_route_legs (trip_id, day_index, from_place_id, to_place_id, is_road_trip, route_geometry, distance_meters, duration_seconds, fuel_cost, route_metadata, route_profile, calculated_at) + VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(trip_id, day_index, from_place_id, to_place_id) DO UPDATE SET + route_geometry = excluded.route_geometry, + distance_meters = excluded.distance_meters, + duration_seconds = excluded.duration_seconds, + fuel_cost = excluded.fuel_cost, + route_metadata = excluded.route_metadata, + route_profile = excluded.route_profile, + calculated_at = excluded.calculated_at, + is_road_trip = 1, + updated_at = datetime('now') + `).run(tripId, day_index, from_place_id, to_place_id, geometry, distance_meters, duration_seconds, fuel_cost, route_metadata, 'driving'); + + const leg = db.prepare(` + SELECT rl.*, + pf.name as from_place_name, pf.lat as from_lat, pf.lng as from_lng, + pt.name as to_place_name, pt.lat as to_lat, pt.lng as to_lng + FROM trip_route_legs rl + LEFT JOIN places pf ON rl.from_place_id = pf.id + LEFT JOIN places pt ON rl.to_place_id = pt.id + WHERE rl.trip_id = ? AND rl.day_index = ? AND rl.from_place_id = ? AND rl.to_place_id = ? + `).get(tripId, day_index, from_place_id, to_place_id); + + syncFuelBudget(tripId, authReq.user.id); + res.json({ leg }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + if (message.includes('timed out')) { + return res.status(504).json({ error: 'OSRM request timed out' }); + } + console.error('OSRM calculation error:', message); + res.status(502).json({ error: 'Failed to calculate route' }); + } +}); + +// POST /api/trips/:tripId/route-legs/recalculate +router.post('/recalculate', authenticate, requireAddon, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + if (!validateTripId(tripId)) return res.status(400).json({ error: 'Invalid trip ID' }); + + const trip = verifyTripAccess(tripId, authReq.user.id); + if (!trip) return res.status(404).json({ error: 'Trip not found' }); + + recalculateLegs(tripId, authReq.user.id); + + const updated = db.prepare(` + SELECT rl.*, + pf.name as from_place_name, pf.lat as from_lat, pf.lng as from_lng, + pt.name as to_place_name, pt.lat as to_lat, pt.lng as to_lng + FROM trip_route_legs rl + LEFT JOIN places pf ON rl.from_place_id = pf.id + LEFT JOIN places pt ON rl.to_place_id = pt.id + WHERE rl.trip_id = ? + ORDER BY rl.day_index, rl.id + `).all(tripId); + + syncFuelBudget(tripId, authReq.user.id); + res.json({ legs: updated }); +}); + +// POST /api/trips/:tripId/route-legs/:legId/find-stops +router.post('/:legId/find-stops', authenticate, requireAddon, async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + if (!validateTripId(tripId)) return res.status(400).json({ error: 'Invalid trip ID' }); + + const legId = parseIntParam(req.params.legId); + if (legId === null) return res.status(400).json({ error: 'Invalid leg ID' }); + + const { stop_type, search_points, corridor } = req.body as { stop_type: string; search_points: SearchPoint[]; corridor?: boolean }; + + if (!stop_type || !isValidStopType(stop_type)) { + return res.status(400).json({ error: "stop_type must be one of 'fuel', 'rest', 'both'" }); + } + + if (!search_points || !Array.isArray(search_points) || search_points.length === 0) { + return res.status(400).json({ error: 'search_points are required' }); + } + + for (const sp of search_points) { + if (!isValidLatitude(sp.lat) || !isValidLongitude(sp.lng)) { + return res.status(400).json({ error: 'Invalid coordinates in search_points' }); + } + if (typeof sp.distance_along_route_meters !== 'number' || isNaN(sp.distance_along_route_meters)) { + return res.status(400).json({ error: 'Each search point must have a valid distance_along_route_meters' }); + } + } + + console.log(`[find-stops] called for leg ${legId}, type: ${stop_type}, points: ${search_points.length}, corridor: ${!!corridor}`); + + const debounceKey = `${tripId}-${legId}-${stop_type}`; + if (!checkDebounce(debounceKey)) { + return res.status(429).json({ error: 'Please wait a few seconds before searching again' }); + } + + const trip = verifyTripAccess(tripId, authReq.user.id); + if (!trip) return res.status(404).json({ error: 'Trip not found' }); + + const leg = db.prepare('SELECT id, route_metadata, route_geometry FROM trip_route_legs WHERE id = ? AND trip_id = ?').get(legId, tripId) as { id: number; route_metadata: string | null; route_geometry: string | null } | undefined; + if (!leg) return res.status(404).json({ error: 'Route leg not found' }); + + const fuelBrand = getUserSetting(authReq.user.id, 'roadtrip_fuel_brand') || 'any'; + + let allFoundStops = await findStopsForLeg({ + tripId, legId, userId: authReq.user.id, + stopType: stop_type, searchPoints: search_points, corridor, + }); + + allFoundStops = deduplicateAndFilterStops(allFoundStops, leg.route_geometry, fuelBrand); + + console.log(`[find-stops] total results: ${allFoundStops.length}`); + + const validType = isValidStopType(stop_type) ? stop_type : 'both'; + try { + const meta = leg.route_metadata ? JSON.parse(leg.route_metadata) : {}; + const existing: { type: string }[] = Array.isArray(meta.found_stops) ? meta.found_stops : []; + if (validType === 'both') { + meta.found_stops = allFoundStops; + } else { + const otherStops = existing.filter(s => s.type !== validType); + meta.found_stops = [...otherStops, ...allFoundStops]; + } + db.prepare("UPDATE trip_route_legs SET route_metadata = ?, updated_at = datetime('now') WHERE id = ?") + .run(JSON.stringify(meta), legId); + } catch (err) { + console.error('[find-stops] Error storing route_metadata:', err); + } + + res.json({ stops: allFoundStops }); +}); + +// DELETE /api/trips/:tripId/route-legs/:legId +router.delete('/:legId', authenticate, requireAddon, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + if (!validateTripId(tripId)) return res.status(400).json({ error: 'Invalid trip ID' }); + + const legId = parseIntParam(req.params.legId); + if (legId === null) return res.status(400).json({ error: 'Invalid leg ID' }); + + const trip = verifyTripAccess(tripId, authReq.user.id); + if (!trip) return res.status(404).json({ error: 'Trip not found' }); + + const leg = db.prepare('SELECT id FROM trip_route_legs WHERE id = ? AND trip_id = ?').get(legId, tripId); + if (!leg) return res.status(404).json({ error: 'Route leg not found' }); + + db.prepare('DELETE FROM trip_route_legs WHERE id = ?').run(legId); + syncFuelBudget(tripId, authReq.user.id); + res.json({ success: true }); +}); + +export default router; diff --git a/server/src/services/fuelService.ts b/server/src/services/fuelService.ts new file mode 100644 index 00000000..b0602668 --- /dev/null +++ b/server/src/services/fuelService.ts @@ -0,0 +1,124 @@ +import { db } from '../db/database'; +import { Addon } from '../types'; +import { calculateSpeedCappedDuration } from './routingService'; + +const AUTO_FUEL_MARKER = '[auto-fuel]'; +const SAFETY_MARGIN_MINUTES = 30; + +export { SAFETY_MARGIN_MINUTES }; + +export function isRoadtripEnabled(): boolean { + const addon = db.prepare('SELECT enabled FROM addons WHERE id = ?').get('roadtrip') as Pick | undefined; + return !!addon?.enabled; +} + +export function getUserSetting(userId: number, key: string): string | null { + const row = db.prepare('SELECT value FROM settings WHERE user_id = ? AND key = ?').get(userId, key) as { value: string } | undefined; + return row?.value || null; +} + +export function getMaxSpeedMs(userId: number): number | null { + const maxSpeed = getUserSetting(userId, 'roadtrip_max_speed'); + if (!maxSpeed) return null; + const speed = parseFloat(maxSpeed); + if (!speed || speed <= 0) return null; + const unitSystem = getUserSetting(userId, 'roadtrip_unit_system') || 'metric'; + return unitSystem === 'imperial' ? speed * 0.44704 : speed / 3.6; +} + +export function calculateFuelCost(distanceMeters: number, userId: number, tripId?: string | number): number | null { + const unitSystem = getUserSetting(userId, 'roadtrip_unit_system') || 'metric'; + const fuelConsumption = getUserSetting(userId, 'roadtrip_fuel_consumption'); + + let fuelPrice: string | null = null; + if (tripId) { + const trip = db.prepare('SELECT roadtrip_fuel_price FROM trips WHERE id = ?').get(tripId) as { roadtrip_fuel_price: string | null } | undefined; + if (trip?.roadtrip_fuel_price) fuelPrice = trip.roadtrip_fuel_price; + } + if (!fuelPrice) fuelPrice = getUserSetting(userId, 'roadtrip_fuel_price'); + + if (!fuelPrice || !fuelConsumption) return null; + const price = parseFloat(fuelPrice); + const consumption = parseFloat(fuelConsumption); + if (!price || !consumption) return null; + + if (unitSystem === 'imperial') { + const distanceMiles = distanceMeters / 1609.344; + return Math.round((distanceMiles / consumption) * price * 100) / 100; + } + const distanceKm = distanceMeters / 1000; + return Math.round((distanceKm / 100) * consumption * price * 100) / 100; +} + +export function syncFuelBudget(tripId: string | number, userId: number): void { + const dismissed = getUserSetting(userId, `roadtrip_fuel_budget_dismissed_${tripId}`); + if (dismissed === 'true') return; + + const row = db.prepare( + 'SELECT COALESCE(SUM(fuel_cost), 0) as total FROM trip_route_legs WHERE trip_id = ? AND is_road_trip = 1 AND fuel_cost IS NOT NULL' + ).get(tripId) as { total: number }; + const totalFuel = Math.round(row.total * 100) / 100; + + const existing = db.prepare( + 'SELECT id FROM budget_items WHERE trip_id = ? AND note LIKE ?' + ).get(tripId, `%${AUTO_FUEL_MARKER}%`) as { id: number } | undefined; + + if (totalFuel > 0) { + const tripRow = db.prepare('SELECT roadtrip_fuel_currency FROM trips WHERE id = ?').get(tripId) as { roadtrip_fuel_currency: string | null } | undefined; + const currency = tripRow?.roadtrip_fuel_currency || getUserSetting(userId, 'roadtrip_fuel_currency') || 'USD'; + const name = `Road Trip Fuel (${currency})`; + if (existing) { + db.prepare("UPDATE budget_items SET total_price = ?, name = ? WHERE id = ?").run(totalFuel, name, existing.id); + } else { + const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?').get(tripId) as { max: number | null }; + const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1; + db.prepare( + 'INSERT INTO budget_items (trip_id, category, name, total_price, note, sort_order) VALUES (?, ?, ?, ?, ?, ?)' + ).run(tripId, 'Transport', name, totalFuel, AUTO_FUEL_MARKER, sortOrder); + } + } else if (existing) { + db.prepare('DELETE FROM budget_items WHERE id = ?').run(existing.id); + } +} + +export function recalculateLegs(tripId: string | number, userId: number): void { + const legs = db.prepare('SELECT id, distance_meters, route_metadata FROM trip_route_legs WHERE trip_id = ? AND distance_meters IS NOT NULL').all(tripId) as { id: number; distance_meters: number; route_metadata: string | null }[]; + + const maxSpeedMs = getMaxSpeedMs(userId); + const updateFuelOnly = db.prepare("UPDATE trip_route_legs SET fuel_cost = ?, updated_at = datetime('now') WHERE id = ?"); + const updateFuelAndDuration = db.prepare("UPDATE trip_route_legs SET fuel_cost = ?, duration_seconds = ?, route_metadata = ?, updated_at = datetime('now') WHERE id = ?"); + + const transaction = db.transaction(() => { + for (const leg of legs) { + const cost = calculateFuelCost(leg.distance_meters, userId, tripId); + + if (leg.route_metadata) { + try { + const meta = JSON.parse(leg.route_metadata); + const annotations = meta.annotations; + if (annotations?.speed?.length > 0 && annotations?.distance?.length > 0) { + let newDuration: number; + let speedCapped = false; + if (maxSpeedMs) { + newDuration = calculateSpeedCappedDuration(annotations, maxSpeedMs); + speedCapped = true; + } else { + newDuration = meta.osrm_duration_seconds; + } + const updatedMeta = JSON.stringify({ + ...meta, + speed_capped: speedCapped, + max_speed_ms: maxSpeedMs || null, + }); + updateFuelAndDuration.run(cost, newDuration, updatedMeta, leg.id); + continue; + } + } catch (err) { + console.error(`Failed to parse route_metadata for leg ${leg.id}:`, err); + } + } + updateFuelOnly.run(cost, leg.id); + } + }); + transaction(); +} diff --git a/server/src/services/routingService.ts b/server/src/services/routingService.ts new file mode 100644 index 00000000..23bcce62 --- /dev/null +++ b/server/src/services/routingService.ts @@ -0,0 +1,263 @@ +import https from 'https'; +import fetch from 'node-fetch'; + +// Force IPv4 to avoid ETIMEDOUT on Docker bridge networks where IPv6 cannot route +export const ipv4Agent = new https.Agent({ family: 4 }); + +export const OSRM_API_URL = (process.env.OSRM_API_URL || 'https://router.project-osrm.org').replace(/\/+$/, ''); + +// Timeouts +export const OSRM_TIMEOUT_MS = 15000; + +// Speed cap constants +const SPEED_CAP_MIN_THRESHOLD = 0.1; // m/s — below this, treat as near-zero speed + +// Rate limiter +let lastOsrmRequest = 0; +const OSRM_MIN_INTERVAL_MS = 1000; + +export interface RouteDirection { + instruction: string; + distance_meters: number; + duration_seconds: number; + maneuver: string; + road_name: string; +} + +export interface OsrmRouteResult { + geometry: string; + distance_meters: number; + duration_seconds: number; + osrm_duration_seconds: number; + speed_capped: boolean; + max_speed_ms: number | null; + directions: RouteDirection[]; + annotations: { speed: number[]; distance: number[] }; +} + +export interface OsrmRoute { + geometry: string; + distance: number; + duration: number; + legs?: OsrmRouteLeg[]; +} + +export interface OsrmRouteLeg { + annotation?: { + speed?: number[]; + distance?: number[]; + }; + steps?: OsrmStep[]; +} + +export interface OsrmStep { + maneuver?: { type?: string; modifier?: string }; + name?: string; + distance?: number; + duration?: number; +} + +export interface OsrmResponse { + code: string; + routes?: OsrmRoute[]; +} + +export function calculateSpeedCappedDuration( + annotations: { speed: number[]; distance: number[] }, + maxSpeedMs: number +): number { + let totalDuration = 0; + for (let i = 0; i < annotations.speed.length; i++) { + const segSpeed = annotations.speed[i]; + const segDist = annotations.distance[i]; + const effectiveSpeed = (segSpeed < SPEED_CAP_MIN_THRESHOLD) ? segSpeed : Math.min(segSpeed, maxSpeedMs); + if (effectiveSpeed < SPEED_CAP_MIN_THRESHOLD) { + totalDuration += segSpeed > 0 ? segDist / segSpeed : 0; + } else { + totalDuration += segDist / effectiveSpeed; + } + } + return Math.round(totalDuration); +} + +function buildInstruction(maneuverType: string, modifier: string | undefined, roadName: string): string { + const onto = roadName ? ` onto ${roadName}` : ''; + switch (maneuverType) { + case 'depart': + return modifier ? `Head ${modifier}${roadName ? ' on ' + roadName : ''}` : `Head${roadName ? ' on ' + roadName : ''}`; + case 'arrive': + return 'Arrive at destination'; + case 'turn': + if (modifier === 'right' || modifier === 'sharp right' || modifier === 'slight right') return `Turn right${onto}`; + if (modifier === 'left' || modifier === 'sharp left' || modifier === 'slight left') return `Turn left${onto}`; + return `Turn${onto}`; + case 'new name': + return `Continue${onto}`; + case 'merge': + return `Merge${onto}`; + case 'on ramp': + return `Take the on-ramp${onto}`; + case 'off ramp': + return `Take exit${onto}`; + case 'roundabout': + case 'rotary': + return `At the roundabout, take exit${onto}`; + case 'fork': + if (modifier === 'right' || modifier === 'slight right') return `Keep right${onto}`; + if (modifier === 'left' || modifier === 'slight left') return `Keep left${onto}`; + return `Keep${onto}`; + case 'end of road': + if (modifier === 'right' || modifier === 'slight right' || modifier === 'sharp right') return `Turn right${onto}`; + if (modifier === 'left' || modifier === 'slight left' || modifier === 'sharp left') return `Turn left${onto}`; + return `Turn${onto}`; + default: + return `Continue${onto}`; + } +} + +const MAJOR_MANEUVERS = new Set(['turn', 'new name', 'merge', 'on ramp', 'off ramp', 'fork', 'end of road', 'roundabout', 'rotary', 'depart', 'arrive']); +const MIN_DIRECTION_DISTANCE = 500; // meters — skip segments shorter than this + +export function extractDirections(steps: OsrmStep[]): RouteDirection[] { + if (!steps || steps.length === 0) return []; + const directions: RouteDirection[] = []; + let prevName = ''; + for (const step of steps) { + const maneuver = step.maneuver || {}; + const type: string = maneuver.type || ''; + const modifier: string | undefined = maneuver.modifier; + const roadName: string = step.name || ''; + const distance: number = step.distance || 0; + const duration: number = step.duration || 0; + + if (distance <= MIN_DIRECTION_DISTANCE && type !== 'depart' && type !== 'arrive') continue; + + if (type === 'continue' || (modifier === 'straight' && type !== 'new name')) { + if (roadName === prevName || !roadName) { + prevName = roadName || prevName; + continue; + } + } + + if (!MAJOR_MANEUVERS.has(type) && type !== 'continue') { + prevName = roadName || prevName; + continue; + } + + directions.push({ + instruction: buildInstruction(type, modifier, roadName), + distance_meters: distance, + duration_seconds: duration, + maneuver: type, + road_name: roadName, + }); + prevName = roadName || prevName; + } + return directions; +} + +/** Decode Google-encoded polyline into [lat, lng] pairs */ +export function decodePolyline(encoded: string): [number, number][] { + const points: [number, number][] = []; + let index = 0, lat = 0, lng = 0; + while (index < encoded.length) { + let shift = 0, result = 0, byte: number; + do { byte = encoded.charCodeAt(index++) - 63; result |= (byte & 0x1f) << shift; shift += 5; } while (byte >= 0x20); + lat += result & 1 ? ~(result >> 1) : result >> 1; + shift = 0; result = 0; + do { byte = encoded.charCodeAt(index++) - 63; result |= (byte & 0x1f) << shift; shift += 5; } while (byte >= 0x20); + lng += result & 1 ? ~(result >> 1) : result >> 1; + points.push([lat / 1e5, lng / 1e5]); + } + return points; +} + +/** Haversine distance in meters */ +export function haversineDistance(lat1: number, lng1: number, lat2: number, lng2: number): number { + const R = 6371000; + const dLat = (lat2 - lat1) * Math.PI / 180; + const dLng = (lng2 - lng1) * Math.PI / 180; + const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +/** Perpendicular distance from a point to a line segment A->B, in meters */ +function distanceToSegment(pLat: number, pLng: number, aLat: number, aLng: number, bLat: number, bLng: number): number { + const dAB = haversineDistance(aLat, aLng, bLat, bLng); + if (dAB < 1) return haversineDistance(pLat, pLng, aLat, aLng); + + const toRad = Math.PI / 180; + const cosLat = Math.cos(aLat * toRad); + const dx = (bLng - aLng) * toRad * cosLat; + const dy = (bLat - aLat) * toRad; + const px = (pLng - aLng) * toRad * cosLat; + const py = (pLat - aLat) * toRad; + + const dot = px * dx + py * dy; + const lenSq = dx * dx + dy * dy; + const t = lenSq > 0 ? Math.max(0, Math.min(1, dot / lenSq)) : 0; + + const closestLat = aLat + t * (bLat - aLat); + const closestLng = aLng + t * (bLng - aLng); + return haversineDistance(pLat, pLng, closestLat, closestLng); +} + +/** Minimum distance from a point to any segment of a polyline, in meters */ +export function distanceToPolyline(pointLat: number, pointLng: number, polylineCoords: [number, number][]): number { + let minDist = Infinity; + for (let i = 0; i < polylineCoords.length - 1; i++) { + const d = distanceToSegment( + pointLat, pointLng, + polylineCoords[i][0], polylineCoords[i][1], + polylineCoords[i + 1][0], polylineCoords[i + 1][1] + ); + if (d < minDist) minDist = d; + if (d < 10) break; // close enough + } + return minDist; +} + +export async function enforceOsrmRateLimit(): Promise { + const now = Date.now(); + const elapsed = now - lastOsrmRequest; + if (elapsed < OSRM_MIN_INTERVAL_MS) { + await new Promise(resolve => setTimeout(resolve, OSRM_MIN_INTERVAL_MS - elapsed)); + } + lastOsrmRequest = Date.now(); +} + +export async function fetchOsrmRoute( + fromLng: number, fromLat: number, toLng: number, toLat: number +): Promise { + await enforceOsrmRateLimit(); + + const url = `${OSRM_API_URL}/route/v1/driving/${fromLng},${fromLat};${toLng},${toLat}?overview=full&geometries=polyline&steps=true&annotations=speed,distance`; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), OSRM_TIMEOUT_MS); + try { + const response = await fetch(url, { agent: ipv4Agent, signal: controller.signal as never }); + clearTimeout(timeout); + if (!response.ok) { + console.error(`OSRM request failed: ${response.status} ${response.statusText} — URL: ${url}`); + throw new Error(`OSRM request failed: ${response.status}`); + } + const data = await response.json() as OsrmResponse; + return data; + } catch (err: unknown) { + clearTimeout(timeout); + if (err instanceof Error && err.name === 'AbortError') { + console.error(`OSRM request timed out after ${OSRM_TIMEOUT_MS}ms — URL: ${url}`); + throw new Error('OSRM request timed out'); + } + throw err; + } +} + +export function isValidLatitude(lat: number): boolean { + return typeof lat === 'number' && !isNaN(lat) && lat >= -90 && lat <= 90; +} + +export function isValidLongitude(lng: number): boolean { + return typeof lng === 'number' && !isNaN(lng) && lng >= -180 && lng <= 180; +} diff --git a/server/src/services/stopSearchService.ts b/server/src/services/stopSearchService.ts new file mode 100644 index 00000000..2c381907 --- /dev/null +++ b/server/src/services/stopSearchService.ts @@ -0,0 +1,552 @@ +import fetch from 'node-fetch'; +import { ipv4Agent, haversineDistance, distanceToPolyline, decodePolyline } from './routingService'; +import { getUserSetting } from './fuelService'; +import { db } from '../db/database'; + +// Timeouts +const OVERPASS_TIMEOUT_MS = 45000; +const GOOGLE_PLACES_TIMEOUT_MS = 10000; + +// Search constants +const SEARCH_RADII = [15000, 50000, 100000, 200000]; // meters, progressive expansion +const ROUTE_PROXIMITY_THRESHOLD = 2000; // meters, max distance from route polyline +const DEDUP_PROXIMITY = 500; // meters, POI deduplication distance +const CROSS_TYPE_DEDUP_PROXIMITY = 5000; // meters, fuel/rest cross-type dedup +const OVERPASS_QUERY_TIMEOUT = 30; // seconds, in Overpass QL query +const GOOGLE_MAX_RADIUS = 50000; // meters, Google Places API limit +const CORRIDOR_SAMPLE_INTERVAL = 150000; // meters, fuel corridor sampling +const MAX_SEARCH_POINTS = 20; // max points per request + +export { CORRIDOR_SAMPLE_INTERVAL, SEARCH_RADII, ROUTE_PROXIMITY_THRESHOLD }; + +// Rate limiters +let lastOverpassRequest = 0; +let lastGooglePlacesRequest = 0; +const OVERPASS_MIN_INTERVAL_MS = 2000; +const GOOGLE_PLACES_MIN_INTERVAL_MS = 100; + +export interface SearchPoint { lat: number; lng: number; distance_along_route_meters: number } +export interface StopResult { + name: string; lat: number; lng: number; type: 'fuel' | 'rest'; + distance_from_route_meters: number; source: 'osm' | 'google'; + brand: string | null; rating: number | null; opening_hours: string | null; + osm_id: string | null; place_id: string | null; +} + +// Debounce: track last find-stops call per leg to avoid rapid re-calls +const lastFindStopsCall = new Map(); +const FIND_STOPS_DEBOUNCE_MS = 5000; + +export function checkDebounce(debounceKey: string): boolean { + const lastCall = lastFindStopsCall.get(debounceKey) || 0; + if (Date.now() - lastCall < FIND_STOPS_DEBOUNCE_MS) return false; + lastFindStopsCall.set(debounceKey, Date.now()); + return true; +} + +export function isValidStopType(type: string): type is 'fuel' | 'rest' | 'both' { + return type === 'fuel' || type === 'rest' || type === 'both'; +} + +// Brand tag mappings for Overpass +const BRAND_OVERPASS_TAGS: Record = { + 'Mobil': ['Mobil', 'Mobil 1'], + 'Ampol': ['Ampol'], + 'BP': ['BP'], + 'Shell': ['Shell', 'Shell Coles Express'], + '7-Eleven': ['7-Eleven', '7-11'], +}; + +function buildFuelFilters(around: string, fuelType: string, fuelBrand: string): string[] { + const filters: string[] = []; + + const brands = fuelBrand === 'any' ? [] : fuelBrand.split(',').map(b => b.trim()).filter(Boolean); + for (const brand of brands) { + const tags = BRAND_OVERPASS_TAGS[brand]; + if (!tags) continue; + for (const tag of tags) { + filters.push(`node["brand"="${tag}"]["amenity"="fuel"](${around});`); + filters.push(`node["operator"="${tag}"]["amenity"="fuel"](${around});`); + } + } + + if (fuelType === 'diesel') { + filters.push(`node["fuel:diesel"="yes"](${around});`); + } else if (fuelType === 'petrol') { + filters.push(`node["fuel:octane_91"="yes"](${around});`); + filters.push(`node["fuel:octane_95"="yes"](${around});`); + } else { + filters.push(`node["amenity"="fuel"](${around});`); + filters.push(`node["fuel:diesel"="yes"](${around});`); + } + filters.push(`node["shop"="fuel"](${around});`); + return filters; +} + +interface OverpassElement { + id: number; + lat: number; + lon: number; + tags?: Record; +} + +interface OverpassResponse { + elements?: OverpassElement[]; +} + +async function enforceOverpassRateLimit(): Promise { + const now = Date.now(); + const elapsed = now - lastOverpassRequest; + if (elapsed < OVERPASS_MIN_INTERVAL_MS) { + await new Promise(resolve => setTimeout(resolve, OVERPASS_MIN_INTERVAL_MS - elapsed)); + } + lastOverpassRequest = Date.now(); +} + +async function enforceGooglePlacesRateLimit(): Promise { + const now = Date.now(); + const elapsed = now - lastGooglePlacesRequest; + if (elapsed < GOOGLE_PLACES_MIN_INTERVAL_MS) { + await new Promise(resolve => setTimeout(resolve, GOOGLE_PLACES_MIN_INTERVAL_MS - elapsed)); + } + lastGooglePlacesRequest = Date.now(); +} + +async function runOverpassQuery(points: SearchPoint[], stopType: string, radiusMeters: number, fuelType: string = 'any', fuelBrand: string = 'any'): Promise { + await enforceOverpassRateLimit(); + + const filters: string[] = []; + for (const sp of points) { + const around = `around:${radiusMeters},${sp.lat},${sp.lng}`; + if (stopType === 'fuel' || stopType === 'both') { + filters.push(...buildFuelFilters(around, fuelType, fuelBrand)); + } + if (stopType === 'rest' || stopType === 'both') filters.push(`node["highway"="rest_area"](${around});`); + } + const query = `[out:json][timeout:${OVERPASS_QUERY_TIMEOUT}];(${filters.join('')});out body;`; + console.log(`[find-stops] Overpass query: ${points.length} points, radius:${radiusMeters}m, type:${stopType}, query length:${query.length}`); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), OVERPASS_TIMEOUT_MS); + try { + const res = await fetch('https://overpass-api.de/api/interpreter', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': 'TREK-TravelPlanner/1.0' }, + body: `data=${encodeURIComponent(query)}`, + agent: ipv4Agent, + signal: controller.signal as never, + }); + clearTimeout(timeout); + const body = await res.text(); + console.log(`[find-stops] Overpass response status: ${res.status}, body length: ${body.length}`); + if (!res.ok) { + console.error(`[find-stops] Overpass error response: ${body.substring(0, 500)}`); + return []; + } + const data = JSON.parse(body) as OverpassResponse; + console.log(`[find-stops] Overpass POIs returned: ${(data.elements || []).length}`); + return data.elements || []; + } catch (err: unknown) { + clearTimeout(timeout); + if (err instanceof Error && err.name === 'AbortError') { + console.error(`[find-stops] Overpass request timed out after ${OVERPASS_TIMEOUT_MS}ms`); + } else { + console.error('[find-stops] Overpass fetch error:', err); + } + return []; + } +} + +function deduplicateElements(elements: OverpassElement[]): OverpassElement[] { + const seen = new Set(); + return elements.filter(el => { + if (seen.has(el.id)) return false; + seen.add(el.id); + return true; + }); +} + +function isFuelElement(el: OverpassElement): boolean { + return el.tags?.amenity === 'fuel' || el.tags?.shop === 'fuel' || el.tags?.['fuel:diesel'] === 'yes'; +} + +function elementsToStopResults(elements: OverpassElement[]): StopResult[] { + return elements.map(el => ({ + name: el.tags?.name || el.tags?.brand || 'Unknown', + lat: el.lat, lng: el.lon, + type: (isFuelElement(el) ? 'fuel' : 'rest') as 'fuel' | 'rest', + distance_from_route_meters: 0, + source: 'osm' as const, + brand: el.tags?.brand || null, + rating: null, + opening_hours: el.tags?.opening_hours || null, + osm_id: `node/${el.id}`, + place_id: null, + })); +} + +function assignBestPoi(sp: SearchPoint, allPois: StopResult[], radiusMeters: number): StopResult[] { + const scored = allPois.map(poi => ({ + ...poi, + distance_from_route_meters: Math.round(haversineDistance(sp.lat, sp.lng, poi.lat, poi.lng)), + })); + scored.sort((a, b) => a.distance_from_route_meters - b.distance_from_route_meters); + const best = scored.filter(p => p.distance_from_route_meters <= radiusMeters).slice(0, 1); + if (best.length > 0) { + console.log(`[find-stops] Found ${best[0].name} at ${best[0].distance_from_route_meters}m from route (search radius: ${radiusMeters}m)`); + } + return best; +} + +export async function searchOverpassBatched(searchPoints: SearchPoint[], stopType: string, fuelType: string = 'any', fuelBrand: string = 'any'): Promise> { + const resultsByPoint = new Map(); + let pendingIndices = searchPoints.map((_, i) => i); + let allElements: OverpassElement[] = []; + + for (const radius of SEARCH_RADII) { + if (pendingIndices.length === 0) break; + + const pendingPoints = pendingIndices.map(i => searchPoints[i]); + const elements = await runOverpassQuery(pendingPoints, stopType, radius, fuelType, fuelBrand); + allElements = deduplicateElements([...allElements, ...elements]); + const allPois = elementsToStopResults(allElements); + + const stillPending: number[] = []; + for (const idx of pendingIndices) { + const best = assignBestPoi(searchPoints[idx], allPois, radius); + if (best.length > 0) { + resultsByPoint.set(idx, best); + } else { + stillPending.push(idx); + } + } + + if (stillPending.length > 0 && radius < SEARCH_RADII[SEARCH_RADII.length - 1]) { + const nextRadius = SEARCH_RADII[SEARCH_RADII.indexOf(radius) + 1]; + console.log(`[find-stops] Overpass: ${stillPending.length} points had no results at ${radius}m, retrying at ${nextRadius}m`); + } + pendingIndices = stillPending; + } + + for (const idx of pendingIndices) { + resultsByPoint.set(idx, []); + } + + return resultsByPoint; +} + +export async function searchCorridorOverpass(searchPoints: SearchPoint[], stopType: string, fuelType: string = 'any', fuelBrand: string = 'any'): Promise { + let allElements: OverpassElement[] = []; + + for (const radius of SEARCH_RADII) { + const elements = await runOverpassQuery(searchPoints, stopType, radius, fuelType, fuelBrand); + allElements = deduplicateElements([...allElements, ...elements]); + const allPois = elementsToStopResults(allElements); + const uncoveredCount = searchPoints.filter(sp => + !allPois.some(poi => haversineDistance(sp.lat, sp.lng, poi.lat, poi.lng) <= radius) + ).length; + if (uncoveredCount === 0) { + console.log(`[find-stops] Corridor Overpass: full coverage at ${radius}m radius, ${allElements.length} total POIs`); + break; + } + if (radius < SEARCH_RADII[SEARCH_RADII.length - 1]) { + console.log(`[find-stops] Corridor Overpass: ${uncoveredCount} uncovered points at ${radius}m, expanding`); + } + } + + console.log(`[find-stops] Corridor Overpass: ${allElements.length} unique POIs found`); + const results = elementsToStopResults(allElements); + return results.map(poi => { + let minDist = Infinity; + for (const sp of searchPoints) { + const d = haversineDistance(sp.lat, sp.lng, poi.lat, poi.lng); + if (d < minDist) minDist = d; + } + return { ...poi, distance_from_route_meters: Math.round(minDist) }; + }); +} + +interface GooglePlaceResult { + id?: string; + displayName?: { text?: string }; + location?: { latitude: number; longitude: number }; + rating?: number; + regularOpeningHours?: { openNow?: boolean }; +} + +interface GooglePlacesResponse { + places?: GooglePlaceResult[]; +} + +export function getGlobalMapsKey(): string | null { + const admin = db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get() as { maps_api_key: string } | undefined; + if (!admin?.maps_api_key) { + console.warn('Google Places requested but no Maps API key configured by admin'); + return null; + } + return admin.maps_api_key; +} + +async function searchGooglePlacesAtRadius(lat: number, lng: number, searchType: string, apiKey: string, radiusMeters: number): Promise { + await enforceGooglePlacesRateLimit(); + + const results: StopResult[] = []; + const includedTypes = searchType === 'fuel' ? ['gas_station'] : ['rest_stop']; + console.log(`[find-stops] Google Places search types:${includedTypes.join(',')} near ${lat},${lng} radius:${radiusMeters}m`); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), GOOGLE_PLACES_TIMEOUT_MS); + try { + const res = await fetch('https://places.googleapis.com/v1/places:searchNearby', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': 'places.id,places.displayName,places.location,places.rating,places.regularOpeningHours', + }, + body: JSON.stringify({ + includedTypes, + maxResultCount: 5, + locationRestriction: { + circle: { center: { latitude: lat, longitude: lng }, radius: Math.min(radiusMeters, GOOGLE_MAX_RADIUS) }, + }, + }), + agent: ipv4Agent, + signal: controller.signal as never, + }); + clearTimeout(timeout); + const body = await res.text(); + console.log(`[find-stops] Google Places response status: ${res.status}, body length: ${body.length}`); + if (!res.ok) { + console.error(`[find-stops] Google Places error: ${body.substring(0, 500)}`); + return []; + } + const data = JSON.parse(body) as GooglePlacesResponse; + for (const r of data.places || []) { + const dist = Math.round(haversineDistance(lat, lng, r.location?.latitude || 0, r.location?.longitude || 0)); + const name = r.displayName?.text || 'Unknown'; + console.log(`[find-stops] Found ${name} at ${dist}m from route (search radius: ${radiusMeters}m)`); + results.push({ + name, + lat: r.location?.latitude || 0, lng: r.location?.longitude || 0, + type: searchType as 'fuel' | 'rest', + distance_from_route_meters: dist, + source: 'google', + brand: null, + rating: r.rating || null, + opening_hours: r.regularOpeningHours?.openNow != null ? (r.regularOpeningHours.openNow ? 'Open now' : 'Closed') : null, + osm_id: null, + place_id: r.id || null, + }); + } + } catch (err: unknown) { + clearTimeout(timeout); + if (err instanceof Error && err.name === 'AbortError') { + console.error(`[find-stops] Google Places request timed out after ${GOOGLE_PLACES_TIMEOUT_MS}ms`); + } else { + console.error('[find-stops] Google Places fetch error:', err); + } + } + return results; +} + +async function searchGooglePlaces(lat: number, lng: number, stopType: string, apiKey: string): Promise { + const searchTypes: string[] = []; + if (stopType === 'fuel' || stopType === 'both') searchTypes.push('fuel'); + if (stopType === 'rest' || stopType === 'both') searchTypes.push('rest'); + + const allResults: StopResult[] = []; + const GOOGLE_RADII = [15000, GOOGLE_MAX_RADIUS]; + + for (const sType of searchTypes) { + let found = false; + for (const radius of GOOGLE_RADII) { + const results = await searchGooglePlacesAtRadius(lat, lng, sType, apiKey, radius); + if (results.length > 0) { + allResults.push(...results); + found = true; + break; + } + if (radius < GOOGLE_RADII[GOOGLE_RADII.length - 1]) { + const nextRadius = GOOGLE_RADII[GOOGLE_RADII.indexOf(radius) + 1]; + console.log(`[find-stops] Google Places: no results at ${radius}m, expanding to ${nextRadius}m for point ${lat},${lng}`); + } + } + if (!found) { + console.log(`[find-stops] Google Places: no ${sType} results at any radius for point ${lat},${lng}`); + } + } + return allResults; +} + +export async function searchCorridorGoogle(searchPoints: SearchPoint[], stopType: string, apiKey: string, fuelType: string = 'any', fuelBrand: string = 'any'): Promise { + const allResults: StopResult[] = []; + const seenIds = new Set(); + + for (const sp of searchPoints) { + const results = await searchGooglePlaces(sp.lat, sp.lng, stopType, apiKey); + for (const r of results) { + const key = r.place_id || `${r.lat},${r.lng}`; + if (!seenIds.has(key)) { + seenIds.add(key); + allResults.push(r); + } + } + } + + const uncoveredPoints = searchPoints.filter(sp => + !allResults.some(r => haversineDistance(sp.lat, sp.lng, r.lat, r.lng) <= GOOGLE_MAX_RADIUS) + ); + if (uncoveredPoints.length > 0) { + console.log(`[find-stops] Corridor Google: ${uncoveredPoints.length} points had no nearby results, falling back to Overpass`); + const overpassResults = await searchCorridorOverpass(uncoveredPoints, stopType, fuelType, fuelBrand); + const overpassDeduped = overpassResults.filter(r => { + const key = r.osm_id || `${r.lat},${r.lng}`; + if (seenIds.has(key)) return false; + seenIds.add(key); + return true; + }); + allResults.push(...overpassDeduped); + } + + return allResults; +} + +export interface FindStopsOptions { + tripId: string; + legId: number; + userId: number; + stopType: string; + searchPoints: SearchPoint[]; + corridor?: boolean; +} + +export async function findStopsForLeg(opts: FindStopsOptions): Promise<(StopResult & { distance_along_route_meters: number })[]> { + const { userId, stopType, searchPoints, corridor } = opts; + + const source = getUserSetting(userId, 'roadtrip_stop_source') || 'osm'; + const apiKey = source === 'google' ? getGlobalMapsKey() : null; + const fuelType = getUserSetting(userId, 'roadtrip_fuel_type') || 'any'; + const fuelBrand = getUserSetting(userId, 'roadtrip_fuel_brand') || 'any'; + + const useGoogle = source === 'google' && apiKey; + console.log(`[find-stops] source: ${source}, useGoogle: ${!!useGoogle}, fuelType: ${fuelType}, fuelBrand: ${fuelBrand}`); + const validType = isValidStopType(stopType) ? stopType : 'both'; + const cappedPoints = searchPoints.slice(0, MAX_SEARCH_POINTS); + + let allFoundStops: (StopResult & { distance_along_route_meters: number })[] = []; + + if (corridor) { + console.log(`[find-stops] Corridor mode: searching entire route corridor`); + let corridorResults: StopResult[]; + if (useGoogle) { + corridorResults = await searchCorridorGoogle(cappedPoints, validType, apiKey!, fuelType, fuelBrand); + } else { + corridorResults = await searchCorridorOverpass(cappedPoints, validType, fuelType, fuelBrand); + } + allFoundStops = corridorResults.map(r => { + let closestDist = Infinity; + let closestPointDist = 0; + for (const sp of cappedPoints) { + const d = haversineDistance(r.lat, r.lng, sp.lat, sp.lng); + if (d < closestDist) { + closestDist = d; + closestPointDist = sp.distance_along_route_meters; + } + } + return { ...r, distance_along_route_meters: closestPointDist }; + }); + allFoundStops.sort((a, b) => a.distance_along_route_meters - b.distance_along_route_meters); + console.log(`[find-stops] Corridor: ${allFoundStops.length} total stops found along corridor`); + } else { + const stops: { search_point: SearchPoint; results: StopResult[] }[] = []; + + if (useGoogle) { + for (const sp of cappedPoints) { + const results = await searchGooglePlaces(sp.lat, sp.lng, validType, apiKey!); + results.sort((a, b) => a.distance_from_route_meters - b.distance_from_route_meters); + stops.push({ search_point: sp, results: results.slice(0, 1) }); + } + const emptyIndices = stops.map((s, i) => s.results.length === 0 ? i : -1).filter(i => i >= 0); + if (emptyIndices.length > 0) { + console.log(`[find-stops] Google Places: ${emptyIndices.length} points had no results, falling back to Overpass`); + const fallbackPoints = emptyIndices.map(i => cappedPoints[i]); + const overpassResults = await searchOverpassBatched(fallbackPoints, validType, fuelType, fuelBrand); + for (let j = 0; j < emptyIndices.length; j++) { + const results = overpassResults.get(j) || []; + if (results.length > 0) { + stops[emptyIndices[j]] = { search_point: cappedPoints[emptyIndices[j]], results }; + } + } + } + } else { + const resultsByPoint = await searchOverpassBatched(cappedPoints, validType, fuelType, fuelBrand); + for (let i = 0; i < cappedPoints.length; i++) { + stops.push({ search_point: cappedPoints[i], results: resultsByPoint.get(i) || [] }); + } + } + + allFoundStops = stops.flatMap(s => + s.results.map(r => ({ + ...r, + distance_along_route_meters: s.search_point.distance_along_route_meters, + })) + ); + } + + return allFoundStops; +} + +export function deduplicateAndFilterStops( + allFoundStops: (StopResult & { distance_along_route_meters: number })[], + routeGeometry: string | null, + fuelBrand: string | null, +): (StopResult & { distance_along_route_meters: number })[] { + // Cross-type dedup: if a rest stop is within 5km of a fuel stop, drop the rest stop + const fuelStops = allFoundStops.filter(s => s.type === 'fuel'); + if (fuelStops.length > 0) { + allFoundStops = allFoundStops.filter(s => { + if (s.type !== 'rest') return true; + return !fuelStops.some(f => haversineDistance(s.lat, s.lng, f.lat, f.lng) < CROSS_TYPE_DEDUP_PROXIMITY); + }); + } + + // Dedup by location (within DEDUP_PROXIMITY = same station) + const dedupedStops: typeof allFoundStops = []; + for (const stop of allFoundStops) { + const isDupe = dedupedStops.some(s => + s.type === stop.type && haversineDistance(s.lat, s.lng, stop.lat, stop.lng) < DEDUP_PROXIMITY + ); + if (!isDupe) dedupedStops.push(stop); + } + allFoundStops = dedupedStops; + + // Filter by true distance from route polyline + if (routeGeometry) { + const routePolyline = decodePolyline(routeGeometry); + if (routePolyline.length >= 2) { + const beforeCount = allFoundStops.length; + allFoundStops = allFoundStops.filter(stop => { + const dist = distanceToPolyline(stop.lat, stop.lng, routePolyline); + stop.distance_from_route_meters = Math.round(dist); + return dist <= ROUTE_PROXIMITY_THRESHOLD; + }); + console.log(`[find-stops] Filtered ${beforeCount} POIs to ${allFoundStops.length} within ${ROUTE_PROXIMITY_THRESHOLD}m of route`); + } + } + + // Sort: preferred brands first if set + if (fuelBrand && fuelBrand !== 'any') { + const brands = fuelBrand.split(',').map(b => b.trim().toLowerCase()).filter(Boolean); + const matchesBrand = (stop: StopResult) => + brands.some(b => stop.brand?.toLowerCase().includes(b) || stop.name?.toLowerCase().includes(b)); + allFoundStops.sort((a, b) => { + const aMatch = matchesBrand(a) ? 0 : 1; + const bMatch = matchesBrand(b) ? 0 : 1; + if (aMatch !== bMatch) return aMatch - bMatch; + return a.distance_along_route_meters - b.distance_along_route_meters; + }); + } + + return allFoundStops; +}