diff --git a/client/src/components/Map/MapView.test.tsx b/client/src/components/Map/MapView.test.tsx index 902a8f32..ce27ae1c 100644 --- a/client/src/components/Map/MapView.test.tsx +++ b/client/src/components/Map/MapView.test.tsx @@ -128,7 +128,8 @@ describe('MapView', () => { it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => { render() - expect(screen.getByTestId('polyline')).toBeTruthy() + // Apple-Maps style draws a casing + a core line per segment. + expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0) }) it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => { @@ -155,16 +156,11 @@ describe('MapView', () => { expect(screen.getByTestId('cluster-group')).toBeTruthy() }) - it('FE-COMP-MAPVIEW-011: renders RouteLabel marker when routeSegments provided with route', () => { - const route = [[[48.0, 2.0], [49.0, 3.0]]] as [number, number][][][] - const routeSegments = [ - { mid: [48.5, 2.5] as [number, number], from: 0, to: 1, walkingText: '10 min', drivingText: '3 min' }, - ] - render() - // Route polyline is rendered - expect(screen.getByTestId('polyline')).toBeTruthy() - // RouteLabel renders a Marker (mocked), but it returns null when zoom < 12 - // so we just assert the polyline is there, exercising the routeSegments.map path + it('FE-COMP-MAPVIEW-011: renders the route polyline; travel times are no longer drawn on the map', () => { + const route = [[[48.0, 2.0], [49.0, 3.0]]] as unknown as [number, number][][] + render() + // The route is drawn; per-segment times now live in the day sidebar, not on the map. + expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0) }) it('FE-COMP-MAPVIEW-012: invalid route_geometry JSON triggers catch and skips polyline', () => { diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index a4a9831d..92322c79 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -225,44 +225,7 @@ function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.Leafle return null } -// ── Route travel time label ── -interface RouteLabelProps { - midpoint: [number, number] - walkingText: string - drivingText: string -} - -function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) { - if (!midpoint) return null - - const icon = L.divIcon({ - className: 'route-info-pill', - html: `
- - - ${walkingText} - - | - - - ${drivingText} - -
`, - iconSize: [0, 0], - iconAnchor: [0, 0], - }) - - return -} +// Travel times are shown in the day sidebar (per-segment connectors), not on the map. // Module-level photo cache shared with PlaceAvatar import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService' @@ -586,23 +549,19 @@ export const MapView = memo(function MapView({ {markers} - {route && route.length > 0 && ( - <> - {route.map((seg, i) => seg.length > 1 && ( - - ))} - {routeSegments.map((seg, i) => ( - - ))} - - )} + {/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded. */} + {route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [ + , + , + ] : [])} {/* GPX imported route geometries */} {gpxPolylines} diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index 0287026e..618b7fb2 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -163,7 +163,6 @@ export function MapViewGL({ const markersRef = useRef>(new Map()) const locationMarkerRef = useRef(null) const reservationOverlayRef = useRef(null) - const routeLabelMarkersRef = useRef([]) // Refs so the reservation overlay always sees the latest callback / // options without forcing a full overlay rebuild on every prop change. const onReservationClickRef = useRef(onReservationClick) @@ -218,16 +217,20 @@ export function MapViewGL({ // initial route source — kept around so updates can setData() cheaply if (!map.getSource('trip-route')) { map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }) + // Apple-Maps style: a darker-blue casing under a bright-blue core, both + // rounded. Casing is added first so it sits beneath the core line. + map.addLayer({ + id: 'trip-route-casing', + type: 'line', + source: 'trip-route', + paint: { 'line-color': '#0a5cc2', 'line-width': 8 }, + layout: { 'line-cap': 'round', 'line-join': 'round' }, + }) map.addLayer({ id: 'trip-route-line', type: 'line', source: 'trip-route', - paint: { - 'line-color': '#111827', - 'line-width': 3, - 'line-opacity': 0.9, - 'line-dasharray': [2, 1.5], - }, + paint: { 'line-color': '#0a84ff', 'line-width': 5 }, layout: { 'line-cap': 'round', 'line-join': 'round' }, }) } @@ -444,34 +447,7 @@ export function MapViewGL({ src.setData({ type: 'FeatureCollection', features }) }, [route]) - // Travel-time pills between consecutive places. The GL map accepted the - // routeSegments prop but never drew anything, so the labels that Leaflet - // shows were missing here (#850). Render them as HTML markers, matching the - // Leaflet pill styling. - useEffect(() => { - const map = mapRef.current - if (!map || !mapReady) return - routeLabelMarkersRef.current.forEach(m => m.remove()) - routeLabelMarkersRef.current = [] - for (const seg of routeSegments) { - if (!seg.mid || (!seg.walkingText && !seg.drivingText)) continue - const el = document.createElement('div') - el.style.pointerEvents = 'none' - el.innerHTML = `
- ${seg.walkingText ?? ''} - | - ${seg.drivingText ?? ''} -
` - const m = new mapboxgl.Marker({ element: el, anchor: 'center' }) - .setLngLat([seg.mid[1], seg.mid[0]]) - .addTo(map) - routeLabelMarkersRef.current.push(m) - } - return () => { - routeLabelMarkersRef.current.forEach(m => m.remove()) - routeLabelMarkersRef.current = [] - } - }, [routeSegments, mapReady]) + // Travel times now live in the day sidebar (per-segment connectors), not on the map. // Update GPX geometries useEffect(() => { diff --git a/client/src/components/Map/RouteCalculator.ts b/client/src/components/Map/RouteCalculator.ts index 8300aabd..53ca76be 100644 --- a/client/src/components/Map/RouteCalculator.ts +++ b/client/src/components/Map/RouteCalculator.ts @@ -1,7 +1,21 @@ -import type { RouteResult, RouteSegment, Waypoint } from '../../types' +import type { RouteResult, RouteSegment, RouteWithLegs, Waypoint } from '../../types' const OSRM_BASE = 'https://router.project-osrm.org/route/v1' +// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the +// project-osrm.org demo is car-only (it ignores the profile in the URL). Use +// the matching profile so walking routes follow footpaths, not the road network. +const OSRM_PROFILE_BASE: Record<'driving' | 'walking' | 'cycling', string> = { + driving: 'https://routing.openstreetmap.de/routed-car/route/v1/driving', + walking: 'https://routing.openstreetmap.de/routed-foot/route/v1/foot', + cycling: 'https://routing.openstreetmap.de/routed-bike/route/v1/bike', +} + +// Cache route responses keyed by the exact waypoint list. Routes are stable, so +// this avoids re-hitting the public OSRM demo server on every day switch / reorder. +const routeCache = new Map() +const ROUTE_CACHE_MAX = 200 + /** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */ export async function calculateRoute( waypoints: Waypoint[], @@ -116,12 +130,72 @@ export async function calculateSegments( const walkingDuration = leg.distance / (5000 / 3600) return { mid, from, to, + distance: leg.distance, + duration: leg.duration, walkingText: formatDuration(walkingDuration), drivingText: formatDuration(leg.duration), + distanceText: formatDistance(leg.distance), } }) } +/** + * One OSRM call per waypoint-run that returns BOTH the real road geometry (for the + * map) and per-leg distance/duration (for the sidebar connectors). Results are cached + * by the exact waypoint list. Throws on OSRM failure so callers can fall back to a + * straight line. + */ +export async function calculateRouteWithLegs( + waypoints: Waypoint[], + { signal, profile = 'driving' }: { signal?: AbortSignal; profile?: 'driving' | 'walking' | 'cycling' } = {} +): Promise { + if (!waypoints || waypoints.length < 2) { + return { coordinates: [], distance: 0, duration: 0, legs: [] } + } + + const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';') + const cacheKey = `${profile}:${coords}` + const cached = routeCache.get(cacheKey) + if (cached) return cached + + const url = `${OSRM_PROFILE_BASE[profile]}/${coords}?overview=full&geometries=geojson&annotations=distance,duration` + const response = await fetch(url, { signal }) + if (!response.ok) throw new Error('Route could not be calculated') + + const data = await response.json() + if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found') + + const route = data.routes[0] + const coordinates: [number, number][] = route.geometry.coordinates.map( + ([lng, lat]: [number, number]) => [lat, lng] + ) + const legs: RouteSegment[] = (route.legs || []).map( + (leg: { distance: number; duration: number }, i: number): RouteSegment => { + const from: [number, number] = [waypoints[i].lat, waypoints[i].lng] + const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng] + const mid: [number, number] = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2] + const walkingDuration = leg.distance / (5000 / 3600) + return { + mid, from, to, + distance: leg.distance, + duration: leg.duration, + walkingText: formatDuration(walkingDuration), + drivingText: formatDuration(leg.duration), + distanceText: formatDistance(leg.distance), + durationText: formatDuration(leg.duration), + } + } + ) + + const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs } + routeCache.set(cacheKey, result) + if (routeCache.size > ROUTE_CACHE_MAX) { + const oldest = routeCache.keys().next().value + if (oldest !== undefined) routeCache.delete(oldest) + } + return result +} + function formatDistance(meters: number): string { if (meters < 1000) { return `${Math.round(meters)} m` diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index fb0d1187..3d00931c 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -268,14 +268,7 @@ describe('DayPlanSidebar', () => { const user = userEvent.setup() const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' }) render() - // Find the pencil/edit button next to the title - const editButtons = screen.getAllByRole('button') - const editBtn = editButtons.find(btn => btn.querySelector('svg') && btn.closest('[style]')?.textContent?.includes('Original Title')) - // Click the edit (pencil) button — it's the small one near the title - // The pencil button is inside the title area with opacity 0.35 - const titleEl = screen.getByText('Original Title') - const pencilBtn = titleEl.parentElement?.querySelector('button') - if (pencilBtn) await user.click(pencilBtn) + await user.click(screen.getByLabelText('Edit')) await waitFor(() => { expect(screen.getByDisplayValue('Original Title')).toBeInTheDocument() }) @@ -287,9 +280,7 @@ describe('DayPlanSidebar', () => { const onUpdateDayTitle = vi.fn() render() // Enter edit mode - const titleEl = screen.getByText('Original Title') - const pencilBtn = titleEl.parentElement?.querySelector('button') - if (pencilBtn) await user.click(pencilBtn) + await user.click(screen.getByLabelText('Edit')) const input = await screen.findByDisplayValue('Original Title') await user.clear(input) await user.type(input, 'New Title') @@ -301,9 +292,7 @@ describe('DayPlanSidebar', () => { const user = userEvent.setup() const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' }) render() - const titleEl = screen.getByText('Original Title') - const pencilBtn = titleEl.parentElement?.querySelector('button') - if (pencilBtn) await user.click(pencilBtn) + await user.click(screen.getByLabelText('Edit')) const input = await screen.findByDisplayValue('Original Title') await user.keyboard('{Escape}') expect(screen.queryByDisplayValue('Original Title')).not.toBeInTheDocument() @@ -625,9 +614,7 @@ describe('DayPlanSidebar', () => { const onUpdateDayTitle = vi.fn() const day = buildDay({ id: 10, date: '2025-06-01', title: 'Old Title' }) render() - const titleEl = screen.getByText('Old Title') - const pencilBtn = titleEl.parentElement?.querySelector('button') - if (pencilBtn) await user.click(pencilBtn) + await user.click(screen.getByLabelText('Edit')) const input = await screen.findByDisplayValue('Old Title') await user.clear(input) await user.type(input, 'New Title') diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 7e2d9f2f..a8cd7aba 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -4,12 +4,12 @@ declare global { interface Window { __dragData: DragDataPayload | null } } import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react' import ReactDOM from 'react-dom' -import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, 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, Undo2, X, Route as RouteIcon } from 'lucide-react' +import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, 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, Undo2, X, Footprints, Route as RouteIcon } 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' import { downloadTripPDF } from '../PDF/TripPDF' -import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator' +import { calculateRoute, calculateRouteWithLegs, optimizeRoute } from '../Map/RouteCalculator' import PlaceAvatar from '../shared/PlaceAvatar' import { useContextMenu, ContextMenu } from '../shared/ContextMenu' import Markdown from 'react-markdown' @@ -31,7 +31,7 @@ import { import { formatDate, formatTime, dayTotalCost, currencyDecimals, splitReservationDateTime } from '../../utils/formatters' import { useDayNotes } from '../../hooks/useDayNotes' import Tooltip from '../shared/Tooltip' -import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types' +import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult, RouteSegment } from '../../types' const NOTE_ICONS = [ { id: 'FileText', Icon: FileText }, @@ -184,6 +184,10 @@ interface DayPlanSidebarProps { onExternalTransportDetailHandled?: () => void onAddReservation: () => void onNavigateToFiles?: () => void + routeShown?: boolean + routeProfile?: 'driving' | 'walking' + onToggleRoute?: () => void + onSetRouteProfile?: (profile: 'driving' | 'walking') => void onAddPlace?: () => void onAddPlaceToDay?: (placeId: number, dayId: number) => void onExpandedDaysChange?: (expandedDayIds: Set) => void @@ -200,6 +204,25 @@ interface DayPlanSidebarProps { onScrollTopChange?: (top: number) => void } +/** Slim travel-time connector shown between two consecutive located stops in a day. */ +function RouteConnector({ seg, profile }: { seg: RouteSegment; profile: 'driving' | 'walking' }) { + const driving = profile === 'driving' + const Icon = driving ? Car : Footprints + const line = { flex: 1, height: 1, minHeight: 1, alignSelf: 'center', background: 'var(--border-primary)' } + return ( +
+
+
+ + {seg.durationText ?? (driving ? seg.drivingText : seg.walkingText)} + · + {seg.distanceText} +
+
+
+ ) +} + const DayPlanSidebar = React.memo(function DayPlanSidebar({ tripId, trip, days, places, categories, assignments, @@ -216,6 +239,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ onAddPlace, onAddPlaceToDay, onNavigateToFiles, + routeShown = false, + routeProfile = 'driving', + onToggleRoute, + onSetRouteProfile, onExpandedDaysChange, pushUndo, canUndo = false, @@ -233,6 +260,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const { t, language, locale } = useTranslation() const ctxMenu = useContextMenu() const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' + const routeCalcEnabled = useSettingsStore(s => s.settings.route_calculation) !== false const tripActions = useRef(useTripStore.getState()).current const can = useCanDo() const canEditDays = can('day_edit', trip) @@ -251,6 +279,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const [editTitle, setEditTitle] = useState('') const [isCalculating, setIsCalculating] = useState(false) const [routeInfo, setRouteInfo] = useState(null) + const [routeLegs, setRouteLegs] = useState>({}) + const legsAbortRef = useRef(null) const [draggingId, setDraggingId] = useState(null) const [lockedIds, setLockedIds] = useState(new Set()) const [lockHoverId, setLockHoverId] = useState(null) @@ -472,6 +502,42 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [days, assignments, dayNotes, reservations, transportPosVersion]) + // Per-segment driving times for the selected day's connectors. Groups located + // places into runs (split at transports), one cached OSRM call per run, keyed by + // the start place's assignment id. Shares RouteCalculator's cache with the map. + useEffect(() => { + if (legsAbortRef.current) legsAbortRef.current.abort() + if (!selectedDayId || !routeCalcEnabled || !routeShown) { setRouteLegs({}); return } + const merged = mergedItemsMap[selectedDayId] || [] + const runs: { id: number; lat: number; lng: number }[][] = [] + let cur: { id: number; lat: number; lng: number }[] = [] + for (const it of merged) { + if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) { + cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng }) + } else if (it.type === 'transport') { + if (cur.length >= 2) runs.push(cur) + cur = [] + } + } + if (cur.length >= 2) runs.push(cur) + if (runs.length === 0) { setRouteLegs({}); return } + + const controller = new AbortController() + legsAbortRef.current = controller + ;(async () => { + const map: Record = {} + for (const run of runs) { + try { + const r = await calculateRouteWithLegs(run.map(p => ({ lat: p.lat, lng: p.lng })), { signal: controller.signal, profile: routeProfile }) + r.legs.forEach((leg, i) => { map[run[i].id] = leg }) + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') return + } + } + if (!controller.signal.aborted) setRouteLegs(map) + })() + }, [selectedDayId, routeCalcEnabled, routeShown, routeProfile, mergedItemsMap]) + const openAddNote = (dayId, e) => { e?.stopPropagation() _openAddNote(dayId, getMergedItems, (id) => { @@ -792,13 +858,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ }) } - const handleGoogleMaps = () => { - if (!selectedDayId) return - const da = getDayAssignments(selectedDayId) - const url = generateGoogleMapsUrl(da.map(a => a.place).filter(p => p?.lat && p?.lng)) - if (url) window.open(url, '_blank') - else toast.error(t('dayplan.toast.noGeoPlaces')) - } const handleDropOnDay = (e, dayId) => { e.preventDefault() @@ -1047,6 +1106,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
{ onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }} onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }} @@ -1066,16 +1127,34 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-tertiary)' }} onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }} > - {/* Tages-Badge */} -
- {index + 1} -
+ {/* Tages-Badge: Nummer oben, darunter (falls vorhanden) das Wetter des Tages */} + {(() => { + const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat + const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng + const hasWeather = !!(day.date && anyGeoPlace && wLat != null && wLng != null) + return ( +
+
+ {index + 1} +
+ {hasWeather && ( + <> +
+
+ +
+ + )} +
+ ) + })()}
{editingDayId === day.id ? ( @@ -1093,40 +1172,27 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ borderBottom: '1.5px solid var(--text-primary)', }} /> - ) : ( -
+ ) : (<> +
{day.title || t('dayplan.dayN', { n: index + 1 })} - {canEditDays && } - {canEditDays && onAddTransport && ( - - - + {formattedDate && ( + <> + + + {formattedDate} + + )} +
+ {(() => { + const hasAccs = accommodations.some(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days)) + const hasRentals = getActiveRentalsForDay(day.id).length > 0 + if (!hasAccs && !hasRentals) return null + return
+ })()} +
{(() => { const dayAccs = accommodations.filter(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days)) // Sort: check-out first, then ongoing stays, then check-in last @@ -1145,13 +1211,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ return dayAccs.map(acc => { const isCheckIn = acc.start_day_id === day.id const isCheckOut = acc.end_day_id === day.id - const bg = isCheckOut && !isCheckIn ? 'rgba(239,68,68,0.08)' : isCheckIn ? 'rgba(34,197,94,0.08)' : 'var(--bg-secondary)' - const border = isCheckOut && !isCheckIn ? 'rgba(239,68,68,0.2)' : isCheckIn ? 'rgba(34,197,94,0.2)' : 'var(--border-primary)' - const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-muted)' + const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-faint)' return ( - { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: bg, border: `1px solid ${border}`, flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: (acc as any).place_id ? 'pointer' : 'default' }}> - - {(acc as any).place_name || (acc as any).reservation_title} + { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 1, minWidth: 0, cursor: (acc as any).place_id ? 'pointer' : 'default', background: 'var(--bg-hover)', borderRadius: 7, padding: '2px 7px 2px 6px' }}> + + {(acc as any).place_name || (acc as any).reservation_title} ) }) @@ -1161,41 +1225,50 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ const activeRentals = getActiveRentalsForDay(day.id) if (activeRentals.length === 0) return null return activeRentals.map(r => ( - { e.stopPropagation(); setTransportDetail(r) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: 'rgba(59,130,246,0.08)', border: '1px solid rgba(59,130,246,0.2)', flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: 'pointer' }}> - - {r.title} + { e.stopPropagation(); setTransportDetail(r) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 1, minWidth: 0, cursor: 'pointer', background: 'var(--bg-hover)', borderRadius: 7, padding: '2px 7px 2px 6px' }}> + + {r.title} )) })()}
+ + )} + {cost && ( +
+ {cost} +
)} -
- {formattedDate && {formattedDate}} - {cost && {cost}} - {day.date && anyGeoPlace && } - {day.date && anyGeoPlace && (() => { - const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat - const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng - return - })()} -
- {canEditDays && } - + {canEditDays ? ( + (() => { + const cell = { padding: 7, cursor: 'pointer', display: 'grid', placeItems: 'center' } as const + const div = '1px solid var(--border-faint)' + return ( +
+ + {onAddTransport ? ( + + ) :
} + + +
+ ) + })() + ) : ( + + )}
{/* Aufgeklappte Orte + Notizen */} @@ -1607,6 +1680,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ )}
+ {routeLegs[assignment.id] && } ) } @@ -1656,6 +1730,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ draggable={canEditDays && spanPhase !== 'middle'} onDragStart={e => { if (!canEditDays || spanPhase === 'middle') { e.preventDefault(); return } + // setData is required for the drag to start reliably (Firefox) and + // matches how place/note items initiate their drag. + e.dataTransfer.setData('reservationId', String(res.id)) + e.dataTransfer.setData('fromDayId', String(day.id)) e.dataTransfer.effectAllowed = 'move' dragDataRef.current = { reservationId: String(res.id), fromDayId: String(day.id), phase: spanPhase } setDraggingId(res.id) @@ -1893,7 +1971,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null; return } - if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return } + if (!assignmentId && !noteId && !fromReservationId) { dragDataRef.current = null; window.__dragData = null; return } if (assignmentId && fromDayId !== day.id) { tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return @@ -1909,6 +1987,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id, true) else if (noteId && String(lastItem?.data?.id) !== noteId) handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true) + else if (fromReservationId && String(lastItem?.data?.id) !== fromReservationId) + handleMergedDrop(day.id, 'transport', Number(fromReservationId), lastItem.type, lastItem.data.id, true) + setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null }} > {dropTargetKey === `end-${day.id}` && ( @@ -1919,15 +2000,21 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ {/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */} {isSelected && getDayAssignments(day.id).length >= 2 && (
- {routeInfo && ( -
- {routeInfo.distance} - · - {routeInfo.duration} -
- )} - -
+
+ - +
+ {(['driving', 'walking'] as const).map(p => { + const ModeIcon = p === 'driving' ? Car : Footprints + const active = routeProfile === p + return ( + + ) + })} +
+ {routeInfo && ( +
+ {routeInfo.distance} + · + {routeInfo.duration} +
+ )}
)} diff --git a/client/src/components/Weather/WeatherWidget.tsx b/client/src/components/Weather/WeatherWidget.tsx index 669f1875..a873e5c2 100644 --- a/client/src/components/Weather/WeatherWidget.tsx +++ b/client/src/components/Weather/WeatherWidget.tsx @@ -42,9 +42,11 @@ interface WeatherWidgetProps { lng: number | null date: string compact?: boolean + /** Vertical icon-over-temp layout that inherits its color (for the day badge). */ + stacked?: boolean } -export default function WeatherWidget({ lat, lng, date, compact = false }: WeatherWidgetProps) { +export default function WeatherWidget({ lat, lng, date, compact = false, stacked = false }: WeatherWidgetProps) { const [weather, setWeather] = useState(null) const [loading, setLoading] = useState(false) const [failed, setFailed] = useState(false) @@ -111,6 +113,15 @@ export default function WeatherWidget({ lat, lng, date, compact = false }: Weath const unit = isFahrenheit ? '°F' : '°C' const isClimate = weather.type === 'climate' + if (stacked) { + return ( +
+ + {temp !== null && {isClimate ? 'Ø' : ''}{temp}°} +
+ ) + } + if (compact) { return ( diff --git a/client/src/hooks/useRouteCalculation.ts b/client/src/hooks/useRouteCalculation.ts index 9c518a7b..13c15058 100644 --- a/client/src/hooks/useRouteCalculation.ts +++ b/client/src/hooks/useRouteCalculation.ts @@ -1,7 +1,7 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react' import { useSettingsStore } from '../store/settingsStore' import { useTripStore } from '../store/tripStore' -import { calculateSegments } from '../components/Map/RouteCalculator' +import { calculateRouteWithLegs } from '../components/Map/RouteCalculator' import type { TripStoreState } from '../store/tripStore' import type { RouteSegment, RouteResult } from '../types' @@ -12,7 +12,7 @@ const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'cruise'] * day assignments, draws a straight-line route, and optionally fetches per-segment * driving/walking durations via OSRM. Aborts in-flight requests when the day changes. */ -export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null) { +export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null, enabled: boolean = true, profile: 'driving' | 'walking' | 'cycling' = 'driving') { const [route, setRoute] = useState<[number, number][][] | null>(null) const [routeInfo, setRouteInfo] = useState(null) const [routeSegments, setRouteSegments] = useState([]) @@ -22,7 +22,8 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu const updateRouteForDay = useCallback(async (dayId: number | null) => { if (routeAbortRef.current) routeAbortRef.current.abort() - if (!dayId) { setRoute(null); setRouteSegments([]); return } + // Route is manual: only compute when explicitly enabled (the "show route" toggle). + if (!dayId || !enabled) { setRoute(null); setRouteSegments([]); return } // Read directly from store (not a render-phase ref) so callers after optimistic // updates or non-optimistic deletes always see the latest assignments. const currentAssignments = useTripStore.getState().assignments || {} @@ -67,35 +68,52 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu })), ].sort((a, b) => a.pos - b.pos) - const segments: [number, number][][] = [] - let currentSeg: [number, number][] = [] + // Group consecutive located places into runs, resetting whenever a transport + // appears (you don't drive between a flight's endpoints) — mirrors getMergedItems order. + const runs: { lat: number; lng: number }[][] = [] + let currentRun: { lat: number; lng: number }[] = [] for (const entry of entries) { if (entry.kind === 'place') { - currentSeg.push([entry.lat, entry.lng]) + currentRun.push({ lat: entry.lat, lng: entry.lng }) } else { - if (currentSeg.length >= 2) segments.push([...currentSeg]) - currentSeg = [] + if (currentRun.length >= 2) runs.push(currentRun) + currentRun = [] } } - if (currentSeg.length >= 2) segments.push(currentSeg) + if (currentRun.length >= 2) runs.push(currentRun) - const geocodedWaypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng) as { lat: number; lng: number }[] + const straightLines = (): [number, number][][] => + runs.map(r => r.map(p => [p.lat, p.lng] as [number, number])) - if (segments.length === 0 && geocodedWaypoints.length < 2) { - setRoute(null); setRouteSegments([]); return - } - setRoute(segments.length > 0 ? segments : null) + if (runs.length === 0) { setRoute(null); setRouteSegments([]); return } + + // Draw straight lines immediately for snappiness, then upgrade to the real + // OSRM road geometry. If route calc is disabled, keep the straight lines. + setRoute(straightLines()) if (!routeCalcEnabled) { setRouteSegments([]); return } + const controller = new AbortController() routeAbortRef.current = controller try { - const calcSegments = await calculateSegments(geocodedWaypoints, { signal: controller.signal }) - if (!controller.signal.aborted) setRouteSegments(calcSegments) + const polylines: [number, number][][] = [] + const allLegs: RouteSegment[] = [] + for (const run of runs) { + try { + const r = await calculateRouteWithLegs(run, { signal: controller.signal, profile }) + polylines.push(r.coordinates.length >= 2 ? r.coordinates : run.map(p => [p.lat, p.lng] as [number, number])) + allLegs.push(...r.legs) + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') throw err + // OSRM failed for this run — fall back to a straight line, no times. + polylines.push(run.map(p => [p.lat, p.lng] as [number, number])) + } + } + if (!controller.signal.aborted) { setRoute(polylines); setRouteSegments(allLegs) } } catch (err: unknown) { - if (err instanceof Error && err.name !== 'AbortError') setRouteSegments([]) - else if (!(err instanceof Error)) setRouteSegments([]) + // Aborted (day changed) — newer call owns the state. Anything else: keep straight lines. + if (!(err instanceof Error) || err.name !== 'AbortError') setRouteSegments([]) } - }, [routeCalcEnabled]) + }, [routeCalcEnabled, enabled, profile]) // Stable signature for transport reservations on the selected day — changes when a transport // is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders. @@ -117,7 +135,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu if (!selectedDayId) { setRoute(null); setRouteSegments([]); return } updateRouteForDay(selectedDayId) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedDayId, selectedDayAssignments, transportSignature]) + }, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile]) return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } } diff --git a/client/src/index.css b/client/src/index.css index 01341c38..ac1723fb 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -812,3 +812,21 @@ img[alt="TREK"] { .collab-note-md-full table { border-collapse: collapse; width: 100%; margin: 0.5em 0; } .collab-note-md-full th, .collab-note-md-full td { border: 1px solid var(--border-primary); padding: 4px 8px; font-size: 0.9em; } .collab-note-md-full hr { border: none; border-top: 1px solid var(--border-primary); margin: 0.8em 0; } + +/* Day-plan header action grid (edit / +transport / note / collapse) */ +.dp-day-actions button { + color: var(--text-faint); + background: transparent; + transition: background-color 0.12s ease, color 0.12s ease; +} +.dp-day-actions button:hover { + background: var(--bg-hover); + color: var(--text-primary); +} +/* Reveal the action grid only when hovering the day row (pointer devices). + Touch devices (hover: none) keep it visible; the selected day stays visible too. */ +@media (hover: hover) { + .dp-day-actions { opacity: 0; transition: opacity 0.12s ease; } + .dp-day-header:hover .dp-day-actions, + .dp-day-header[data-selected="true"] .dp-day-actions { opacity: 1; } +} diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index c19ed55c..f314bea8 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -269,6 +269,10 @@ export default function TripPlannerPage(): React.ReactElement | null { const [showTransportModal, setShowTransportModal] = useState(false) const [editingTransport, setEditingTransport] = useState(null) const [transportModalDayId, setTransportModalDayId] = useState(null) + // Manual route planning: off by default, toggled from the day-plan footer. Mode + // (driving/walking) is per-session and selects which travel time the connectors show. + const [routeShown, setRouteShown] = useState(false) + const [routeProfile, setRouteProfile] = useState<'driving' | 'walking'>('driving') const [fitKey, setFitKey] = useState(0) const initialFitTripId = useRef(null) const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null) @@ -398,7 +402,7 @@ export default function TripPlannerPage(): React.ReactElement | null { }) }, [places, mapCategoryFilter, mapPlacesFilter, assignments, expandedDayIds]) - const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId) + const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation({ assignments } as any, selectedDayId, routeShown, routeProfile) const handleSelectDay = useCallback((dayId, skipFit) => { const changed = dayId !== selectedDayId @@ -891,6 +895,10 @@ export default function TripPlannerPage(): React.ReactElement | null { onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} + routeShown={routeShown} + routeProfile={routeProfile} + onToggleRoute={() => setRouteShown(v => !v)} + onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => handleTabChange('dateien')} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} @@ -1117,7 +1125,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
{mobileSidebarOpen === 'left' - ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} /> + ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} /> : { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} /> }
diff --git a/client/src/types.ts b/client/src/types.ts index 9ce9b93b..0886ddf6 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -237,8 +237,19 @@ export interface RouteSegment { mid: [number, number] from: [number, number] to: [number, number] + distance: number + duration: number walkingText: string drivingText: string + distanceText: string + durationText?: string +} + +export interface RouteWithLegs { + coordinates: [number, number][] + distance: number + duration: number + legs: RouteSegment[] } export interface RouteResult { diff --git a/client/tests/integration/hooks/useRouteCalculation.test.ts b/client/tests/integration/hooks/useRouteCalculation.test.ts index 3be55819..18fc38d1 100644 --- a/client/tests/integration/hooks/useRouteCalculation.test.ts +++ b/client/tests/integration/hooks/useRouteCalculation.test.ts @@ -9,13 +9,13 @@ import type { RouteSegment } from '../../../src/types'; // Mock the RouteCalculator module to avoid real OSRM fetch calls vi.mock('../../../src/components/Map/RouteCalculator', () => ({ - calculateSegments: vi.fn(), + calculateRouteWithLegs: vi.fn(), calculateRoute: vi.fn(), optimizeRoute: vi.fn((waypoints: unknown[]) => waypoints), generateGoogleMapsUrl: vi.fn(), })); -const { calculateSegments } = await import('../../../src/components/Map/RouteCalculator'); +const { calculateRouteWithLegs } = await import('../../../src/components/Map/RouteCalculator'); function buildMockStore(assignments: Record[]> = {}): Partial { // Also populate the real Zustand store so updateRouteForDay (which reads from @@ -27,14 +27,23 @@ function buildMockStore(assignments: Record { beforeEach(() => { vi.clearAllMocks(); @@ -42,7 +51,7 @@ describe('useRouteCalculation', () => { useSettingsStore.setState({ settings: { route_calculation: false } as any }); // Reset trip store assignments so each test starts clean useTripStore.setState({ assignments: {} } as any); - (calculateSegments as ReturnType).mockResolvedValue(MOCK_SEGMENTS); + (calculateRouteWithLegs as ReturnType).mockResolvedValue(MOCK_ROUTE_WITH_LEGS); }); it('FE-HOOK-ROUTE-001: with no selectedDayId, route is null', () => { @@ -84,7 +93,7 @@ describe('useRouteCalculation', () => { ]); }); - it('FE-HOOK-ROUTE-004: with route_calculation enabled, calls calculateSegments', async () => { + it('FE-HOOK-ROUTE-004: with route_calculation enabled, calls calculateRouteWithLegs', async () => { useSettingsStore.setState({ settings: { route_calculation: true } as any }); const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 }); @@ -99,11 +108,11 @@ describe('useRouteCalculation', () => { await act(async () => {}); - expect(calculateSegments).toHaveBeenCalled(); + expect(calculateRouteWithLegs).toHaveBeenCalled(); expect(result.current.routeSegments).toEqual(MOCK_SEGMENTS); }); - it('FE-HOOK-ROUTE-005: with route_calculation disabled, does not call calculateSegments', async () => { + it('FE-HOOK-ROUTE-005: with route_calculation disabled, does not call calculateRouteWithLegs', async () => { useSettingsStore.setState({ settings: { route_calculation: false } as any }); const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 }); @@ -118,7 +127,7 @@ describe('useRouteCalculation', () => { await act(async () => {}); - expect(calculateSegments).not.toHaveBeenCalled(); + expect(calculateRouteWithLegs).not.toHaveBeenCalled(); expect(result.current.routeSegments).toEqual([]); }); @@ -163,13 +172,13 @@ describe('useRouteCalculation', () => { it('FE-HOOK-ROUTE-008: AbortController.abort() is called when selectedDayId changes', async () => { useSettingsStore.setState({ settings: { route_calculation: true } as any }); - // Make calculateSegments resolve slowly - let resolveSegments!: (val: RouteSegment[]) => void; - (calculateSegments as ReturnType).mockImplementationOnce( + // Make calculateRouteWithLegs resolve slowly + let resolveSegments!: (val: typeof MOCK_ROUTE_WITH_LEGS) => void; + (calculateRouteWithLegs as ReturnType).mockImplementationOnce( (_waypoints: unknown[], options: { signal?: AbortSignal }) => { - return new Promise((resolve) => { + return new Promise((resolve) => { resolveSegments = resolve; - options?.signal?.addEventListener('abort', () => resolve([])); + options?.signal?.addEventListener('abort', () => resolve(MOCK_ROUTE_WITH_LEGS)); }); } ); @@ -191,12 +200,12 @@ describe('useRouteCalculation', () => { rerender({ dayId: 6 }); }); - // calculateSegments should have been called at least once for day 5 + // calculateRouteWithLegs should have been called at least once for day 5 // and once more for day 6 - expect((calculateSegments as ReturnType).mock.calls.length).toBeGreaterThanOrEqual(1); + expect((calculateRouteWithLegs as ReturnType).mock.calls.length).toBeGreaterThanOrEqual(1); // Cleanup - resolveSegments?.([]); + resolveSegments?.(MOCK_ROUTE_WITH_LEGS); }); it('FE-HOOK-ROUTE-009: AbortError from calculateSegments does not set routeSegments to []', async () => { @@ -204,7 +213,7 @@ describe('useRouteCalculation', () => { const abortError = new Error('Aborted'); abortError.name = 'AbortError'; - (calculateSegments as ReturnType).mockRejectedValueOnce(abortError); + (calculateRouteWithLegs as ReturnType).mockRejectedValueOnce(abortError); const p1 = buildPlace({ lat: 10, lng: 10 }); const p2 = buildPlace({ lat: 20, lng: 20 }); @@ -224,7 +233,7 @@ describe('useRouteCalculation', () => { it('FE-HOOK-ROUTE-010: non-AbortError from calculateSegments sets routeSegments to []', async () => { useSettingsStore.setState({ settings: { route_calculation: true } as any }); - (calculateSegments as ReturnType).mockRejectedValueOnce(new Error('Network error')); + (calculateRouteWithLegs as ReturnType).mockRejectedValueOnce(new Error('Network error')); const p1 = buildPlace({ lat: 10, lng: 10 }); const p2 = buildPlace({ lat: 20, lng: 20 }); diff --git a/server/src/app.ts b/server/src/app.ts index 06202a6a..a3fd89b9 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -134,7 +134,7 @@ export function createApp(): express.Application { "https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com", "https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com", "https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson", - "https://router.project-osrm.org/route/v1/", + "https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/", "https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com" ], workerSrc: ["'self'", "blob:"],