diff --git a/client/src/components/Map/RouteCalculator.ts b/client/src/components/Map/RouteCalculator.ts
index af86ac01..995e0404 100644
--- a/client/src/components/Map/RouteCalculator.ts
+++ b/client/src/components/Map/RouteCalculator.ts
@@ -1,4 +1,6 @@
-import type { RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types'
+import { useSettingsStore } from '../../store/settingsStore'
+import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types'
+import { formatDistance } from '../../utils/units'
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
@@ -60,7 +62,7 @@ export async function calculateRoute(
coordinates,
distance,
duration,
- distanceText: formatDistance(distance),
+ distanceText: formatRouteDistance(distance),
durationText: formatDuration(duration),
walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(drivingDuration),
@@ -218,7 +220,7 @@ export async function calculateSegments(
duration: leg.duration,
walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(leg.duration),
- distanceText: formatDistance(leg.distance),
+ distanceText: formatRouteDistance(leg.distance),
}
})
}
@@ -238,7 +240,9 @@ export async function calculateRouteWithLegs(
}
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
- const cacheKey = `${profile}:${coords}`
+ // The cached result carries formatted leg distances, so the active distance unit is
+ // part of the key — otherwise switching km↔mi would return stale text (#1300).
+ const cacheKey = `${profile}:${getDistanceUnit()}:${coords}`
const cached = routeCache.get(cacheKey)
if (cached) return cached
@@ -265,7 +269,7 @@ export async function calculateRouteWithLegs(
duration: leg.duration,
walkingText: formatDuration(walkingDuration),
drivingText: formatDuration(leg.duration),
- distanceText: formatDistance(leg.distance),
+ distanceText: formatRouteDistance(leg.distance),
durationText: formatDuration(leg.duration),
}
}
@@ -280,11 +284,16 @@ export async function calculateRouteWithLegs(
return result
}
-function formatDistance(meters: number): string {
- if (meters < 1000) {
+function getDistanceUnit(): DistanceUnit {
+ return useSettingsStore.getState().settings.distance_unit === 'imperial' ? 'imperial' : 'metric'
+}
+
+function formatRouteDistance(meters: number): string {
+ const unit = getDistanceUnit()
+ if (unit === 'metric' && meters < 1000) {
return `${Math.round(meters)} m`
}
- return `${(meters / 1000).toFixed(1)} km`
+ return formatDistance(meters / 1000, unit)
}
function formatDuration(seconds: number): string {
diff --git a/client/src/components/Map/glProviders.test.ts b/client/src/components/Map/glProviders.test.ts
new file mode 100644
index 00000000..899c9f83
--- /dev/null
+++ b/client/src/components/Map/glProviders.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from 'vitest'
+import {
+ MAPBOX_DEFAULT_STYLE,
+ OPENFREEMAP_DEFAULT_STYLE,
+ isOpenFreeMapStyle,
+ normalizeStyleForProvider,
+ styleForActiveProvider,
+ basemapLanguage,
+} from './glProviders'
+
+describe('glProviders', () => {
+ it('keeps OpenFreeMap styles for MapLibre', () => {
+ const style = 'https://tiles.openfreemap.org/styles/bright'
+
+ expect(normalizeStyleForProvider('maplibre-gl', style)).toBe(style)
+ })
+
+ it('falls back to OpenFreeMap for MapLibre styles outside the CSP allowlist', () => {
+ expect(normalizeStyleForProvider('maplibre-gl', 'https://demotiles.maplibre.org/style.json')).toBe(
+ OPENFREEMAP_DEFAULT_STYLE,
+ )
+ expect(normalizeStyleForProvider('maplibre-gl', MAPBOX_DEFAULT_STYLE)).toBe(OPENFREEMAP_DEFAULT_STYLE)
+ })
+
+ it('leaves Mapbox styles unchanged for Mapbox GL', () => {
+ expect(normalizeStyleForProvider('mapbox-gl', MAPBOX_DEFAULT_STYLE)).toBe(MAPBOX_DEFAULT_STYLE)
+ })
+
+ it('matches the OpenFreeMap CSP host', () => {
+ expect(isOpenFreeMapStyle('https://tiles.openfreemap.org/styles/liberty')).toBe(true)
+ expect(isOpenFreeMapStyle('https://demotiles.maplibre.org/style.json')).toBe(false)
+ })
+
+ it('rejects host/userinfo spoofing and http downgrade', () => {
+ expect(isOpenFreeMapStyle('https://tiles.openfreemap.org.evil.com/styles/x')).toBe(false)
+ expect(isOpenFreeMapStyle('https://evil.com/@tiles.openfreemap.org/styles/x')).toBe(false)
+ expect(isOpenFreeMapStyle('http://tiles.openfreemap.org/styles/liberty')).toBe(false)
+ expect(isOpenFreeMapStyle(' https://tiles.openfreemap.org/styles/liberty ')).toBe(true)
+ })
+
+ it('falls back to provider defaults for empty/whitespace styles', () => {
+ expect(normalizeStyleForProvider('maplibre-gl', '')).toBe(OPENFREEMAP_DEFAULT_STYLE)
+ expect(normalizeStyleForProvider('maplibre-gl', ' ')).toBe(OPENFREEMAP_DEFAULT_STYLE)
+ expect(normalizeStyleForProvider('mapbox-gl', '')).toBe(MAPBOX_DEFAULT_STYLE)
+ expect(normalizeStyleForProvider('mapbox-gl', null)).toBe(MAPBOX_DEFAULT_STYLE)
+ })
+
+ it('styleForActiveProvider reads each provider\'s own style slot', () => {
+ const mb = 'mapbox://styles/me/custom'
+ const ofm = 'https://tiles.openfreemap.org/styles/bright'
+ expect(styleForActiveProvider('mapbox-gl', mb, ofm)).toBe(mb)
+ expect(styleForActiveProvider('maplibre-gl', mb, ofm)).toBe(ofm)
+ // An empty MapLibre slot falls back to the OpenFreeMap default, leaving mapbox untouched.
+ expect(styleForActiveProvider('maplibre-gl', mb, '')).toBe(OPENFREEMAP_DEFAULT_STYLE)
+ })
+
+ it('basemapLanguage maps TREK UI codes to basemap label codes (#1299)', () => {
+ // Pass-through for plain ISO 639-1 codes.
+ expect(basemapLanguage('en')).toBe('en')
+ expect(basemapLanguage('de')).toBe('de')
+ expect(basemapLanguage('fr')).toBe('fr')
+ // TREK-specific overrides.
+ expect(basemapLanguage('br')).toBe('pt')
+ expect(basemapLanguage('gr')).toBe('el')
+ expect(basemapLanguage('zh')).toBe('zh-Hans')
+ expect(basemapLanguage('zhTw')).toBe('zh-Hant')
+ expect(basemapLanguage('zh-TW')).toBe('zh-Hant')
+ // Falls back to English when unset.
+ expect(basemapLanguage(undefined)).toBe('en')
+ expect(basemapLanguage('')).toBe('en')
+ })
+})
diff --git a/client/src/components/Map/glProviders.ts b/client/src/components/Map/glProviders.ts
new file mode 100644
index 00000000..05710e5e
--- /dev/null
+++ b/client/src/components/Map/glProviders.ts
@@ -0,0 +1,87 @@
+export type GlMapProvider = 'mapbox-gl' | 'maplibre-gl'
+
+export interface GlStylePreset {
+ name: string
+ url: string
+ tags?: string[]
+}
+
+export const MAPBOX_DEFAULT_STYLE = 'mapbox://styles/mapbox/standard'
+export const OPENFREEMAP_DEFAULT_STYLE = 'https://tiles.openfreemap.org/styles/liberty'
+
+export const MAPBOX_STYLE_PRESETS: GlStylePreset[] = [
+ { name: 'Mapbox Standard', url: MAPBOX_DEFAULT_STYLE, tags: ['3D', 'Apple-like'] },
+ { name: 'Standard Satellite', url: 'mapbox://styles/mapbox/standard-satellite', tags: ['3D', 'Satellite'] },
+ { name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12', tags: ['3D', 'Classic'] },
+ { name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12', tags: ['3D', 'Terrain'] },
+ { name: 'Light', url: 'mapbox://styles/mapbox/light-v11', tags: ['3D', 'Minimal'] },
+ { name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11', tags: ['3D', 'Dark'] },
+ { name: 'Satellite', url: 'mapbox://styles/mapbox/satellite-v9', tags: ['3D', 'Satellite'] },
+ { name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12', tags: ['3D', 'Satellite'] },
+ { name: 'Navigation Day', url: 'mapbox://styles/mapbox/navigation-day-v1', tags: ['3D', 'Apple-like'] },
+ { name: 'Navigation Night', url: 'mapbox://styles/mapbox/navigation-night-v1', tags: ['3D', 'Dark'] },
+]
+
+export const OPENFREEMAP_STYLE_PRESETS: GlStylePreset[] = [
+ { name: 'OpenFreeMap Liberty', url: OPENFREEMAP_DEFAULT_STYLE, tags: ['OpenFreeMap', '2D'] },
+ { name: 'OpenFreeMap Bright', url: 'https://tiles.openfreemap.org/styles/bright', tags: ['OpenFreeMap', 'Classic'] },
+ { name: 'OpenFreeMap Positron', url: 'https://tiles.openfreemap.org/styles/positron', tags: ['OpenFreeMap', 'Minimal'] },
+]
+
+export function getStylePresets(provider: GlMapProvider): GlStylePreset[] {
+ return provider === 'maplibre-gl' ? OPENFREEMAP_STYLE_PRESETS : MAPBOX_STYLE_PRESETS
+}
+
+export function defaultStyleForProvider(provider: GlMapProvider): string {
+ return provider === 'maplibre-gl' ? OPENFREEMAP_DEFAULT_STYLE : MAPBOX_DEFAULT_STYLE
+}
+
+export function isOpenFreeMapStyle(style?: string | null): boolean {
+ return (style || '').trim().startsWith('https://tiles.openfreemap.org/')
+}
+
+export function normalizeStyleForProvider(provider: GlMapProvider, style?: string | null): string {
+ const trimmed = (style || '').trim()
+ if (!trimmed) return defaultStyleForProvider(provider)
+ if (provider === 'maplibre-gl') {
+ return isOpenFreeMapStyle(trimmed) ? trimmed : OPENFREEMAP_DEFAULT_STYLE
+ }
+ return trimmed
+}
+
+/** The settings key that holds the style for a given GL provider. */
+export function styleSettingKey(provider: GlMapProvider): 'mapbox_style' | 'maplibre_style' {
+ return provider === 'maplibre-gl' ? 'maplibre_style' : 'mapbox_style'
+}
+
+/**
+ * Each GL provider keeps its style in its own slot (mapbox_style / maplibre_style), so
+ * switching providers never overwrites the other one's custom style. Picks and normalizes
+ * the style for the active provider.
+ */
+export function styleForActiveProvider(
+ provider: GlMapProvider,
+ mapboxStyle?: string | null,
+ maplibreStyle?: string | null,
+): string {
+ return normalizeStyleForProvider(provider, provider === 'maplibre-gl' ? maplibreStyle : mapboxStyle)
+}
+
+// A few TREK UI language codes differ from what the GL basemap expects for its labels.
+const BASEMAP_LANG_OVERRIDES: Record
= {
+ br: 'pt', // TREK 'br' = Brazilian Portuguese
+ gr: 'el', // TREK 'gr' = Greek
+ zh: 'zh-Hans',
+ zhTw: 'zh-Hant',
+ 'zh-TW': 'zh-Hant',
+}
+
+/**
+ * Maps a TREK UI language code to the label language the GL basemap expects. Used to pin
+ * Mapbox Standard's basemap labels to the user's language so they don't fall back to the
+ * browser/OS locale and stack multiple scripts per place (#1299).
+ */
+export function basemapLanguage(uiLang: string | undefined): string {
+ const code = (uiLang || 'en').trim()
+ return BASEMAP_LANG_OVERRIDES[code] ?? code
+}
diff --git a/client/src/components/Map/locationMarkerMapbox.ts b/client/src/components/Map/locationMarkerMapbox.ts
index 44abe8ae..ff28173f 100644
--- a/client/src/components/Map/locationMarkerMapbox.ts
+++ b/client/src/components/Map/locationMarkerMapbox.ts
@@ -1,6 +1,13 @@
-import mapboxgl from 'mapbox-gl'
+import type mapboxgl from 'mapbox-gl'
import type { GeoPosition } from '../../hooks/useGeolocation'
+type MarkerConstructor = new (options?: { element?: HTMLElement; anchor?: string }) => {
+ setLngLat: (lngLat: mapboxgl.LngLatLike) => { addTo: (map: mapboxgl.Map) => unknown }
+ addTo: (map: mapboxgl.Map) => unknown
+ remove: () => void
+ getElement: () => HTMLElement
+}
+
// Build the DOM element that backs the mapbox Marker. We animate the
// heading cone via a CSS rotation so the DOM stays stable across updates
// and mapbox doesn't get confused about which element to position.
@@ -66,10 +73,10 @@ export interface LocationMarkerHandle {
// mapbox map. Returns a handle the caller uses to push position updates
// and clean up. Keeps its own DOM element and GeoJSON source so it can
// coexist with the regular trip markers.
-export function attachLocationMarker(map: mapboxgl.Map): LocationMarkerHandle {
+export function attachLocationMarker(map: mapboxgl.Map, MarkerCtor: MarkerConstructor): LocationMarkerHandle {
ensurePulseStyle()
const { root, cone } = buildLocationEl()
- const marker = new mapboxgl.Marker({ element: root, anchor: 'center' })
+ const marker = new MarkerCtor({ element: root, anchor: 'center' })
const ensureAccuracyLayer = () => {
if (map.getSource('trek-location-accuracy')) return
diff --git a/client/src/components/Map/reservationsMapbox.ts b/client/src/components/Map/reservationsMapbox.ts
index 1722690c..401a4cc8 100644
--- a/client/src/components/Map/reservationsMapbox.ts
+++ b/client/src/components/Map/reservationsMapbox.ts
@@ -8,7 +8,7 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
-import mapboxgl from 'mapbox-gl'
+import type mapboxgl from 'mapbox-gl'
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
import { escapeHtml } from '@trek/shared'
import type { Reservation, ReservationEndpoint } from '../../types'
@@ -220,18 +220,29 @@ export interface ReservationOverlayOptions {
onEndpointClick?: (reservationId: number) => void
}
+type GlMarker = {
+ setLngLat: (lngLat: mapboxgl.LngLatLike) => GlMarker
+ addTo: (map: mapboxgl.Map) => GlMarker
+ remove: () => void
+ getElement: () => HTMLElement
+}
+
+type MarkerConstructor = new (options?: { element?: HTMLElement; anchor?: string }) => GlMarker
+
export class ReservationMapboxOverlay {
private map: mapboxgl.Map
private items: TransportItem[] = []
private opts: ReservationOverlayOptions
- private endpointMarkers: mapboxgl.Marker[] = []
- private statsMarkers: { marker: mapboxgl.Marker; arc: [number, number][] }[] = []
+ private MarkerCtor: MarkerConstructor
+ private endpointMarkers: GlMarker[] = []
+ private statsMarkers: { marker: GlMarker; arc: [number, number][] }[] = []
private rerender: () => void
private destroyed = false
- constructor(map: mapboxgl.Map, opts: ReservationOverlayOptions) {
+ constructor(map: mapboxgl.Map, opts: ReservationOverlayOptions, MarkerCtor: MarkerConstructor) {
this.map = map
this.opts = opts
+ this.MarkerCtor = MarkerCtor
this.rerender = () => { if (!this.destroyed) this.render() }
this.setupLayer()
map.on('zoomend', this.rerender)
@@ -350,7 +361,7 @@ export class ReservationMapboxOverlay {
this.opts.onEndpointClick?.(item.res.id)
})
}
- const marker = new mapboxgl.Marker({ element: node, anchor: 'center' })
+ const marker = new this.MarkerCtor({ element: node, anchor: 'center' })
.setLngLat([ep.lng, ep.lat])
.addTo(map)
this.endpointMarkers.push(marker)
diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx
index 7038db2d..28c37f1b 100644
--- a/client/src/components/Planner/DayPlanSidebar.test.tsx
+++ b/client/src/components/Planner/DayPlanSidebar.test.tsx
@@ -168,6 +168,34 @@ describe('DayPlanSidebar', () => {
expect(screen.getByText('D2')).toBeInTheDocument()
})
+ // ── #1330: route tools for a single optimizable place ───────────────────────
+ it('FE-PLANNER-DAYPLAN-005b: route tools show for one located place with a bookend hotel (#1330)', () => {
+ const place = buildPlace({ name: 'Louvre', lat: 48.86, lng: 2.34 })
+ const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
+ const day2 = buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' })
+ const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
+ const accommodations = [{ id: 1, start_day_id: 10, end_day_id: 11, place_lat: 48.85, place_lng: 2.35 }]
+ render()
+ // With accommodation optimization on, one located place is routable (hotel → place → hotel),
+ // so the route tools (here the Google Maps export button) must be visible.
+ expect(screen.getByRole('button', { name: 'Open in Google Maps' })).toBeInTheDocument()
+ })
+
+ it('FE-PLANNER-DAYPLAN-005c: route tools stay hidden for one place with no bookend hotel (#1330 guard)', () => {
+ const place = buildPlace({ name: 'Louvre', lat: 48.86, lng: 2.34 })
+ const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
+ const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
+ render()
+ // No accommodation to bookend the lone place, so nothing routable — tools stay hidden.
+ expect(screen.queryByRole('button', { name: 'Open in Google Maps' })).not.toBeInTheDocument()
+ })
+
// ── Day expansion/collapse ──────────────────────────────────────────────
it('FE-PLANNER-DAYPLAN-006: days are expanded by default', () => {
diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx
index 8c168915..892945a3 100644
--- a/client/src/components/Planner/DayPlanSidebar.tsx
+++ b/client/src/components/Planner/DayPlanSidebar.tsx
@@ -35,6 +35,7 @@ import { DayPlanSidebarTimeConfirmModal } from './DayPlanSidebarTimeConfirmModal
import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDetailModal'
import { DayPlanSidebarFooter } from './DayPlanSidebarFooter'
import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types'
+import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
interface DayPlanSidebarProps {
tripId: number
@@ -154,6 +155,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const [routeLegs, setRouteLegs] = useState>({})
const [hotelLegs, setHotelLegs] = useState<{ top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } }>({})
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
+ // Recompute the hotel/route legs when the user flips km↔mi so the connector
+ // distances refresh instead of showing stale cached text (#1300).
+ const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const legsAbortRef = useRef(null)
const [draggingId, setDraggingId] = useState(null)
const [lockedIds, setLockedIds] = useState(new Set())
@@ -411,25 +415,30 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
// waypoint of the day (morning) and from the last one back to it (evening). Only when
// the "optimize from accommodation" setting is on and the day has a hotel.
const day = days.find(d => d.id === selectedDayId)
- const { morning: startHotel, evening: endHotel } =
- day && optimizeFromAccommodation !== false ? getDayBookendHotels(day, days, accommodations) : {}
+ const bookends = day && optimizeFromAccommodation !== false
+ ? getDayBookendHotels(day, days, accommodations)
+ : null
+ const startHotel = bookends?.morning
+ const endHotel = bookends?.evening
const hotelName = (a: Accommodation) => (a as any).place_name || (a as any).reservation_title || ''
// Waypoints include transport endpoints (a car return, a taxi/train arrival), so the hotel
- // legs connect even when the day starts or ends with a booking rather than a place.
- const wayPts: { lat: number; lng: number }[] = []
+ // legs connect even when the day starts or ends with a booking rather than a place. Track
+ // whether each is a place so we can skip a hotel↔transport leg that isn't real: on a day-1
+ // arrival the check-in hotel never drove to the departure airport (#1321).
+ const wayPts: { lat: number; lng: number; isPlace: boolean }[] = []
for (const it of merged) {
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
- wayPts.push({ lat: it.data.place.lat, lng: it.data.place.lng })
+ wayPts.push({ lat: it.data.place.lat, lng: it.data.place.lng, isPlace: true })
} else if (it.type === 'transport') {
const { from, to } = getTransportRouteEndpoints(it.data, selectedDayId)
- if (from) wayPts.push({ lat: from.lat, lng: from.lng })
- if (to) wayPts.push({ lat: to.lat, lng: to.lng })
+ if (from) wayPts.push({ lat: from.lat, lng: from.lng, isPlace: false })
+ if (to) wayPts.push({ lat: to.lat, lng: to.lng, isPlace: false })
}
}
const firstWay = wayPts[0]
const lastWay = wayPts[wayPts.length - 1]
- const wantTop = !!(startHotel && firstWay)
- const wantBottom = !!(endHotel && lastWay)
+ const wantTop = !!(startHotel && firstWay && (firstWay.isPlace || bookends?.morningIsSleptHere))
+ const wantBottom = !!(endHotel && lastWay && (lastWay.isPlace || bookends?.eveningIsOvernight))
if (runs.length === 0 && !wantTop && !wantBottom) { setRouteLegs({}); setHotelLegs({}); return }
@@ -465,7 +474,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
if (!controller.signal.aborted) { setRouteLegs(map); setHotelLegs(hotel) }
})()
- }, [selectedDayId, routeShown, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation])
+ }, [selectedDayId, routeShown, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation, distanceUnit])
const openAddNote = (dayId, e) => {
e?.stopPropagation()
@@ -1046,6 +1055,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarProps) {
const S = useDayPlanSidebar(props)
+ // Needed by the route-tools visibility gate in the render below (#1330); the hook
+ // keeps its own copy, so read it reactively here in the component scope too.
+ const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
const {
tripId,
trip,
@@ -1231,6 +1243,16 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
const cost = dayTotalCost(day.id, assignments, currency)
const formattedDate = formatDate(day.date, locale)
const loc = da.find(a => a.place?.lat && a.place?.lng)
+ // Route tools normally need 2+ stops, but a single located place is still
+ // routable when accommodation optimization can bookend it with a hotel
+ // (hotel → place → hotel, the same line the map draws) — otherwise the tools
+ // vanish on such a day (#1330). Purely additive to the 2+ case.
+ const routeBookends = optimizeFromAccommodation !== false ? getDayBookendHotels(day, days, accommodations) : null
+ const hasRouteBookend = !!(
+ (routeBookends?.morning?.place_lat != null && routeBookends?.morning?.place_lng != null) ||
+ (routeBookends?.evening?.place_lat != null && routeBookends?.evening?.place_lng != null)
+ )
+ const routeToolsRoutable = da.length >= 2 || (loc != null && hasRouteBookend)
const isDragTarget = dragOverDayId === day.id
const merged = mergedItemsMap[day.id] || []
const dayNoteUi = noteUi[day.id]
@@ -1595,14 +1617,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
}}
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
- onContextMenu={e => ctxMenu.open(e, [
- canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
- canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
- place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
- (place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank') },
- { divider: true },
- canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
- ])}
+ onContextMenu={e => {
+ const googleMapsUrl = getGoogleMapsUrlForPlace(place)
+ ctxMenu.open(e, [
+ canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
+ canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
+ place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
+ googleMapsUrl && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(googleMapsUrl, '_blank') },
+ { divider: true },
+ canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
+ ])
+ }}
onMouseEnter={e => {
if (!isPlaceSelected && !lockedIds.has(assignment.id))
e.currentTarget.style.background = 'var(--bg-hover)'
@@ -2151,8 +2176,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
)}
- {/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
- {(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && getDayAssignments(day.id).length >= 2 && (
+ {/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte — oder 1 Ort mit Hotel-Bookend, #1330) */}
+ {(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && routeToolsRoutable && (