mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 22:31:46 +00:00
Compare commits
3 Commits
7c4bf3a5df
...
f3350095b1
| Author | SHA1 | Date | |
|---|---|---|---|
| f3350095b1 | |||
| 324d930ca3 | |||
| e050814c42 |
@@ -20,7 +20,6 @@ type Defaults = {
|
||||
temperature_unit?: string
|
||||
dark_mode?: string | boolean
|
||||
time_format?: string
|
||||
route_calculation?: boolean
|
||||
blur_booking_codes?: boolean
|
||||
map_tile_url?: string
|
||||
}
|
||||
@@ -208,22 +207,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Route Calculation */}
|
||||
<OptionRow label={<>{t('settings.routeCalculation')} <ResetButton field="route_calculation" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={String(opt.value)}
|
||||
active={defaults.route_calculation === opt.value}
|
||||
onClick={() => save({ route_calculation: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{/* Blur Booking Codes */}
|
||||
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
||||
{([
|
||||
|
||||
@@ -128,7 +128,8 @@ describe('MapView', () => {
|
||||
|
||||
it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => {
|
||||
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} />)
|
||||
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(<MapView route={route} routeSegments={routeSegments} />)
|
||||
// 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(<MapView route={route} />)
|
||||
// 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', () => {
|
||||
|
||||
@@ -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: `<div style="
|
||||
display:flex;align-items:center;gap:5px;
|
||||
background:rgba(0,0,0,0.85);backdrop-filter:blur(8px);
|
||||
color:#fff;border-radius:99px;padding:3px 9px;
|
||||
font-size:9px;font-weight:600;white-space:nowrap;
|
||||
font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
|
||||
box-shadow:0 2px 12px rgba(0,0,0,0.3);
|
||||
pointer-events:none;
|
||||
position:relative;left:-50%;top:-50%;
|
||||
">
|
||||
<span style="display:flex;align-items:center;gap:2px">
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="13" cy="4" r="2"/><path d="M7 21l3-7"/><path d="M10 14l5-5"/><path d="M15 9l-4 7"/><path d="M18 18l-3-7"/></svg>
|
||||
${walkingText}
|
||||
</span>
|
||||
<span style="opacity:0.3">|</span>
|
||||
<span style="display:flex;align-items:center;gap:2px">
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9L18 10l-2-4H7L5 10l-2.5 1.1C1.7 11.3 1 12.1 1 13v3c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><circle cx="17" cy="17" r="2"/></svg>
|
||||
${drivingText}
|
||||
</span>
|
||||
</div>`,
|
||||
iconSize: [0, 0],
|
||||
iconAnchor: [0, 0],
|
||||
})
|
||||
|
||||
return <Marker position={midpoint} icon={icon} interactive={false} zIndexOffset={2000} />
|
||||
}
|
||||
// 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}
|
||||
</MarkerClusterGroup>
|
||||
|
||||
{route && route.length > 0 && (
|
||||
<>
|
||||
{route.map((seg, i) => seg.length > 1 && (
|
||||
<Polyline
|
||||
key={i}
|
||||
positions={seg}
|
||||
color="#111827"
|
||||
weight={3}
|
||||
opacity={0.9}
|
||||
dashArray="6, 5"
|
||||
/>
|
||||
))}
|
||||
{routeSegments.map((seg, i) => (
|
||||
<RouteLabel key={i} midpoint={seg.mid} from={seg.from} to={seg.to} walkingText={seg.walkingText} drivingText={seg.drivingText} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded. */}
|
||||
{route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [
|
||||
<Polyline
|
||||
key={`${i}-casing`}
|
||||
positions={seg}
|
||||
pathOptions={{ color: '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
/>,
|
||||
<Polyline
|
||||
key={`${i}-core`}
|
||||
positions={seg}
|
||||
pathOptions={{ color: '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
/>,
|
||||
] : [])}
|
||||
|
||||
{/* GPX imported route geometries */}
|
||||
{gpxPolylines}
|
||||
|
||||
@@ -163,7 +163,6 @@ export function MapViewGL({
|
||||
const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map())
|
||||
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
|
||||
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
|
||||
const routeLabelMarkersRef = useRef<mapboxgl.Marker[]>([])
|
||||
// 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 = `<div style="display:flex;align-items:center;gap:5px;background:rgba(0,0,0,0.85);backdrop-filter:blur(8px);color:#fff;border-radius:99px;padding:3px 9px;font-size:9px;font-weight:600;white-space:nowrap;font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif;box-shadow:0 2px 12px rgba(0,0,0,0.3);">
|
||||
<span style="display:flex;align-items:center;gap:2px"><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="13" cy="4" r="2"/><path d="M7 21l3-7"/><path d="M10 14l5-5"/><path d="M15 9l-4 7"/><path d="M18 18l-3-7"/></svg>${seg.walkingText ?? ''}</span>
|
||||
<span style="opacity:0.3">|</span>
|
||||
<span style="display:flex;align-items:center;gap:2px"><svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9L18 10l-2-4H7L5 10l-2.5 1.1C1.7 11.3 1 12.1 1 13v3c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><circle cx="17" cy="17" r="2"/></svg>${seg.drivingText ?? ''}</span>
|
||||
</div>`
|
||||
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(() => {
|
||||
|
||||
@@ -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<string, RouteWithLegs>()
|
||||
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<RouteWithLegs> {
|
||||
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`
|
||||
|
||||
@@ -268,14 +268,7 @@ describe('DayPlanSidebar', () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
// 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(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
|
||||
// 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(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
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(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
|
||||
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')
|
||||
|
||||
@@ -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<number>) => 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 (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '3px 14px', fontSize: 10.5, color: 'var(--text-faint)', lineHeight: 1.2 }}>
|
||||
<div style={line} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
||||
<Icon size={11} strokeWidth={2} />
|
||||
<span>{seg.durationText ?? (driving ? seg.drivingText : seg.walkingText)}</span>
|
||||
<span style={{ opacity: 0.4 }}>·</span>
|
||||
<span>{seg.distanceText}</span>
|
||||
</div>
|
||||
<div style={line} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -251,6 +278,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<Record<number, RouteSegment>>({})
|
||||
const legsAbortRef = useRef<AbortController | null>(null)
|
||||
const [draggingId, setDraggingId] = useState(null)
|
||||
const [lockedIds, setLockedIds] = useState(new Set())
|
||||
const [lockHoverId, setLockHoverId] = useState(null)
|
||||
@@ -472,6 +501,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 || !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<number, RouteSegment> = {}
|
||||
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, routeShown, routeProfile, mergedItemsMap])
|
||||
|
||||
const openAddNote = (dayId, e) => {
|
||||
e?.stopPropagation()
|
||||
_openAddNote(dayId, getMergedItems, (id) => {
|
||||
@@ -792,13 +857,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 +1105,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
|
||||
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
|
||||
<div
|
||||
className="dp-day-header"
|
||||
data-selected={isSelected}
|
||||
onClick={() => { 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 +1126,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 */}
|
||||
<div style={{
|
||||
width: 26, height: 26, borderRadius: '50%', flexShrink: 0,
|
||||
background: isSelected ? 'var(--accent)' : 'var(--bg-hover)',
|
||||
color: isSelected ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 11, fontWeight: 700,
|
||||
}}>
|
||||
{index + 1}
|
||||
</div>
|
||||
{/* 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 (
|
||||
<div style={{
|
||||
flexShrink: 0, alignSelf: 'flex-start',
|
||||
width: hasWeather ? 34 : 26,
|
||||
borderRadius: hasWeather ? 11 : '50%',
|
||||
background: isSelected ? 'var(--accent)' : 'var(--bg-hover)',
|
||||
color: isSelected ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ width: '100%', height: 26, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 700 }}>
|
||||
{index + 1}
|
||||
</div>
|
||||
{hasWeather && (
|
||||
<>
|
||||
<div style={{ width: '64%', height: 1, background: 'currentColor', opacity: 0.25 }} />
|
||||
<div style={{ padding: '3px 0 4px' }}>
|
||||
<WeatherWidget lat={wLat} lng={wLng} date={day.date} stacked />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{editingDayId === day.id ? (
|
||||
@@ -1093,40 +1171,27 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
borderBottom: '1.5px solid var(--text-primary)',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, minWidth: 0 }}>
|
||||
) : (<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 1, minWidth: 0 }}>
|
||||
{day.title || t('dayplan.dayN', { n: index + 1 })}
|
||||
</span>
|
||||
{canEditDays && <button
|
||||
onClick={e => startEditTitle(day, e)}
|
||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: '4px', cursor: 'pointer', opacity: 0.35, display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<Pencil size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
||||
</button>}
|
||||
{canEditDays && onAddTransport && (
|
||||
<Tooltip label={t('transport.addTransport')} placement="top">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onAddTransport(day.id) }}
|
||||
aria-label={t('transport.addTransport')}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: '4px',
|
||||
cursor: 'pointer',
|
||||
opacity: 0.45,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.opacity = '1' }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.opacity = '0.45' }}
|
||||
>
|
||||
<Plus size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{formattedDate && (
|
||||
<>
|
||||
<span style={{ flexShrink: 0, width: 1, height: 11, background: 'var(--border-primary)' }} />
|
||||
<span style={{ flexShrink: 0, fontSize: 11, fontWeight: 400, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>
|
||||
{formattedDate}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
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 <div style={{ height: 1, background: 'var(--border-faint)', margin: '5px 0 5px' }} />
|
||||
})()}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'nowrap', minWidth: 0 }}>
|
||||
{(() => {
|
||||
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 +1210,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 (
|
||||
<span key={acc.id} onClick={e => { 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' }}>
|
||||
<Hotel size={8} style={{ color: iconColor, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{(acc as any).place_name || (acc as any).reservation_title}</span>
|
||||
<span key={acc.id} onClick={e => { 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' }}>
|
||||
<Hotel size={11} strokeWidth={1.8} style={{ color: iconColor, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 10.5, color: 'var(--text-muted)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{(acc as any).place_name || (acc as any).reservation_title}</span>
|
||||
</span>
|
||||
)
|
||||
})
|
||||
@@ -1161,41 +1224,50 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const activeRentals = getActiveRentalsForDay(day.id)
|
||||
if (activeRentals.length === 0) return null
|
||||
return activeRentals.map(r => (
|
||||
<span key={`rental-${r.id}`} onClick={e => { 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' }}>
|
||||
<Car size={8} style={{ color: '#3b82f6', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
||||
<span key={`rental-${r.id}`} onClick={e => { 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' }}>
|
||||
<Car size={11} strokeWidth={1.8} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 10.5, color: 'var(--text-muted)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
||||
</span>
|
||||
))
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{cost && (
|
||||
<div style={{ marginTop: 2 }}>
|
||||
<span style={{ fontSize: 11, color: '#059669' }}>{cost}</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 2, flexWrap: 'wrap' }}>
|
||||
{formattedDate && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formattedDate}</span>}
|
||||
{cost && <span style={{ fontSize: 11, color: '#059669' }}>{cost}</span>}
|
||||
{day.date && anyGeoPlace && <span style={{ width: 1, height: 10, background: 'var(--text-faint)', opacity: 0.3, flexShrink: 0 }} />}
|
||||
{day.date && anyGeoPlace && (() => {
|
||||
const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat
|
||||
const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng
|
||||
return <WeatherWidget lat={wLat} lng={wLng} date={day.date} compact />
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canEditDays && <Tooltip label={t('dayplan.addNote')} placement="top"><button
|
||||
onClick={e => openAddNote(day.id, e)}
|
||||
aria-label={t('dayplan.addNote')}
|
||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
|
||||
>
|
||||
<FileText size={16} strokeWidth={2} />
|
||||
</button></Tooltip>}
|
||||
<button
|
||||
onClick={e => toggleDay(day.id, e)}
|
||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={18} strokeWidth={2} /> : <ChevronRight size={18} strokeWidth={2} />}
|
||||
</button>
|
||||
{canEditDays ? (
|
||||
(() => {
|
||||
const cell = { padding: 7, cursor: 'pointer', display: 'grid', placeItems: 'center' } as const
|
||||
const div = '1px solid var(--border-faint)'
|
||||
return (
|
||||
<div className="dp-day-actions" style={{ alignSelf: 'flex-start', flexShrink: 0, display: 'grid', gridTemplateColumns: '1fr 1fr', border: div, borderRadius: 9, overflow: 'hidden' }}>
|
||||
<button onClick={e => startEditTitle(day, e)} aria-label={t('common.edit')} style={{ ...cell, border: 'none', borderRight: div, borderBottom: div }}>
|
||||
<Pencil size={14} strokeWidth={1.8} />
|
||||
</button>
|
||||
{onAddTransport ? (
|
||||
<button onClick={e => { e.stopPropagation(); onAddTransport(day.id) }} title={t('transport.addTransport')} style={{ ...cell, border: 'none', borderBottom: div }}>
|
||||
<Plus size={14} strokeWidth={1.8} />
|
||||
</button>
|
||||
) : <div style={{ borderBottom: div }} />}
|
||||
<button onClick={e => openAddNote(day.id, e)} aria-label={t('dayplan.addNote')} style={{ ...cell, border: 'none', borderRight: div }}>
|
||||
<FileText size={14} strokeWidth={1.8} />
|
||||
</button>
|
||||
<button onClick={e => toggleDay(day.id, e)} title={isExpanded ? t('common.collapse') : t('common.expand')} style={{ ...cell, border: 'none' }}>
|
||||
{isExpanded ? <ChevronDown size={15} strokeWidth={1.8} /> : <ChevronRight size={15} strokeWidth={1.8} />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<button onClick={e => toggleDay(day.id, e)} style={{ alignSelf: 'flex-start', flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}>
|
||||
{isExpanded ? <ChevronDown size={16} strokeWidth={1.8} /> : <ChevronRight size={16} strokeWidth={1.8} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Aufgeklappte Orte + Notizen */}
|
||||
@@ -1607,6 +1679,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{routeLegs[assignment.id] && <RouteConnector seg={routeLegs[assignment.id]} profile={routeProfile} />}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
@@ -1656,6 +1729,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 +1970,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 +1986,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 +1999,21 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
||||
{isSelected && getDayAssignments(day.id).length >= 2 && (
|
||||
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{routeInfo && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, color: 'var(--text-secondary)', background: 'var(--bg-hover)', borderRadius: 8, padding: '5px 10px' }}>
|
||||
<span>{routeInfo.distance}</span>
|
||||
<span style={{ color: 'var(--text-faint)' }}>·</span>
|
||||
<span>{routeInfo.duration}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
|
||||
<button
|
||||
onClick={() => onToggleRoute?.()}
|
||||
style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
padding: '6px 0', fontSize: 11, fontWeight: 600, borderRadius: 8,
|
||||
border: routeShown ? 'none' : '1px solid var(--border-faint)',
|
||||
background: routeShown ? 'var(--accent)' : 'transparent',
|
||||
color: routeShown ? 'var(--accent-text)' : 'var(--text-secondary)',
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<RouteIcon size={12} strokeWidth={2} />
|
||||
{t('dayplan.route')}
|
||||
</button>
|
||||
<button onClick={handleOptimize} style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
||||
@@ -1936,14 +2022,35 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
<RotateCcw size={12} strokeWidth={2} />
|
||||
{t('dayplan.optimize')}
|
||||
</button>
|
||||
<button onClick={handleGoogleMaps} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '6px 10px', fontSize: 11, fontWeight: 500, borderRadius: 8,
|
||||
border: '1px solid var(--border-faint)', background: 'transparent', color: 'var(--text-secondary)', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<ExternalLink size={12} strokeWidth={2} />
|
||||
</button>
|
||||
<div style={{ display: 'flex', borderRadius: 8, overflow: 'hidden', border: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||
{(['driving', 'walking'] as const).map(p => {
|
||||
const ModeIcon = p === 'driving' ? Car : Footprints
|
||||
const active = routeProfile === p
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => onSetRouteProfile?.(p)}
|
||||
aria-label={p === 'driving' ? 'Driving' : 'Walking'}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '6px 10px', border: 'none', cursor: 'pointer',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
color: active ? 'var(--accent-text)' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
<ModeIcon size={13} strokeWidth={2} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{routeInfo && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, color: 'var(--text-secondary)', background: 'var(--bg-hover)', borderRadius: 8, padding: '5px 10px' }}>
|
||||
<span>{routeInfo.distance}</span>
|
||||
<span style={{ color: 'var(--text-faint)' }}>·</span>
|
||||
<span>{routeInfo.duration}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: false, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false, route_calculation: false } });
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: false, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false } });
|
||||
});
|
||||
|
||||
describe('ReservationsPanel', () => {
|
||||
@@ -211,7 +211,7 @@ describe('ReservationsPanel', () => {
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESP-022: confirmation number is blurred when blur_booking_codes=true', () => {
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false, route_calculation: false } });
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false } });
|
||||
const res = buildReservation({ confirmation_number: 'ABC123', status: 'confirmed' });
|
||||
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
|
||||
const codeEl = screen.getByText('ABC123');
|
||||
@@ -220,7 +220,7 @@ describe('ReservationsPanel', () => {
|
||||
|
||||
it('FE-PLANNER-RESP-023: confirmation code revealed on hover when blurred', async () => {
|
||||
const user = userEvent.setup();
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false, route_calculation: false } });
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', default_lat: 48.8566, default_lng: 2.3522, default_zoom: 10, map_tile_url: '', show_place_description: false } });
|
||||
const res = buildReservation({ confirmation_number: 'ABC123', status: 'confirmed' });
|
||||
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
|
||||
const codeEl = screen.getByText('ABC123');
|
||||
|
||||
@@ -161,29 +161,6 @@ describe('DisplaySettingsTab', () => {
|
||||
expect(updateSetting).toHaveBeenCalledWith('time_format', '24h');
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-021: shows Route Calculation section', () => {
|
||||
render(<DisplaySettingsTab />);
|
||||
expect(screen.getByText(/route calculation/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-022: route calculation On button is active when route_calculation is true', () => {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ route_calculation: true }) });
|
||||
render(<DisplaySettingsTab />);
|
||||
const onButtons = screen.getAllByText(/^On$/i);
|
||||
const routeCalcOnBtn = onButtons[0].closest('button')!;
|
||||
expect(routeCalcOnBtn.style.border).toContain('var(--text-primary)');
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-023: clicking route calculation Off calls updateSetting with false', async () => {
|
||||
const user = userEvent.setup();
|
||||
const updateSetting = vi.fn().mockResolvedValue(undefined);
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ route_calculation: true }), updateSetting });
|
||||
render(<DisplaySettingsTab />);
|
||||
const offButtons = screen.getAllByText(/^Off$/i);
|
||||
await user.click(offButtons[0]);
|
||||
expect(updateSetting).toHaveBeenCalledWith('route_calculation', false);
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-024: shows Blur Booking Codes section', () => {
|
||||
render(<DisplaySettingsTab />);
|
||||
expect(screen.getByText(/blur booking codes/i)).toBeInTheDocument();
|
||||
|
||||
@@ -214,36 +214,6 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Route Calculation */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.routeCalculation')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('route_calculation', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: (settings.route_calculation !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: (settings.route_calculation !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Booking route labels */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.bookingLabels')}</label>
|
||||
|
||||
@@ -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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1, fontSize: 9.5, fontWeight: 600, lineHeight: 1, color: 'inherit', ...fontStyle }}>
|
||||
<WeatherIcon main={weather.main} size={13} />
|
||||
{temp !== null && <span>{isClimate ? 'Ø' : ''}{temp}°</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, color: isClimate ? '#a1a1aa' : '#6b7280', ...fontStyle }}>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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'
|
||||
|
||||
@@ -9,20 +8,20 @@ const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'cruise']
|
||||
|
||||
/**
|
||||
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
|
||||
* 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.
|
||||
* day assignments, draws a straight-line route immediately, then upgrades it to real OSRM
|
||||
* road geometry with per-segment durations. 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<RouteResult | null>(null)
|
||||
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
||||
const routeCalcEnabled = useSettingsStore((s) => s.settings.route_calculation) !== false
|
||||
const routeAbortRef = useRef<AbortController | null>(null)
|
||||
const reservationsForSignature = useTripStore((s) => s.reservations)
|
||||
|
||||
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 +66,51 @@ 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 (runs.length === 0) { setRoute(null); setRouteSegments([]); return }
|
||||
|
||||
// Draw straight lines immediately for snappiness, then upgrade to the real
|
||||
// OSRM road geometry.
|
||||
setRoute(straightLines())
|
||||
|
||||
if (segments.length === 0 && geocodedWaypoints.length < 2) {
|
||||
setRoute(null); setRouteSegments([]); return
|
||||
}
|
||||
setRoute(segments.length > 0 ? segments : null)
|
||||
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])
|
||||
}, [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 +132,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 }
|
||||
}
|
||||
|
||||
@@ -200,7 +200,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.language': 'اللغة',
|
||||
'settings.temperature': 'وحدة الحرارة',
|
||||
'settings.timeFormat': 'تنسيق الوقت',
|
||||
'settings.routeCalculation': 'حساب المسار',
|
||||
'settings.blurBookingCodes': 'إخفاء رموز الحجز',
|
||||
'settings.notifications': 'الإشعارات',
|
||||
'settings.notifyTripInvite': 'دعوات الرحلات',
|
||||
|
||||
@@ -195,7 +195,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.language': 'Idioma',
|
||||
'settings.temperature': 'Unidade de temperatura',
|
||||
'settings.timeFormat': 'Formato de hora',
|
||||
'settings.routeCalculation': 'Cálculo de rota',
|
||||
'settings.blurBookingCodes': 'Ocultar códigos de reserva',
|
||||
'settings.notifications': 'Notificações',
|
||||
'settings.notifyTripInvite': 'Convites de viagem',
|
||||
|
||||
@@ -196,7 +196,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.language': 'Jazyk',
|
||||
'settings.temperature': 'Jednotky teploty',
|
||||
'settings.timeFormat': 'Formát času',
|
||||
'settings.routeCalculation': 'Výpočet trasy',
|
||||
'settings.blurBookingCodes': 'Skrýt rezervační kódy',
|
||||
'settings.notifications': 'Oznámení',
|
||||
'settings.notifyTripInvite': 'Pozvánky na cesty',
|
||||
|
||||
@@ -198,7 +198,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.language': 'Sprache',
|
||||
'settings.temperature': 'Temperatureinheit',
|
||||
'settings.timeFormat': 'Zeitformat',
|
||||
'settings.routeCalculation': 'Routenberechnung',
|
||||
'settings.bookingLabels': 'Orts-Labels auf Buchungsrouten',
|
||||
'settings.bookingLabelsHint': 'Zeigt Bahnhofs-/Flughafennamen auf der Karte. Wenn aus, wird nur das Icon angezeigt.',
|
||||
'settings.blurBookingCodes': 'Buchungscodes verbergen',
|
||||
|
||||
@@ -212,7 +212,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.language': 'Language',
|
||||
'settings.temperature': 'Temperature Unit',
|
||||
'settings.timeFormat': 'Time Format',
|
||||
'settings.routeCalculation': 'Route Calculation',
|
||||
'settings.bookingLabels': 'Booking route labels',
|
||||
'settings.bookingLabelsHint': 'Show station / airport names on the map. When off, only the icon is shown.',
|
||||
'settings.blurBookingCodes': 'Blur Booking Codes',
|
||||
|
||||
@@ -196,7 +196,6 @@ const es: Record<string, string> = {
|
||||
'settings.language': 'Idioma',
|
||||
'settings.temperature': 'Unidad de temperatura',
|
||||
'settings.timeFormat': 'Formato de hora',
|
||||
'settings.routeCalculation': 'Cálculo de ruta',
|
||||
'settings.blurBookingCodes': 'Difuminar códigos de reserva',
|
||||
'settings.notifications': 'Notificaciones',
|
||||
'settings.notifyTripInvite': 'Invitaciones de viaje',
|
||||
|
||||
@@ -195,7 +195,6 @@ const fr: Record<string, string> = {
|
||||
'settings.language': 'Langue',
|
||||
'settings.temperature': 'Unité de température',
|
||||
'settings.timeFormat': 'Format de l\'heure',
|
||||
'settings.routeCalculation': 'Calcul d\'itinéraire',
|
||||
'settings.blurBookingCodes': 'Masquer les codes de réservation',
|
||||
'settings.notifications': 'Notifications',
|
||||
'settings.notifyTripInvite': 'Invitations de voyage',
|
||||
|
||||
@@ -195,7 +195,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.language': 'Nyelv',
|
||||
'settings.temperature': 'Hőmérséklet egység',
|
||||
'settings.timeFormat': 'Időformátum',
|
||||
'settings.routeCalculation': 'Útvonalszámítás',
|
||||
'settings.blurBookingCodes': 'Foglalási kódok elrejtése',
|
||||
'settings.notifications': 'Értesítések',
|
||||
'settings.notifyTripInvite': 'Utazási meghívók',
|
||||
|
||||
@@ -198,7 +198,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.language': 'Bahasa',
|
||||
'settings.temperature': 'Satuan Suhu',
|
||||
'settings.timeFormat': 'Format Waktu',
|
||||
'settings.routeCalculation': 'Perhitungan Rute',
|
||||
'settings.blurBookingCodes': 'Sembunyikan Kode Pemesanan',
|
||||
'settings.notifications': 'Notifikasi',
|
||||
'settings.notifyTripInvite': 'Undangan perjalanan',
|
||||
|
||||
@@ -195,7 +195,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.language': 'Lingua',
|
||||
'settings.temperature': 'Unità di Temperatura',
|
||||
'settings.timeFormat': 'Formato Ora',
|
||||
'settings.routeCalculation': 'Calcolo Percorso',
|
||||
'settings.blurBookingCodes': 'Nascondi codici di prenotazione',
|
||||
'settings.notifications': 'Notifiche',
|
||||
'settings.notifyTripInvite': 'Inviti di viaggio',
|
||||
|
||||
@@ -212,7 +212,6 @@ const ja: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.language': '言語',
|
||||
'settings.temperature': '温度単位',
|
||||
'settings.timeFormat': '時刻形式',
|
||||
'settings.routeCalculation': '経路計算',
|
||||
'settings.bookingLabels': '予約ルートのラベル',
|
||||
'settings.bookingLabelsHint': '地図に駅・空港名を表示。オフ時はアイコンのみ。',
|
||||
'settings.blurBookingCodes': '予約コードをぼかす',
|
||||
|
||||
@@ -212,7 +212,6 @@ const ko: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.language': '언어',
|
||||
'settings.temperature': '온도 단위',
|
||||
'settings.timeFormat': '시간 형식',
|
||||
'settings.routeCalculation': '경로 계산',
|
||||
'settings.bookingLabels': '예약 경로 레이블',
|
||||
'settings.bookingLabelsHint': '지도에 역 / 공항 이름을 표시합니다. 끄면 아이콘만 표시됩니다.',
|
||||
'settings.blurBookingCodes': '예약 코드 흐리게',
|
||||
|
||||
@@ -195,7 +195,6 @@ const nl: Record<string, string> = {
|
||||
'settings.language': 'Taal',
|
||||
'settings.temperature': 'Temperatuureenheid',
|
||||
'settings.timeFormat': 'Tijdnotatie',
|
||||
'settings.routeCalculation': 'Routeberekening',
|
||||
'settings.blurBookingCodes': 'Boekingscodes vervagen',
|
||||
'settings.notifications': 'Meldingen',
|
||||
'settings.notifyTripInvite': 'Reisuitnodigingen',
|
||||
|
||||
@@ -178,7 +178,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.language': 'Język',
|
||||
'settings.temperature': 'Jednostka temperatury',
|
||||
'settings.timeFormat': 'Format czasu',
|
||||
'settings.routeCalculation': 'Obliczanie trasy',
|
||||
'settings.blurBookingCodes': 'Rozmyj kody rezerwacji',
|
||||
'settings.notifications': 'Powiadomienia',
|
||||
'settings.notifyTripInvite': 'Zaproszenia do podróży',
|
||||
|
||||
@@ -195,7 +195,6 @@ const ru: Record<string, string> = {
|
||||
'settings.language': 'Язык',
|
||||
'settings.temperature': 'Единица температуры',
|
||||
'settings.timeFormat': 'Формат времени',
|
||||
'settings.routeCalculation': 'Расчёт маршрута',
|
||||
'settings.blurBookingCodes': 'Скрыть коды бронирования',
|
||||
'settings.notifications': 'Уведомления',
|
||||
'settings.notifyTripInvite': 'Приглашения в поездку',
|
||||
|
||||
@@ -212,7 +212,6 @@ const tr: Record<string, string | { name: string; category: string }[]> = {
|
||||
'settings.language': 'Dil',
|
||||
'settings.temperature': 'Sıcaklık birimi',
|
||||
'settings.timeFormat': 'Saat biçimi',
|
||||
'settings.routeCalculation': 'Route Calculation',
|
||||
'settings.bookingLabels': 'Booking route labels',
|
||||
'settings.bookingLabelsHint': 'Show station / airport names on the map. When off, only the icon is shown.',
|
||||
'settings.blurBookingCodes': 'Blur Booking Codes',
|
||||
|
||||
@@ -209,7 +209,6 @@ const uk: Record<string, string> = {
|
||||
'settings.language': 'Мова',
|
||||
'settings.temperature': 'Одиниця температури',
|
||||
'settings.timeFormat': 'Формат часу',
|
||||
'settings.routeCalculation': 'Розрахунок маршруту',
|
||||
'settings.blurBookingCodes': 'Приховати коди бронювання',
|
||||
'settings.notifications': 'Сповіщення',
|
||||
'settings.notifyTripInvite': 'Запрошення до поїздки',
|
||||
|
||||
@@ -195,7 +195,6 @@ const zh: Record<string, string> = {
|
||||
'settings.language': '语言',
|
||||
'settings.temperature': '温度单位',
|
||||
'settings.timeFormat': '时间格式',
|
||||
'settings.routeCalculation': '路线计算',
|
||||
'settings.blurBookingCodes': '模糊预订代码',
|
||||
'settings.notifications': '通知',
|
||||
'settings.notifyTripInvite': '旅行邀请',
|
||||
|
||||
@@ -195,7 +195,6 @@ const zhTw: Record<string, string> = {
|
||||
'settings.language': '語言',
|
||||
'settings.temperature': '溫度單位',
|
||||
'settings.timeFormat': '時間格式',
|
||||
'settings.routeCalculation': '路線計算',
|
||||
'settings.blurBookingCodes': '模糊預訂程式碼',
|
||||
'settings.notifications': '通知',
|
||||
'settings.notifyTripInvite': '旅行邀請',
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -857,7 +857,6 @@ describe('DashboardPage', () => {
|
||||
temperature_unit: 'fahrenheit',
|
||||
time_format: '12h',
|
||||
show_place_description: false,
|
||||
route_calculation: false,
|
||||
blur_booking_codes: false,
|
||||
dashboard_currency: 'on',
|
||||
dashboard_timezone: 'on',
|
||||
|
||||
@@ -269,6 +269,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
|
||||
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
|
||||
const [transportModalDayId, setTransportModalDayId] = useState<number | null>(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<number>(0)
|
||||
const initialFitTripId = useRef<number | null>(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
|
||||
@@ -826,7 +830,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
hasInspector={!!selectedPlace}
|
||||
hasDayDetail={!!showDayDetail && !selectedPlace}
|
||||
reservations={reservations}
|
||||
showReservationStats={settings.route_calculation !== false}
|
||||
showReservationStats={true}
|
||||
visibleConnectionIds={visibleConnections}
|
||||
onReservationClick={(rid) => {
|
||||
const r = reservations.find(x => x.id === rid)
|
||||
@@ -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 {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { 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 }} />
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { 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 }} />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { 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 }} />
|
||||
}
|
||||
</div>
|
||||
|
||||
+11
-1
@@ -215,7 +215,6 @@ export interface Settings {
|
||||
temperature_unit: string
|
||||
time_format: string
|
||||
show_place_description: boolean
|
||||
route_calculation?: boolean
|
||||
blur_booking_codes?: boolean
|
||||
map_booking_labels?: boolean
|
||||
map_provider?: 'leaflet' | 'mapbox-gl'
|
||||
@@ -237,8 +236,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 {
|
||||
|
||||
@@ -258,7 +258,6 @@ export function buildSettings(overrides: Partial<Settings> = {}): Settings {
|
||||
temperature_unit: 'fahrenheit',
|
||||
time_format: '12h',
|
||||
show_place_description: false,
|
||||
route_calculation: false,
|
||||
blur_booking_codes: false,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useRouteCalculation } from '../../../src/hooks/useRouteCalculation';
|
||||
import { useSettingsStore } from '../../../src/store/settingsStore';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { buildAssignment, buildPlace } from '../../helpers/factories';
|
||||
import type { TripStoreState } from '../../../src/store/tripStore';
|
||||
@@ -9,13 +8,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<string, ReturnType<typeof buildAssignment>[]> = {}): Partial<TripStoreState> {
|
||||
// Also populate the real Zustand store so updateRouteForDay (which reads from
|
||||
@@ -27,22 +26,29 @@ function buildMockStore(assignments: Record<string, ReturnType<typeof buildAssig
|
||||
|
||||
const MOCK_SEGMENTS: RouteSegment[] = [
|
||||
{
|
||||
from: [48.8566, 2.3522],
|
||||
to: [51.5074, -0.1278],
|
||||
mid: [50.182, 1.1122],
|
||||
walkingText: '120 min',
|
||||
drivingText: '90 min',
|
||||
distance: 343000,
|
||||
duration: 12600,
|
||||
distanceText: '343 km',
|
||||
durationText: '3 h 30 min',
|
||||
},
|
||||
];
|
||||
|
||||
// Empty coordinates make the hook fall back to the straight-line geometry,
|
||||
// so the `route` assertions keep checking the raw waypoints while the legs
|
||||
// still flow through to `routeSegments`.
|
||||
const MOCK_ROUTE_WITH_LEGS = {
|
||||
coordinates: [] as [number, number][],
|
||||
distance: 343000,
|
||||
duration: 12600,
|
||||
legs: MOCK_SEGMENTS,
|
||||
};
|
||||
|
||||
describe('useRouteCalculation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default: route_calculation disabled
|
||||
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<typeof vi.fn>).mockResolvedValue(MOCK_SEGMENTS);
|
||||
(calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockResolvedValue(MOCK_ROUTE_WITH_LEGS);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ROUTE-001: with no selectedDayId, route is null', () => {
|
||||
@@ -84,9 +90,7 @@ describe('useRouteCalculation', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ROUTE-004: with route_calculation enabled, calls calculateSegments', async () => {
|
||||
useSettingsStore.setState({ settings: { route_calculation: true } as any });
|
||||
|
||||
it('FE-HOOK-ROUTE-004: calls calculateRouteWithLegs and exposes the returned segments', async () => {
|
||||
const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 });
|
||||
const p2 = buildPlace({ lat: 51.5074, lng: -0.1278 });
|
||||
const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
|
||||
@@ -99,32 +103,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 () => {
|
||||
useSettingsStore.setState({ settings: { route_calculation: false } as any });
|
||||
|
||||
const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 });
|
||||
const p2 = buildPlace({ lat: 51.5074, lng: -0.1278 });
|
||||
const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
|
||||
const a2 = buildAssignment({ day_id: 5, order_index: 1, place: p2 });
|
||||
const store = buildMockStore({ '5': [a1, a2] });
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useRouteCalculation(store as TripStoreState, 5)
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
|
||||
expect(calculateSegments).not.toHaveBeenCalled();
|
||||
expect(result.current.routeSegments).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-HOOK-ROUTE-006: assignments are sorted by order_index before extracting waypoints', async () => {
|
||||
useSettingsStore.setState({ settings: { route_calculation: true } as any });
|
||||
|
||||
const p1 = buildPlace({ lat: 10, lng: 10 });
|
||||
const p2 = buildPlace({ lat: 20, lng: 20 });
|
||||
// order_index 1 comes before 0 in the array, but should be sorted
|
||||
@@ -161,15 +144,14 @@ 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<typeof vi.fn>).mockImplementationOnce(
|
||||
// Make calculateRouteWithLegs resolve slowly
|
||||
let resolveSegments!: (val: typeof MOCK_ROUTE_WITH_LEGS) => void;
|
||||
(calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockImplementationOnce(
|
||||
(_waypoints: unknown[], options: { signal?: AbortSignal }) => {
|
||||
return new Promise<RouteSegment[]>((resolve) => {
|
||||
return new Promise<typeof MOCK_ROUTE_WITH_LEGS>((resolve) => {
|
||||
resolveSegments = resolve;
|
||||
options?.signal?.addEventListener('abort', () => resolve([]));
|
||||
options?.signal?.addEventListener('abort', () => resolve(MOCK_ROUTE_WITH_LEGS));
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -191,20 +173,19 @@ 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<typeof vi.fn>).mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
expect((calculateRouteWithLegs as ReturnType<typeof vi.fn>).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 () => {
|
||||
useSettingsStore.setState({ settings: { route_calculation: true } as any });
|
||||
|
||||
const abortError = new Error('Aborted');
|
||||
abortError.name = 'AbortError';
|
||||
(calculateSegments as ReturnType<typeof vi.fn>).mockRejectedValueOnce(abortError);
|
||||
(calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockRejectedValueOnce(abortError);
|
||||
|
||||
const p1 = buildPlace({ lat: 10, lng: 10 });
|
||||
const p2 = buildPlace({ lat: 20, lng: 20 });
|
||||
@@ -222,9 +203,8 @@ 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<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error'));
|
||||
(calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const p1 = buildPlace({ lat: 10, lng: 10 });
|
||||
const p2 = buildPlace({ lat: 20, lng: 20 });
|
||||
@@ -273,7 +253,6 @@ describe('useRouteCalculation', () => {
|
||||
});
|
||||
|
||||
it('FE-HOOK-ROUTE-013: route recalculates when assignments change via store update', async () => {
|
||||
useSettingsStore.setState({ settings: { route_calculation: true } as any });
|
||||
|
||||
const p1 = buildPlace({ lat: 10, lng: 10 });
|
||||
const p2 = buildPlace({ lat: 20, lng: 20 });
|
||||
|
||||
+1
-1
@@ -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:"],
|
||||
|
||||
@@ -10,7 +10,6 @@ export const DEFAULTABLE_USER_SETTING_KEYS = [
|
||||
'temperature_unit',
|
||||
'dark_mode',
|
||||
'time_format',
|
||||
'route_calculation',
|
||||
'blur_booking_codes',
|
||||
'map_tile_url',
|
||||
] as const;
|
||||
@@ -23,7 +22,7 @@ const VALID_VALUES: Partial<Record<DefaultableKey, unknown[]>> = {
|
||||
dark_mode: [true, false, 'light', 'dark', 'auto'],
|
||||
};
|
||||
|
||||
const BOOLEAN_KEYS = new Set<DefaultableKey>(['route_calculation', 'blur_booking_codes']);
|
||||
const BOOLEAN_KEYS = new Set<DefaultableKey>(['blur_booking_codes']);
|
||||
|
||||
function parseValue(raw: string): unknown {
|
||||
try { return JSON.parse(raw); } catch { return raw; }
|
||||
|
||||
Reference in New Issue
Block a user