Compare commits

...

4 Commits

Author SHA1 Message Date
Dimitris Kafetzis f3350095b1 Merge 3a837f8313 into 324d930ca3 2026-05-26 17:43:56 +02:00
Maurice 324d930ca3 remove route_calculation setting, always use OSRM routing (#1064)
The per-user route_calculation toggle was a second, hidden on/off layer
on top of the day footer's show-route button, and made it easy to end up
with straight-line routes for no obvious reason. Drop the setting
entirely: routing is always on, the footer toggle stays the single
switch. Old stored values are simply ignored (settings are key-value, no
migration needed).
2026-05-26 16:21:10 +02:00
Dkafetzis 3a837f8313 feat(i18n): add Greek translation 2026-05-25 22:46:58 +02:00
Maurice e050814c42 feat(planner): real road routes (OSRM) with travel-time connectors (#1060)
* feat(planner): real road routes (OSRM) with travel-time connectors

Replace the straight-line "as the crow flies" route with real OSRM road
geometry (FOSSGIS routed-car/-foot) and an Apple-Maps style render
(blue casing under a lighter core) on both the Leaflet and Mapbox GL
maps. Routes are off by default and toggled per session, with a
driving/walking mode switch in the day footer.

Each day shows per-segment travel time/distance connectors between
places, computed from the OSRM legs and split at transport bookings.

Also redesigns the day header for visual consistency: vertical
number+weather capsule, name with a divider before the date, subtle
hotel/rental pills that stay on one line, and a hover-revealed 2x2
action square (edit / add transport / add note / collapse). Drops the
Google Maps button.

* test(planner): update route hook tests for calculateRouteWithLegs
2026-05-25 22:27:49 +02:00
43 changed files with 2880 additions and 400 deletions
@@ -20,7 +20,6 @@ type Defaults = {
temperature_unit?: string temperature_unit?: string
dark_mode?: string | boolean dark_mode?: string | boolean
time_format?: string time_format?: string
route_calculation?: boolean
blur_booking_codes?: boolean blur_booking_codes?: boolean
map_tile_url?: string map_tile_url?: string
} }
@@ -208,22 +207,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
))} ))}
</OptionRow> </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 */} {/* Blur Booking Codes */}
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}> <OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
{([ {([
+7 -11
View File
@@ -128,7 +128,8 @@ describe('MapView', () => {
it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => { it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => {
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} />) 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', () => { 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() expect(screen.getByTestId('cluster-group')).toBeTruthy()
}) })
it('FE-COMP-MAPVIEW-011: renders RouteLabel marker when routeSegments provided with route', () => { 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 [number, number][][][] const route = [[[48.0, 2.0], [49.0, 3.0]]] as unknown as [number, number][][]
const routeSegments = [ render(<MapView route={route} />)
{ mid: [48.5, 2.5] as [number, number], from: 0, to: 1, walkingText: '10 min', drivingText: '3 min' }, // The route is drawn; per-segment times now live in the day sidebar, not on the map.
] expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
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-012: invalid route_geometry JSON triggers catch and skips polyline', () => { it('FE-COMP-MAPVIEW-012: invalid route_geometry JSON triggers catch and skips polyline', () => {
+14 -55
View File
@@ -225,44 +225,7 @@ function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.Leafle
return null return null
} }
// ── Route travel time label ── // Travel times are shown in the day sidebar (per-segment connectors), not on the map.
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} />
}
// Module-level photo cache shared with PlaceAvatar // Module-level photo cache shared with PlaceAvatar
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService' import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
@@ -586,23 +549,19 @@ export const MapView = memo(function MapView({
{markers} {markers}
</MarkerClusterGroup> </MarkerClusterGroup>
{route && route.length > 0 && ( {/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded. */}
<> {route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [
{route.map((seg, i) => seg.length > 1 && ( <Polyline
<Polyline key={`${i}-casing`}
key={i} positions={seg}
positions={seg} pathOptions={{ color: '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
color="#111827" />,
weight={3} <Polyline
opacity={0.9} key={`${i}-core`}
dashArray="6, 5" positions={seg}
/> pathOptions={{ color: '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
))} />,
{routeSegments.map((seg, i) => ( ] : [])}
<RouteLabel key={i} midpoint={seg.mid} from={seg.from} to={seg.to} walkingText={seg.walkingText} drivingText={seg.drivingText} />
))}
</>
)}
{/* GPX imported route geometries */} {/* GPX imported route geometries */}
{gpxPolylines} {gpxPolylines}
+11 -35
View File
@@ -163,7 +163,6 @@ export function MapViewGL({
const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map()) const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map())
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null) const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null) const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
const routeLabelMarkersRef = useRef<mapboxgl.Marker[]>([])
// Refs so the reservation overlay always sees the latest callback / // Refs so the reservation overlay always sees the latest callback /
// options without forcing a full overlay rebuild on every prop change. // options without forcing a full overlay rebuild on every prop change.
const onReservationClickRef = useRef(onReservationClick) const onReservationClickRef = useRef(onReservationClick)
@@ -218,16 +217,20 @@ export function MapViewGL({
// initial route source — kept around so updates can setData() cheaply // initial route source — kept around so updates can setData() cheaply
if (!map.getSource('trip-route')) { if (!map.getSource('trip-route')) {
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }) 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({ map.addLayer({
id: 'trip-route-line', id: 'trip-route-line',
type: 'line', type: 'line',
source: 'trip-route', source: 'trip-route',
paint: { paint: { 'line-color': '#0a84ff', 'line-width': 5 },
'line-color': '#111827',
'line-width': 3,
'line-opacity': 0.9,
'line-dasharray': [2, 1.5],
},
layout: { 'line-cap': 'round', 'line-join': 'round' }, layout: { 'line-cap': 'round', 'line-join': 'round' },
}) })
} }
@@ -444,34 +447,7 @@ export function MapViewGL({
src.setData({ type: 'FeatureCollection', features }) src.setData({ type: 'FeatureCollection', features })
}, [route]) }, [route])
// Travel-time pills between consecutive places. The GL map accepted the // Travel times now live in the day sidebar (per-segment connectors), not on the map.
// 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])
// Update GPX geometries // Update GPX geometries
useEffect(() => { useEffect(() => {
+75 -1
View File
@@ -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' 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. */ /** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */
export async function calculateRoute( export async function calculateRoute(
waypoints: Waypoint[], waypoints: Waypoint[],
@@ -116,12 +130,72 @@ export async function calculateSegments(
const walkingDuration = leg.distance / (5000 / 3600) const walkingDuration = leg.distance / (5000 / 3600)
return { return {
mid, from, to, mid, from, to,
distance: leg.distance,
duration: leg.duration,
walkingText: formatDuration(walkingDuration), walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(leg.duration), 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 { function formatDistance(meters: number): string {
if (meters < 1000) { if (meters < 1000) {
return `${Math.round(meters)} m` return `${Math.round(meters)} m`
@@ -268,14 +268,7 @@ describe('DayPlanSidebar', () => {
const user = userEvent.setup() const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' }) const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />) render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
// Find the pencil/edit button next to the title await user.click(screen.getByLabelText('Edit'))
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 waitFor(() => { await waitFor(() => {
expect(screen.getByDisplayValue('Original Title')).toBeInTheDocument() expect(screen.getByDisplayValue('Original Title')).toBeInTheDocument()
}) })
@@ -287,9 +280,7 @@ describe('DayPlanSidebar', () => {
const onUpdateDayTitle = vi.fn() const onUpdateDayTitle = vi.fn()
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />) render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
// Enter edit mode // Enter edit mode
const titleEl = screen.getByText('Original Title') await user.click(screen.getByLabelText('Edit'))
const pencilBtn = titleEl.parentElement?.querySelector('button')
if (pencilBtn) await user.click(pencilBtn)
const input = await screen.findByDisplayValue('Original Title') const input = await screen.findByDisplayValue('Original Title')
await user.clear(input) await user.clear(input)
await user.type(input, 'New Title') await user.type(input, 'New Title')
@@ -301,9 +292,7 @@ describe('DayPlanSidebar', () => {
const user = userEvent.setup() const user = userEvent.setup()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' }) const day = buildDay({ id: 10, date: '2025-06-01', title: 'Original Title' })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />) render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
const titleEl = screen.getByText('Original Title') await user.click(screen.getByLabelText('Edit'))
const pencilBtn = titleEl.parentElement?.querySelector('button')
if (pencilBtn) await user.click(pencilBtn)
const input = await screen.findByDisplayValue('Original Title') const input = await screen.findByDisplayValue('Original Title')
await user.keyboard('{Escape}') await user.keyboard('{Escape}')
expect(screen.queryByDisplayValue('Original Title')).not.toBeInTheDocument() expect(screen.queryByDisplayValue('Original Title')).not.toBeInTheDocument()
@@ -625,9 +614,7 @@ describe('DayPlanSidebar', () => {
const onUpdateDayTitle = vi.fn() const onUpdateDayTitle = vi.fn()
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Old Title' }) const day = buildDay({ id: 10, date: '2025-06-01', title: 'Old Title' })
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />) render(<DayPlanSidebar {...makeDefaultProps({ days: [day], onUpdateDayTitle })} />)
const titleEl = screen.getByText('Old Title') await user.click(screen.getByLabelText('Edit'))
const pencilBtn = titleEl.parentElement?.querySelector('button')
if (pencilBtn) await user.click(pencilBtn)
const input = await screen.findByDisplayValue('Old Title') const input = await screen.findByDisplayValue('Old Title')
await user.clear(input) await user.clear(input)
await user.type(input, 'New Title') await user.type(input, 'New Title')
+208 -101
View File
@@ -4,12 +4,12 @@ declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react' import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
import ReactDOM from 'react-dom' 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 } 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 { assignmentsApi, reservationsApi } from '../../api/client'
import { downloadTripPDF } from '../PDF/TripPDF' 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 PlaceAvatar from '../shared/PlaceAvatar'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu' import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import Markdown from 'react-markdown' import Markdown from 'react-markdown'
@@ -31,7 +31,7 @@ import {
import { formatDate, formatTime, dayTotalCost, currencyDecimals, splitReservationDateTime } from '../../utils/formatters' import { formatDate, formatTime, dayTotalCost, currencyDecimals, splitReservationDateTime } from '../../utils/formatters'
import { useDayNotes } from '../../hooks/useDayNotes' import { useDayNotes } from '../../hooks/useDayNotes'
import Tooltip from '../shared/Tooltip' 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 = [ const NOTE_ICONS = [
{ id: 'FileText', Icon: FileText }, { id: 'FileText', Icon: FileText },
@@ -184,6 +184,10 @@ interface DayPlanSidebarProps {
onExternalTransportDetailHandled?: () => void onExternalTransportDetailHandled?: () => void
onAddReservation: () => void onAddReservation: () => void
onNavigateToFiles?: () => void onNavigateToFiles?: () => void
routeShown?: boolean
routeProfile?: 'driving' | 'walking'
onToggleRoute?: () => void
onSetRouteProfile?: (profile: 'driving' | 'walking') => void
onAddPlace?: () => void onAddPlace?: () => void
onAddPlaceToDay?: (placeId: number, dayId: number) => void onAddPlaceToDay?: (placeId: number, dayId: number) => void
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
@@ -200,6 +204,25 @@ interface DayPlanSidebarProps {
onScrollTopChange?: (top: number) => void 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({ const DayPlanSidebar = React.memo(function DayPlanSidebar({
tripId, tripId,
trip, days, places, categories, assignments, trip, days, places, categories, assignments,
@@ -216,6 +239,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onAddPlace, onAddPlace,
onAddPlaceToDay, onAddPlaceToDay,
onNavigateToFiles, onNavigateToFiles,
routeShown = false,
routeProfile = 'driving',
onToggleRoute,
onSetRouteProfile,
onExpandedDaysChange, onExpandedDaysChange,
pushUndo, pushUndo,
canUndo = false, canUndo = false,
@@ -251,6 +278,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const [editTitle, setEditTitle] = useState('') const [editTitle, setEditTitle] = useState('')
const [isCalculating, setIsCalculating] = useState(false) const [isCalculating, setIsCalculating] = useState(false)
const [routeInfo, setRouteInfo] = useState(null) const [routeInfo, setRouteInfo] = useState(null)
const [routeLegs, setRouteLegs] = useState<Record<number, RouteSegment>>({})
const legsAbortRef = useRef<AbortController | null>(null)
const [draggingId, setDraggingId] = useState(null) const [draggingId, setDraggingId] = useState(null)
const [lockedIds, setLockedIds] = useState(new Set()) const [lockedIds, setLockedIds] = useState(new Set())
const [lockHoverId, setLockHoverId] = useState(null) const [lockHoverId, setLockHoverId] = useState(null)
@@ -472,6 +501,42 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [days, assignments, dayNotes, reservations, transportPosVersion]) }, [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) => { const openAddNote = (dayId, e) => {
e?.stopPropagation() e?.stopPropagation()
_openAddNote(dayId, getMergedItems, (id) => { _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) => { const handleDropOnDay = (e, dayId) => {
e.preventDefault() e.preventDefault()
@@ -1047,6 +1105,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}> <div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */} {/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
<div <div
className="dp-day-header"
data-selected={isSelected}
onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }} onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }}
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }} onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }} 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)' }} 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' }} onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }}
> >
{/* Tages-Badge */} {/* Tages-Badge: Nummer oben, darunter (falls vorhanden) das Wetter des Tages */}
<div style={{ {(() => {
width: 26, height: 26, borderRadius: '50%', flexShrink: 0, const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat
background: isSelected ? 'var(--accent)' : 'var(--bg-hover)', const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng
color: isSelected ? 'var(--accent-text)' : 'var(--text-muted)', const hasWeather = !!(day.date && anyGeoPlace && wLat != null && wLng != null)
display: 'flex', alignItems: 'center', justifyContent: 'center', return (
fontSize: 11, fontWeight: 700, <div style={{
}}> flexShrink: 0, alignSelf: 'flex-start',
{index + 1} width: hasWeather ? 34 : 26,
</div> 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 }}> <div style={{ flex: 1, minWidth: 0 }}>
{editingDayId === day.id ? ( {editingDayId === day.id ? (
@@ -1093,40 +1171,27 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
borderBottom: '1.5px solid var(--text-primary)', 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 }}> <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 })} {day.title || t('dayplan.dayN', { n: index + 1 })}
</span> </span>
{canEditDays && <button {formattedDate && (
onClick={e => startEditTitle(day, e)} <>
style={{ flexShrink: 0, background: 'none', border: 'none', padding: '4px', cursor: 'pointer', opacity: 0.35, display: 'flex', alignItems: 'center' }} <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' }}>
<Pencil size={15} strokeWidth={1.8} color="var(--text-secondary)" /> {formattedDate}
</button>} </span>
{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>
)} )}
</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)) 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 // 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 => { return dayAccs.map(acc => {
const isCheckIn = acc.start_day_id === day.id const isCheckIn = acc.start_day_id === day.id
const isCheckOut = acc.end_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 iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-faint)'
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)'
return ( 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' }}> <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={8} style={{ color: iconColor, flexShrink: 0 }} /> <Hotel size={11} strokeWidth={1.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 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> </span>
) )
}) })
@@ -1161,41 +1224,50 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const activeRentals = getActiveRentalsForDay(day.id) const activeRentals = getActiveRentalsForDay(day.id)
if (activeRentals.length === 0) return null if (activeRentals.length === 0) return null
return activeRentals.map(r => ( 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' }}> <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={8} style={{ color: '#3b82f6', flexShrink: 0 }} /> <Car size={11} strokeWidth={1.8} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span> <span style={{ fontSize: 10.5, color: 'var(--text-muted)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
</span> </span>
)) ))
})()} })()}
</div> </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> </div>
{canEditDays && <Tooltip label={t('dayplan.addNote')} placement="top"><button {canEditDays ? (
onClick={e => openAddNote(day.id, e)} (() => {
aria-label={t('dayplan.addNote')} const cell = { padding: 7, cursor: 'pointer', display: 'grid', placeItems: 'center' } as const
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }} const div = '1px solid var(--border-faint)'
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} return (
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'} <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 }}>
<FileText size={16} strokeWidth={2} /> <Pencil size={14} strokeWidth={1.8} />
</button></Tooltip>} </button>
<button {onAddTransport ? (
onClick={e => toggleDay(day.id, e)} <button onClick={e => { e.stopPropagation(); onAddTransport(day.id) }} title={t('transport.addTransport')} style={{ ...cell, border: 'none', borderBottom: div }}>
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }} <Plus size={14} strokeWidth={1.8} />
> </button>
{isExpanded ? <ChevronDown size={18} strokeWidth={2} /> : <ChevronRight size={18} strokeWidth={2} />} ) : <div style={{ borderBottom: div }} />}
</button> <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> </div>
{/* Aufgeklappte Orte + Notizen */} {/* Aufgeklappte Orte + Notizen */}
@@ -1607,6 +1679,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</button> </button>
)} )}
</div> </div>
{routeLegs[assignment.id] && <RouteConnector seg={routeLegs[assignment.id]} profile={routeProfile} />}
</React.Fragment> </React.Fragment>
) )
} }
@@ -1656,6 +1729,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
draggable={canEditDays && spanPhase !== 'middle'} draggable={canEditDays && spanPhase !== 'middle'}
onDragStart={e => { onDragStart={e => {
if (!canEditDays || spanPhase === 'middle') { e.preventDefault(); return } 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' e.dataTransfer.effectAllowed = 'move'
dragDataRef.current = { reservationId: String(res.id), fromDayId: String(day.id), phase: spanPhase } dragDataRef.current = { reservationId: String(res.id), fromDayId: String(day.id), phase: spanPhase }
setDraggingId(res.id) 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'))) } 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 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) { 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'))) 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 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) handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id, true)
else if (noteId && String(lastItem?.data?.id) !== noteId) else if (noteId && String(lastItem?.data?.id) !== noteId)
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true) 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}` && ( {dropTargetKey === `end-${day.id}` && (
@@ -1919,15 +1999,21 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */} {/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
{isSelected && getDayAssignments(day.id).length >= 2 && ( {isSelected && getDayAssignments(day.id).length >= 2 && (
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}> <div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
{routeInfo && ( <div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, color: 'var(--text-secondary)', background: 'var(--bg-hover)', borderRadius: 8, padding: '5px 10px' }}> <button
<span>{routeInfo.distance}</span> onClick={() => onToggleRoute?.()}
<span style={{ color: 'var(--text-faint)' }}>·</span> style={{
<span>{routeInfo.duration}</span> flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
</div> padding: '6px 0', fontSize: 11, fontWeight: 600, borderRadius: 8,
)} border: routeShown ? 'none' : '1px solid var(--border-faint)',
background: routeShown ? 'var(--accent)' : 'transparent',
<div style={{ display: 'flex', gap: 6 }}> 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={{ <button onClick={handleOptimize} style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none', 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} /> <RotateCcw size={12} strokeWidth={2} />
{t('dayplan.optimize')} {t('dayplan.optimize')}
</button> </button>
<button onClick={handleGoogleMaps} style={{ <div style={{ display: 'flex', borderRadius: 8, overflow: 'hidden', border: '1px solid var(--border-faint)', flexShrink: 0 }}>
display: 'flex', alignItems: 'center', justifyContent: 'center', {(['driving', 'walking'] as const).map(p => {
padding: '6px 10px', fontSize: 11, fontWeight: 500, borderRadius: 8, const ModeIcon = p === 'driving' ? Car : Footprints
border: '1px solid var(--border-faint)', background: 'transparent', color: 'var(--text-secondary)', cursor: 'pointer', fontFamily: 'inherit', const active = routeProfile === p
}}> return (
<ExternalLink size={12} strokeWidth={2} /> <button
</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> </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> </div>
)} )}
@@ -27,7 +27,7 @@ beforeEach(() => {
resetAllStores(); resetAllStores();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) }); 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', () => { describe('ReservationsPanel', () => {
@@ -211,7 +211,7 @@ describe('ReservationsPanel', () => {
}); });
it('FE-PLANNER-RESP-022: confirmation number is blurred when blur_booking_codes=true', () => { 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' }); const res = buildReservation({ confirmation_number: 'ABC123', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />); render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
const codeEl = screen.getByText('ABC123'); const codeEl = screen.getByText('ABC123');
@@ -220,7 +220,7 @@ describe('ReservationsPanel', () => {
it('FE-PLANNER-RESP-023: confirmation code revealed on hover when blurred', async () => { it('FE-PLANNER-RESP-023: confirmation code revealed on hover when blurred', async () => {
const user = userEvent.setup(); 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' }); const res = buildReservation({ confirmation_number: 'ABC123', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />); render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
const codeEl = screen.getByText('ABC123'); const codeEl = screen.getByText('ABC123');
@@ -161,29 +161,6 @@ describe('DisplaySettingsTab', () => {
expect(updateSetting).toHaveBeenCalledWith('time_format', '24h'); 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', () => { it('FE-COMP-DISPLAY-024: shows Blur Booking Codes section', () => {
render(<DisplaySettingsTab />); render(<DisplaySettingsTab />);
expect(screen.getByText(/blur booking codes/i)).toBeInTheDocument(); expect(screen.getByText(/blur booking codes/i)).toBeInTheDocument();
@@ -214,36 +214,6 @@ export default function DisplaySettingsTab(): React.ReactElement {
</div> </div>
</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 */} {/* Booking route labels */}
<div> <div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.bookingLabels')}</label> <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 lng: number | null
date: string date: string
compact?: boolean 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 [weather, setWeather] = useState(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [failed, setFailed] = 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 unit = isFahrenheit ? '°F' : '°C'
const isClimate = weather.type === 'climate' 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) { if (compact) {
return ( return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, color: isClimate ? '#a1a1aa' : '#6b7280', ...fontStyle }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, color: isClimate ? '#a1a1aa' : '#6b7280', ...fontStyle }}>
+40 -25
View File
@@ -1,7 +1,6 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react' import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { useSettingsStore } from '../store/settingsStore'
import { useTripStore } from '../store/tripStore' 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 { TripStoreState } from '../store/tripStore'
import type { RouteSegment, RouteResult } from '../types' 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 * 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 * day assignments, draws a straight-line route immediately, then upgrades it to real OSRM
* driving/walking durations via OSRM. Aborts in-flight requests when the day changes. * 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 [route, setRoute] = useState<[number, number][][] | null>(null)
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null) const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([]) const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
const routeCalcEnabled = useSettingsStore((s) => s.settings.route_calculation) !== false
const routeAbortRef = useRef<AbortController | null>(null) const routeAbortRef = useRef<AbortController | null>(null)
const reservationsForSignature = useTripStore((s) => s.reservations) const reservationsForSignature = useTripStore((s) => s.reservations)
const updateRouteForDay = useCallback(async (dayId: number | null) => { const updateRouteForDay = useCallback(async (dayId: number | null) => {
if (routeAbortRef.current) routeAbortRef.current.abort() 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 // Read directly from store (not a render-phase ref) so callers after optimistic
// updates or non-optimistic deletes always see the latest assignments. // updates or non-optimistic deletes always see the latest assignments.
const currentAssignments = useTripStore.getState().assignments || {} const currentAssignments = useTripStore.getState().assignments || {}
@@ -67,35 +66,51 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
})), })),
].sort((a, b) => a.pos - b.pos) ].sort((a, b) => a.pos - b.pos)
const segments: [number, number][][] = [] // Group consecutive located places into runs, resetting whenever a transport
let currentSeg: [number, number][] = [] // 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) { for (const entry of entries) {
if (entry.kind === 'place') { if (entry.kind === 'place') {
currentSeg.push([entry.lat, entry.lng]) currentRun.push({ lat: entry.lat, lng: entry.lng })
} else { } else {
if (currentSeg.length >= 2) segments.push([...currentSeg]) if (currentRun.length >= 2) runs.push(currentRun)
currentSeg = [] 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() const controller = new AbortController()
routeAbortRef.current = controller routeAbortRef.current = controller
try { try {
const calcSegments = await calculateSegments(geocodedWaypoints, { signal: controller.signal }) const polylines: [number, number][][] = []
if (!controller.signal.aborted) setRouteSegments(calcSegments) 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) { } catch (err: unknown) {
if (err instanceof Error && err.name !== 'AbortError') setRouteSegments([]) // Aborted (day changed) — newer call owns the state. Anything else: keep straight lines.
else if (!(err instanceof Error)) setRouteSegments([]) if (!(err instanceof Error) || err.name !== 'AbortError') setRouteSegments([])
} }
}, [routeCalcEnabled]) }, [enabled, profile])
// Stable signature for transport reservations on the selected day — changes when a transport // 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. // 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 } if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
updateRouteForDay(selectedDayId) updateRouteForDay(selectedDayId)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDayId, selectedDayAssignments, transportSignature]) }, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile])
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
} }
+3 -2
View File
@@ -19,6 +19,7 @@ import pl from './translations/pl'
import ja from './translations/ja' import ja from './translations/ja'
import ko from './translations/ko' import ko from './translations/ko'
import uk from './translations/uk' import uk from './translations/uk'
import gr from './translations/gr'
import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages' import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages'
export { SUPPORTED_LANGUAGES } export { SUPPORTED_LANGUAGES }
@@ -27,7 +28,7 @@ type TranslationStrings = Record<string, string | { name: string; category: stri
// Keyed by SupportedLanguageCode so TypeScript enforces all languages have a translation. // Keyed by SupportedLanguageCode so TypeScript enforces all languages have a translation.
const translations: Record<SupportedLanguageCode, TranslationStrings> = { const translations: Record<SupportedLanguageCode, TranslationStrings> = {
de, en, es, fr, hu, it, tr, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl, ja, ko, uk, de, en, es, fr, hu, it, tr, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl, ja, ko, uk, gr,
} }
// Derived from SUPPORTED_LANGUAGES — add new languages there, not here. // Derived from SUPPORTED_LANGUAGES — add new languages there, not here.
@@ -42,7 +43,7 @@ export function getLocaleForLanguage(language: string): string {
export function getIntlLanguage(language: string): string { export function getIntlLanguage(language: string): string {
if (language === 'br') return 'pt-BR' if (language === 'br') return 'pt-BR'
return ['de', 'es', 'fr', 'hu', 'it', 'tr', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl', 'id', 'ja', 'ko', 'uk'].includes(language) ? language : 'en' return ['de', 'es', 'fr', 'hu', 'it', 'tr', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl', 'id', 'ja', 'ko', 'uk', 'gr'].includes(language) ? language : 'en'
} }
export function isRtlLanguage(language: string): boolean { export function isRtlLanguage(language: string): boolean {
+1
View File
@@ -18,6 +18,7 @@ export const SUPPORTED_LANGUAGES = [
{ value: 'ja', label: '日本語', locale: 'ja-JP' }, { value: 'ja', label: '日本語', locale: 'ja-JP' },
{ value: 'ko', label: '한국어', locale: 'ko-KR' }, { value: 'ko', label: '한국어', locale: 'ko-KR' },
{ value: 'uk', label: 'Українська', locale: 'uk-UA' }, { value: 'uk', label: 'Українська', locale: 'uk-UA' },
{ value: 'gr', label: 'Ελληνικά', locale: 'el-GR' },
] as const ] as const
export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value'] export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value']
-1
View File
@@ -200,7 +200,6 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'settings.language': 'اللغة', 'settings.language': 'اللغة',
'settings.temperature': 'وحدة الحرارة', 'settings.temperature': 'وحدة الحرارة',
'settings.timeFormat': 'تنسيق الوقت', 'settings.timeFormat': 'تنسيق الوقت',
'settings.routeCalculation': 'حساب المسار',
'settings.blurBookingCodes': 'إخفاء رموز الحجز', 'settings.blurBookingCodes': 'إخفاء رموز الحجز',
'settings.notifications': 'الإشعارات', 'settings.notifications': 'الإشعارات',
'settings.notifyTripInvite': 'دعوات الرحلات', 'settings.notifyTripInvite': 'دعوات الرحلات',
-1
View File
@@ -195,7 +195,6 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'settings.language': 'Idioma', 'settings.language': 'Idioma',
'settings.temperature': 'Unidade de temperatura', 'settings.temperature': 'Unidade de temperatura',
'settings.timeFormat': 'Formato de hora', 'settings.timeFormat': 'Formato de hora',
'settings.routeCalculation': 'Cálculo de rota',
'settings.blurBookingCodes': 'Ocultar códigos de reserva', 'settings.blurBookingCodes': 'Ocultar códigos de reserva',
'settings.notifications': 'Notificações', 'settings.notifications': 'Notificações',
'settings.notifyTripInvite': 'Convites de viagem', 'settings.notifyTripInvite': 'Convites de viagem',
-1
View File
@@ -196,7 +196,6 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'settings.language': 'Jazyk', 'settings.language': 'Jazyk',
'settings.temperature': 'Jednotky teploty', 'settings.temperature': 'Jednotky teploty',
'settings.timeFormat': 'Formát času', 'settings.timeFormat': 'Formát času',
'settings.routeCalculation': 'Výpočet trasy',
'settings.blurBookingCodes': 'Skrýt rezervační kódy', 'settings.blurBookingCodes': 'Skrýt rezervační kódy',
'settings.notifications': 'Oznámení', 'settings.notifications': 'Oznámení',
'settings.notifyTripInvite': 'Pozvánky na cesty', 'settings.notifyTripInvite': 'Pozvánky na cesty',
-1
View File
@@ -198,7 +198,6 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'settings.language': 'Sprache', 'settings.language': 'Sprache',
'settings.temperature': 'Temperatureinheit', 'settings.temperature': 'Temperatureinheit',
'settings.timeFormat': 'Zeitformat', 'settings.timeFormat': 'Zeitformat',
'settings.routeCalculation': 'Routenberechnung',
'settings.bookingLabels': 'Orts-Labels auf Buchungsrouten', 'settings.bookingLabels': 'Orts-Labels auf Buchungsrouten',
'settings.bookingLabelsHint': 'Zeigt Bahnhofs-/Flughafennamen auf der Karte. Wenn aus, wird nur das Icon angezeigt.', 'settings.bookingLabelsHint': 'Zeigt Bahnhofs-/Flughafennamen auf der Karte. Wenn aus, wird nur das Icon angezeigt.',
'settings.blurBookingCodes': 'Buchungscodes verbergen', 'settings.blurBookingCodes': 'Buchungscodes verbergen',
-1
View File
@@ -212,7 +212,6 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'settings.language': 'Language', 'settings.language': 'Language',
'settings.temperature': 'Temperature Unit', 'settings.temperature': 'Temperature Unit',
'settings.timeFormat': 'Time Format', 'settings.timeFormat': 'Time Format',
'settings.routeCalculation': 'Route Calculation',
'settings.bookingLabels': 'Booking route labels', 'settings.bookingLabels': 'Booking route labels',
'settings.bookingLabelsHint': 'Show station / airport names on the map. When off, only the icon is shown.', 'settings.bookingLabelsHint': 'Show station / airport names on the map. When off, only the icon is shown.',
'settings.blurBookingCodes': 'Blur Booking Codes', 'settings.blurBookingCodes': 'Blur Booking Codes',
-1
View File
@@ -196,7 +196,6 @@ const es: Record<string, string> = {
'settings.language': 'Idioma', 'settings.language': 'Idioma',
'settings.temperature': 'Unidad de temperatura', 'settings.temperature': 'Unidad de temperatura',
'settings.timeFormat': 'Formato de hora', 'settings.timeFormat': 'Formato de hora',
'settings.routeCalculation': 'Cálculo de ruta',
'settings.blurBookingCodes': 'Difuminar códigos de reserva', 'settings.blurBookingCodes': 'Difuminar códigos de reserva',
'settings.notifications': 'Notificaciones', 'settings.notifications': 'Notificaciones',
'settings.notifyTripInvite': 'Invitaciones de viaje', 'settings.notifyTripInvite': 'Invitaciones de viaje',
-1
View File
@@ -195,7 +195,6 @@ const fr: Record<string, string> = {
'settings.language': 'Langue', 'settings.language': 'Langue',
'settings.temperature': 'Unité de température', 'settings.temperature': 'Unité de température',
'settings.timeFormat': 'Format de l\'heure', 'settings.timeFormat': 'Format de l\'heure',
'settings.routeCalculation': 'Calcul d\'itinéraire',
'settings.blurBookingCodes': 'Masquer les codes de réservation', 'settings.blurBookingCodes': 'Masquer les codes de réservation',
'settings.notifications': 'Notifications', 'settings.notifications': 'Notifications',
'settings.notifyTripInvite': 'Invitations de voyage', 'settings.notifyTripInvite': 'Invitations de voyage',
File diff suppressed because it is too large Load Diff
-1
View File
@@ -195,7 +195,6 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'settings.language': 'Nyelv', 'settings.language': 'Nyelv',
'settings.temperature': 'Hőmérséklet egység', 'settings.temperature': 'Hőmérséklet egység',
'settings.timeFormat': 'Időformátum', 'settings.timeFormat': 'Időformátum',
'settings.routeCalculation': 'Útvonalszámítás',
'settings.blurBookingCodes': 'Foglalási kódok elrejtése', 'settings.blurBookingCodes': 'Foglalási kódok elrejtése',
'settings.notifications': 'Értesítések', 'settings.notifications': 'Értesítések',
'settings.notifyTripInvite': 'Utazási meghívók', 'settings.notifyTripInvite': 'Utazási meghívók',
-1
View File
@@ -198,7 +198,6 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'settings.language': 'Bahasa', 'settings.language': 'Bahasa',
'settings.temperature': 'Satuan Suhu', 'settings.temperature': 'Satuan Suhu',
'settings.timeFormat': 'Format Waktu', 'settings.timeFormat': 'Format Waktu',
'settings.routeCalculation': 'Perhitungan Rute',
'settings.blurBookingCodes': 'Sembunyikan Kode Pemesanan', 'settings.blurBookingCodes': 'Sembunyikan Kode Pemesanan',
'settings.notifications': 'Notifikasi', 'settings.notifications': 'Notifikasi',
'settings.notifyTripInvite': 'Undangan perjalanan', 'settings.notifyTripInvite': 'Undangan perjalanan',
-1
View File
@@ -195,7 +195,6 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'settings.language': 'Lingua', 'settings.language': 'Lingua',
'settings.temperature': 'Unità di Temperatura', 'settings.temperature': 'Unità di Temperatura',
'settings.timeFormat': 'Formato Ora', 'settings.timeFormat': 'Formato Ora',
'settings.routeCalculation': 'Calcolo Percorso',
'settings.blurBookingCodes': 'Nascondi codici di prenotazione', 'settings.blurBookingCodes': 'Nascondi codici di prenotazione',
'settings.notifications': 'Notifiche', 'settings.notifications': 'Notifiche',
'settings.notifyTripInvite': 'Inviti di viaggio', 'settings.notifyTripInvite': 'Inviti di viaggio',
-1
View File
@@ -212,7 +212,6 @@ const ja: Record<string, string | { name: string; category: string }[]> = {
'settings.language': '言語', 'settings.language': '言語',
'settings.temperature': '温度単位', 'settings.temperature': '温度単位',
'settings.timeFormat': '時刻形式', 'settings.timeFormat': '時刻形式',
'settings.routeCalculation': '経路計算',
'settings.bookingLabels': '予約ルートのラベル', 'settings.bookingLabels': '予約ルートのラベル',
'settings.bookingLabelsHint': '地図に駅・空港名を表示。オフ時はアイコンのみ。', 'settings.bookingLabelsHint': '地図に駅・空港名を表示。オフ時はアイコンのみ。',
'settings.blurBookingCodes': '予約コードをぼかす', 'settings.blurBookingCodes': '予約コードをぼかす',
-1
View File
@@ -212,7 +212,6 @@ const ko: Record<string, string | { name: string; category: string }[]> = {
'settings.language': '언어', 'settings.language': '언어',
'settings.temperature': '온도 단위', 'settings.temperature': '온도 단위',
'settings.timeFormat': '시간 형식', 'settings.timeFormat': '시간 형식',
'settings.routeCalculation': '경로 계산',
'settings.bookingLabels': '예약 경로 레이블', 'settings.bookingLabels': '예약 경로 레이블',
'settings.bookingLabelsHint': '지도에 역 / 공항 이름을 표시합니다. 끄면 아이콘만 표시됩니다.', 'settings.bookingLabelsHint': '지도에 역 / 공항 이름을 표시합니다. 끄면 아이콘만 표시됩니다.',
'settings.blurBookingCodes': '예약 코드 흐리게', 'settings.blurBookingCodes': '예약 코드 흐리게',
-1
View File
@@ -195,7 +195,6 @@ const nl: Record<string, string> = {
'settings.language': 'Taal', 'settings.language': 'Taal',
'settings.temperature': 'Temperatuureenheid', 'settings.temperature': 'Temperatuureenheid',
'settings.timeFormat': 'Tijdnotatie', 'settings.timeFormat': 'Tijdnotatie',
'settings.routeCalculation': 'Routeberekening',
'settings.blurBookingCodes': 'Boekingscodes vervagen', 'settings.blurBookingCodes': 'Boekingscodes vervagen',
'settings.notifications': 'Meldingen', 'settings.notifications': 'Meldingen',
'settings.notifyTripInvite': 'Reisuitnodigingen', 'settings.notifyTripInvite': 'Reisuitnodigingen',
-1
View File
@@ -178,7 +178,6 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'settings.language': 'Język', 'settings.language': 'Język',
'settings.temperature': 'Jednostka temperatury', 'settings.temperature': 'Jednostka temperatury',
'settings.timeFormat': 'Format czasu', 'settings.timeFormat': 'Format czasu',
'settings.routeCalculation': 'Obliczanie trasy',
'settings.blurBookingCodes': 'Rozmyj kody rezerwacji', 'settings.blurBookingCodes': 'Rozmyj kody rezerwacji',
'settings.notifications': 'Powiadomienia', 'settings.notifications': 'Powiadomienia',
'settings.notifyTripInvite': 'Zaproszenia do podróży', 'settings.notifyTripInvite': 'Zaproszenia do podróży',
-1
View File
@@ -195,7 +195,6 @@ const ru: Record<string, string> = {
'settings.language': 'Язык', 'settings.language': 'Язык',
'settings.temperature': 'Единица температуры', 'settings.temperature': 'Единица температуры',
'settings.timeFormat': 'Формат времени', 'settings.timeFormat': 'Формат времени',
'settings.routeCalculation': 'Расчёт маршрута',
'settings.blurBookingCodes': 'Скрыть коды бронирования', 'settings.blurBookingCodes': 'Скрыть коды бронирования',
'settings.notifications': 'Уведомления', 'settings.notifications': 'Уведомления',
'settings.notifyTripInvite': 'Приглашения в поездку', 'settings.notifyTripInvite': 'Приглашения в поездку',
-1
View File
@@ -212,7 +212,6 @@ const tr: Record<string, string | { name: string; category: string }[]> = {
'settings.language': 'Dil', 'settings.language': 'Dil',
'settings.temperature': 'Sıcaklık birimi', 'settings.temperature': 'Sıcaklık birimi',
'settings.timeFormat': 'Saat biçimi', 'settings.timeFormat': 'Saat biçimi',
'settings.routeCalculation': 'Route Calculation',
'settings.bookingLabels': 'Booking route labels', 'settings.bookingLabels': 'Booking route labels',
'settings.bookingLabelsHint': 'Show station / airport names on the map. When off, only the icon is shown.', 'settings.bookingLabelsHint': 'Show station / airport names on the map. When off, only the icon is shown.',
'settings.blurBookingCodes': 'Blur Booking Codes', 'settings.blurBookingCodes': 'Blur Booking Codes',
-1
View File
@@ -209,7 +209,6 @@ const uk: Record<string, string> = {
'settings.language': 'Мова', 'settings.language': 'Мова',
'settings.temperature': 'Одиниця температури', 'settings.temperature': 'Одиниця температури',
'settings.timeFormat': 'Формат часу', 'settings.timeFormat': 'Формат часу',
'settings.routeCalculation': 'Розрахунок маршруту',
'settings.blurBookingCodes': 'Приховати коди бронювання', 'settings.blurBookingCodes': 'Приховати коди бронювання',
'settings.notifications': 'Сповіщення', 'settings.notifications': 'Сповіщення',
'settings.notifyTripInvite': 'Запрошення до поїздки', 'settings.notifyTripInvite': 'Запрошення до поїздки',
-1
View File
@@ -195,7 +195,6 @@ const zh: Record<string, string> = {
'settings.language': '语言', 'settings.language': '语言',
'settings.temperature': '温度单位', 'settings.temperature': '温度单位',
'settings.timeFormat': '时间格式', 'settings.timeFormat': '时间格式',
'settings.routeCalculation': '路线计算',
'settings.blurBookingCodes': '模糊预订代码', 'settings.blurBookingCodes': '模糊预订代码',
'settings.notifications': '通知', 'settings.notifications': '通知',
'settings.notifyTripInvite': '旅行邀请', 'settings.notifyTripInvite': '旅行邀请',
-1
View File
@@ -195,7 +195,6 @@ const zhTw: Record<string, string> = {
'settings.language': '語言', 'settings.language': '語言',
'settings.temperature': '溫度單位', 'settings.temperature': '溫度單位',
'settings.timeFormat': '時間格式', 'settings.timeFormat': '時間格式',
'settings.routeCalculation': '路線計算',
'settings.blurBookingCodes': '模糊預訂程式碼', 'settings.blurBookingCodes': '模糊預訂程式碼',
'settings.notifications': '通知', 'settings.notifications': '通知',
'settings.notifyTripInvite': '旅行邀請', 'settings.notifyTripInvite': '旅行邀請',
+18
View File
@@ -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 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 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; } .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; }
}
-1
View File
@@ -857,7 +857,6 @@ describe('DashboardPage', () => {
temperature_unit: 'fahrenheit', temperature_unit: 'fahrenheit',
time_format: '12h', time_format: '12h',
show_place_description: false, show_place_description: false,
route_calculation: false,
blur_booking_codes: false, blur_booking_codes: false,
dashboard_currency: 'on', dashboard_currency: 'on',
dashboard_timezone: 'on', dashboard_timezone: 'on',
+11 -3
View File
@@ -269,6 +269,10 @@ export default function TripPlannerPage(): React.ReactElement | null {
const [showTransportModal, setShowTransportModal] = useState<boolean>(false) const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null) const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
const [transportModalDayId, setTransportModalDayId] = useState<number | 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 [fitKey, setFitKey] = useState<number>(0)
const initialFitTripId = useRef<number | null>(null) const initialFitTripId = useRef<number | null>(null)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | 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]) }, [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 handleSelectDay = useCallback((dayId, skipFit) => {
const changed = dayId !== selectedDayId const changed = dayId !== selectedDayId
@@ -826,7 +830,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
hasInspector={!!selectedPlace} hasInspector={!!selectedPlace}
hasDayDetail={!!showDayDetail && !selectedPlace} hasDayDetail={!!showDayDetail && !selectedPlace}
reservations={reservations} reservations={reservations}
showReservationStats={settings.route_calculation !== false} showReservationStats={true}
visibleConnectionIds={visibleConnections} visibleConnectionIds={visibleConnections}
onReservationClick={(rid) => { onReservationClick={(rid) => {
const r = reservations.find(x => x.id === 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) }} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
onDeletePlace={(placeId) => handleDeletePlace(placeId)} onDeletePlace={(placeId) => handleDeletePlace(placeId)}
accommodations={tripAccommodations} accommodations={tripAccommodations}
routeShown={routeShown}
routeProfile={routeProfile}
onToggleRoute={() => setRouteShown(v => !v)}
onSetRouteProfile={setRouteProfile}
onNavigateToFiles={() => handleTabChange('dateien')} onNavigateToFiles={() => handleTabChange('dateien')}
onExpandedDaysChange={setExpandedDayIds} onExpandedDaysChange={setExpandedDayIds}
pushUndo={pushUndo} pushUndo={pushUndo}
@@ -1117,7 +1125,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div> </div>
<div style={{ flex: 1, overflow: 'auto' }}> <div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left' {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 }} /> : <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> </div>
+11 -1
View File
@@ -215,7 +215,6 @@ export interface Settings {
temperature_unit: string temperature_unit: string
time_format: string time_format: string
show_place_description: boolean show_place_description: boolean
route_calculation?: boolean
blur_booking_codes?: boolean blur_booking_codes?: boolean
map_booking_labels?: boolean map_booking_labels?: boolean
map_provider?: 'leaflet' | 'mapbox-gl' map_provider?: 'leaflet' | 'mapbox-gl'
@@ -237,8 +236,19 @@ export interface RouteSegment {
mid: [number, number] mid: [number, number]
from: [number, number] from: [number, number]
to: [number, number] to: [number, number]
distance: number
duration: number
walkingText: string walkingText: string
drivingText: string drivingText: string
distanceText: string
durationText?: string
}
export interface RouteWithLegs {
coordinates: [number, number][]
distance: number
duration: number
legs: RouteSegment[]
} }
export interface RouteResult { export interface RouteResult {
-1
View File
@@ -258,7 +258,6 @@ export function buildSettings(overrides: Partial<Settings> = {}): Settings {
temperature_unit: 'fahrenheit', temperature_unit: 'fahrenheit',
time_format: '12h', time_format: '12h',
show_place_description: false, show_place_description: false,
route_calculation: false,
blur_booking_codes: false, blur_booking_codes: false,
...overrides, ...overrides,
}; };
@@ -1,7 +1,6 @@
import { renderHook, act } from '@testing-library/react'; import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useRouteCalculation } from '../../../src/hooks/useRouteCalculation'; import { useRouteCalculation } from '../../../src/hooks/useRouteCalculation';
import { useSettingsStore } from '../../../src/store/settingsStore';
import { useTripStore } from '../../../src/store/tripStore'; import { useTripStore } from '../../../src/store/tripStore';
import { buildAssignment, buildPlace } from '../../helpers/factories'; import { buildAssignment, buildPlace } from '../../helpers/factories';
import type { TripStoreState } from '../../../src/store/tripStore'; 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 // Mock the RouteCalculator module to avoid real OSRM fetch calls
vi.mock('../../../src/components/Map/RouteCalculator', () => ({ vi.mock('../../../src/components/Map/RouteCalculator', () => ({
calculateSegments: vi.fn(), calculateRouteWithLegs: vi.fn(),
calculateRoute: vi.fn(), calculateRoute: vi.fn(),
optimizeRoute: vi.fn((waypoints: unknown[]) => waypoints), optimizeRoute: vi.fn((waypoints: unknown[]) => waypoints),
generateGoogleMapsUrl: vi.fn(), 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> { function buildMockStore(assignments: Record<string, ReturnType<typeof buildAssignment>[]> = {}): Partial<TripStoreState> {
// Also populate the real Zustand store so updateRouteForDay (which reads from // 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[] = [ const MOCK_SEGMENTS: RouteSegment[] = [
{ {
from: [48.8566, 2.3522], distance: 343000,
to: [51.5074, -0.1278], duration: 12600,
mid: [50.182, 1.1122], distanceText: '343 km',
walkingText: '120 min', durationText: '3 h 30 min',
drivingText: '90 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', () => { describe('useRouteCalculation', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
// Default: route_calculation disabled
useSettingsStore.setState({ settings: { route_calculation: false } as any });
// Reset trip store assignments so each test starts clean // Reset trip store assignments so each test starts clean
useTripStore.setState({ assignments: {} } as any); 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', () => { 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 () => { it('FE-HOOK-ROUTE-004: calls calculateRouteWithLegs and exposes the returned segments', async () => {
useSettingsStore.setState({ settings: { route_calculation: true } as any });
const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 }); const p1 = buildPlace({ lat: 48.8566, lng: 2.3522 });
const p2 = buildPlace({ lat: 51.5074, lng: -0.1278 }); const p2 = buildPlace({ lat: 51.5074, lng: -0.1278 });
const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 }); const a1 = buildAssignment({ day_id: 5, order_index: 0, place: p1 });
@@ -99,32 +103,11 @@ describe('useRouteCalculation', () => {
await act(async () => {}); await act(async () => {});
expect(calculateSegments).toHaveBeenCalled(); expect(calculateRouteWithLegs).toHaveBeenCalled();
expect(result.current.routeSegments).toEqual(MOCK_SEGMENTS); 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 () => { 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 p1 = buildPlace({ lat: 10, lng: 10 });
const p2 = buildPlace({ lat: 20, lng: 20 }); const p2 = buildPlace({ lat: 20, lng: 20 });
// order_index 1 comes before 0 in the array, but should be sorted // 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 () => { 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 // Make calculateRouteWithLegs resolve slowly
let resolveSegments!: (val: RouteSegment[]) => void; let resolveSegments!: (val: typeof MOCK_ROUTE_WITH_LEGS) => void;
(calculateSegments as ReturnType<typeof vi.fn>).mockImplementationOnce( (calculateRouteWithLegs as ReturnType<typeof vi.fn>).mockImplementationOnce(
(_waypoints: unknown[], options: { signal?: AbortSignal }) => { (_waypoints: unknown[], options: { signal?: AbortSignal }) => {
return new Promise<RouteSegment[]>((resolve) => { return new Promise<typeof MOCK_ROUTE_WITH_LEGS>((resolve) => {
resolveSegments = 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 }); 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 // 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 // Cleanup
resolveSegments?.([]); resolveSegments?.(MOCK_ROUTE_WITH_LEGS);
}); });
it('FE-HOOK-ROUTE-009: AbortError from calculateSegments does not set routeSegments to []', async () => { 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'); const abortError = new Error('Aborted');
abortError.name = 'AbortError'; 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 p1 = buildPlace({ lat: 10, lng: 10 });
const p2 = buildPlace({ lat: 20, lng: 20 }); 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 () => { 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 p1 = buildPlace({ lat: 10, lng: 10 });
const p2 = buildPlace({ lat: 20, lng: 20 }); 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 () => { 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 p1 = buildPlace({ lat: 10, lng: 10 });
const p2 = buildPlace({ lat: 20, lng: 20 }); const p2 = buildPlace({ lat: 20, lng: 20 });
+1 -1
View File
@@ -91,7 +91,7 @@ describe('isRtlLanguage', () => {
describe('SUPPORTED_LANGUAGES', () => { describe('SUPPORTED_LANGUAGES', () => {
it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => { it('FE-COMP-I18N-009: contains expected entries with value/label shape', () => {
expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true) expect(Array.isArray(SUPPORTED_LANGUAGES)).toBe(true)
expect(SUPPORTED_LANGUAGES).toHaveLength(19) expect(SUPPORTED_LANGUAGES).toHaveLength(20)
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'en', label: 'English' })) expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'en', label: 'English' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'tr', label: 'Türkçe' })) expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'tr', label: 'Türkçe' }))
expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ja', label: '日本語' })) expect(SUPPORTED_LANGUAGES).toContainEqual(expect.objectContaining({ value: 'ja', label: '日本語' }))
+1 -1
View File
@@ -134,7 +134,7 @@ export function createApp(): express.Application {
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com", "https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.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://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" "https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com"
], ],
workerSrc: ["'self'", "blob:"], workerSrc: ["'self'", "blob:"],
+1 -2
View File
@@ -10,7 +10,6 @@ export const DEFAULTABLE_USER_SETTING_KEYS = [
'temperature_unit', 'temperature_unit',
'dark_mode', 'dark_mode',
'time_format', 'time_format',
'route_calculation',
'blur_booking_codes', 'blur_booking_codes',
'map_tile_url', 'map_tile_url',
] as const; ] as const;
@@ -23,7 +22,7 @@ const VALID_VALUES: Partial<Record<DefaultableKey, unknown[]>> = {
dark_mode: [true, false, 'light', 'dark', 'auto'], 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 { function parseValue(raw: string): unknown {
try { return JSON.parse(raw); } catch { return raw; } try { return JSON.parse(raw); } catch { return raw; }