From aaff8f9db49862e2fa3995b1f75cb0a17b146b44 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 16 Sep 2026 21:55:18 +0200 Subject: [PATCH] feat(roadtrip): bring the road trip addon to the phone A road trip tab on the mobile trip screen: the stage as a chain, the same map underneath it, the sheets a passenger needs, and a search along the route. The addon was desktop only because road trip MODE is a data switch rather than a view switch. It rewrites `assignments` and `places` for the whole planner hook, and every mobile sheet is permanently mounted and reads those same two lists, so turning it on there brought back documented regressions. The mode flag is therefore untouched. A second flag, `roadtripFeedActive`, feeds only the routing round and does it off the STORED assignments, so the phone gets the drive without what the mode drags along with it. The map is one instance shared by the plan tab and the road trip tab, hoisted into a single JSX expression so React keeps it in the same position and the WebGL context is never torn down on a tab switch. The corridor search is scoped to what a phone can afford: a window of the day rather than all of it, a smaller tile ceiling, a capped retry pass and a deadline. The standard search is three requests where the desk makes fifteen. The categories are deliberately not narrowed, because they all ride one query per box, so a second one costs nothing. Two gaps found while wiring it up are closed here as well. The fuel search drew its offers nowhere on the phone, so the button did nothing visible; they are in the dry band now, with the detour, the spare range and an accept button. And the map credit opened as a two line ribbon across a screen the map fills: both GL engines now start it collapsed, and Leaflet, which has no collapsed mode of its own, is given one. --- .../components/Map/MapAttributionToggle.tsx | 57 +++ client/src/components/Map/MapView.tsx | 22 +- client/src/components/Map/MapViewGL.test.tsx | 45 ++ client/src/components/Map/MapViewGL.tsx | 17 + .../Roadtrip/RoadtripCorridorPanel.tsx | 18 +- .../Roadtrip/corridorSearchModel.test.ts | 93 ++++ .../Roadtrip/corridorSearchModel.ts | 117 +++++ .../Roadtrip/roadtripRowModel.test.ts | 331 ++++++++++++ .../components/Roadtrip/roadtripRowModel.ts | 199 ++++++++ .../src/components/Roadtrip/stageMap.test.ts | 209 ++++++++ client/src/components/Roadtrip/stageMap.ts | 93 ++++ .../Roadtrip/useCorridorPois.test.ts | 69 +++ .../components/Roadtrip/useCorridorPois.ts | 64 ++- .../Roadtrip/useRoadtripCorridor.ts | 14 +- client/src/constants/tripTabs.ts | 2 + client/src/mobile/components/MChip.tsx | 13 +- client/src/mobile/mobile.css | 46 +- client/src/mobile/screens/trip/MTripShell.tsx | 151 +++++- .../src/mobile/screens/trip/dockTabs.test.ts | 62 +++ client/src/mobile/screens/trip/dockTabs.ts | 45 ++ .../src/mobile/screens/trip/map/MMapArea.tsx | 97 +++- .../screens/trip/roadtrip/MRoadtripRows.tsx | 371 ++++++++++++++ .../screens/trip/roadtrip/MRoadtripTab.tsx | 415 +++++++++++++++ .../screens/trip/roadtrip/MRtCorridorBar.tsx | 88 ++++ .../trip/roadtrip/MRtCorridorSheet.tsx | 268 ++++++++++ .../screens/trip/roadtrip/MRtDraftSheet.tsx | 181 +++++++ .../screens/trip/roadtrip/MRtInfoSheet.tsx | 237 +++++++++ .../screens/trip/roadtrip/MRtStaySheet.tsx | 285 +++++++++++ .../screens/trip/roadtrip/MRtStopSheet.tsx | 395 +++++++++++++++ .../screens/trip/roadtrip/useMRoadtrip.ts | 88 ++++ .../screens/trip/roadtrip/useMRtCorridor.ts | 146 ++++++ .../mobile/screens/trip/sheets/MMehrSheet.tsx | 10 +- .../screens/trip/sheets/MTripSheets.tsx | 15 + .../screens/trip/tabs/MTripTabPanel.tsx | 6 + .../useTripPlanner.roadtrip.test.tsx | 169 ++++++- .../src/pages/tripPlanner/useTripPlanner.ts | 55 +- client/tests/helpers/mobileTrip.ts | 41 ++ .../tests/unit/mobile/trip/MMapArea.test.tsx | 85 +++- .../unit/mobile/trip/MMehrSheet.test.tsx | 46 +- .../unit/mobile/trip/MRoadtripRows.test.tsx | 381 ++++++++++++++ .../unit/mobile/trip/MRoadtripTab.test.tsx | 475 ++++++++++++++++++ .../unit/mobile/trip/MRtCorridorBar.test.tsx | 96 ++++ .../mobile/trip/MRtCorridorSheet.test.tsx | 188 +++++++ .../unit/mobile/trip/MRtDraftSheet.test.tsx | 172 +++++++ .../unit/mobile/trip/MRtInfoSheet.test.tsx | 168 +++++++ .../unit/mobile/trip/MRtStaySheet.test.tsx | 214 ++++++++ .../unit/mobile/trip/MRtStopSheet.test.tsx | 361 +++++++++++++ .../unit/mobile/trip/MTripSheets.test.tsx | 19 +- .../unit/mobile/trip/MTripShell.test.tsx | 211 +++++++- .../unit/mobile/trip/useMRtCorridor.test.ts | 218 ++++++++ shared/src/i18n/ar/map.ts | 1 + shared/src/i18n/ar/mobileTrip.ts | 17 + shared/src/i18n/br/map.ts | 1 + shared/src/i18n/br/mobileTrip.ts | 21 + shared/src/i18n/ca/map.ts | 1 + shared/src/i18n/ca/mobileTrip.ts | 21 + shared/src/i18n/cs/map.ts | 1 + shared/src/i18n/cs/mobileTrip.ts | 20 + shared/src/i18n/de/map.ts | 1 + shared/src/i18n/de/mobileTrip.ts | 20 + shared/src/i18n/en/map.ts | 1 + shared/src/i18n/en/mobileTrip.ts | 19 + shared/src/i18n/es/map.ts | 1 + shared/src/i18n/es/mobileTrip.ts | 21 + shared/src/i18n/fr/map.ts | 1 + shared/src/i18n/fr/mobileTrip.ts | 21 + shared/src/i18n/gr/map.ts | 1 + shared/src/i18n/gr/mobileTrip.ts | 21 + shared/src/i18n/hu/map.ts | 1 + shared/src/i18n/hu/mobileTrip.ts | 21 + shared/src/i18n/id/map.ts | 1 + shared/src/i18n/id/mobileTrip.ts | 21 + shared/src/i18n/it/map.ts | 1 + shared/src/i18n/it/mobileTrip.ts | 21 + shared/src/i18n/ja/map.ts | 1 + shared/src/i18n/ja/mobileTrip.ts | 17 + shared/src/i18n/ko/map.ts | 1 + shared/src/i18n/ko/mobileTrip.ts | 17 + shared/src/i18n/nl/map.ts | 1 + shared/src/i18n/nl/mobileTrip.ts | 19 + shared/src/i18n/pl/map.ts | 1 + shared/src/i18n/pl/mobileTrip.ts | 20 + shared/src/i18n/ru/map.ts | 1 + shared/src/i18n/ru/mobileTrip.ts | 21 + shared/src/i18n/sv/map.ts | 1 + shared/src/i18n/sv/mobileTrip.ts | 19 + shared/src/i18n/tr/map.ts | 1 + shared/src/i18n/tr/mobileTrip.ts | 19 + shared/src/i18n/uk/map.ts | 1 + shared/src/i18n/uk/mobileTrip.ts | 21 + shared/src/i18n/vi/map.ts | 1 + shared/src/i18n/vi/mobileTrip.ts | 21 + shared/src/i18n/zh-TW/map.ts | 1 + shared/src/i18n/zh-TW/mobileTrip.ts | 16 + shared/src/i18n/zh/map.ts | 1 + shared/src/i18n/zh/mobileTrip.ts | 16 + 96 files changed, 7544 insertions(+), 97 deletions(-) create mode 100644 client/src/components/Map/MapAttributionToggle.tsx create mode 100644 client/src/components/Roadtrip/corridorSearchModel.test.ts create mode 100644 client/src/components/Roadtrip/corridorSearchModel.ts create mode 100644 client/src/components/Roadtrip/roadtripRowModel.test.ts create mode 100644 client/src/components/Roadtrip/roadtripRowModel.ts create mode 100644 client/src/components/Roadtrip/stageMap.test.ts create mode 100644 client/src/components/Roadtrip/stageMap.ts create mode 100644 client/src/mobile/screens/trip/dockTabs.test.ts create mode 100644 client/src/mobile/screens/trip/dockTabs.ts create mode 100644 client/src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx create mode 100644 client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx create mode 100644 client/src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx create mode 100644 client/src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx create mode 100644 client/src/mobile/screens/trip/roadtrip/MRtDraftSheet.tsx create mode 100644 client/src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx create mode 100644 client/src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx create mode 100644 client/src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx create mode 100644 client/src/mobile/screens/trip/roadtrip/useMRoadtrip.ts create mode 100644 client/src/mobile/screens/trip/roadtrip/useMRtCorridor.ts create mode 100644 client/tests/unit/mobile/trip/MRoadtripRows.test.tsx create mode 100644 client/tests/unit/mobile/trip/MRoadtripTab.test.tsx create mode 100644 client/tests/unit/mobile/trip/MRtCorridorBar.test.tsx create mode 100644 client/tests/unit/mobile/trip/MRtCorridorSheet.test.tsx create mode 100644 client/tests/unit/mobile/trip/MRtDraftSheet.test.tsx create mode 100644 client/tests/unit/mobile/trip/MRtInfoSheet.test.tsx create mode 100644 client/tests/unit/mobile/trip/MRtStaySheet.test.tsx create mode 100644 client/tests/unit/mobile/trip/MRtStopSheet.test.tsx create mode 100644 client/tests/unit/mobile/trip/useMRtCorridor.test.ts diff --git a/client/src/components/Map/MapAttributionToggle.tsx b/client/src/components/Map/MapAttributionToggle.tsx new file mode 100644 index 000000000..b4da0f194 --- /dev/null +++ b/client/src/components/Map/MapAttributionToggle.tsx @@ -0,0 +1,57 @@ +import { Info } from 'lucide-react' +import { MAP_CONTROL_SHADOW } from './mapControlShadow' +import { useTranslation } from '../../i18n' + +interface MapAttributionToggleProps { + open: boolean + onToggle: () => void + /** Same band as the locate button, so the two sit on one line. */ + bottomOffset: string +} + +/** + * The little (i) that stands in for the credit on a phone. + * + * Both GL engines ship one of these: below 640px they collapse their attribution to a + * button and open it on a tap. Leaflet has no such mode, so on a screen the map fills, + * its credit is a white two-line block parked in the middle of the picture. This is the + * missing half of that pattern, and it is a React button rather than surgery on + * Leaflet's own container because Leaflet rewrites that container's markup from scratch + * whenever a layer with an attribution is added or removed. + * + * Nothing is hidden that cannot be read: one tap shows the full credit, every link in + * it included, which is the same bargain the GL engines strike. + */ +export default function MapAttributionToggle({ open, onToggle, bottomOffset }: MapAttributionToggleProps) { + const { t } = useTranslation() + const label = t('map.attribution') + return ( + + ) +} diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index 6e36b8e36..770d14589 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -34,6 +34,7 @@ function alternativeLabelIcon(label: string, note: string, background: string, a }) } import L from 'leaflet' +import MapAttributionToggle from './MapAttributionToggle' import 'leaflet.markercluster/dist/MarkerCluster.css' import 'leaflet.markercluster/dist/MarkerCluster.Default.css' import { mapsApi } from '../../api/client' @@ -978,6 +979,16 @@ export const MapView = memo(function MapView({ ? 'calc(var(--bottom-nav-h, 84px) + 20px + var(--day-panel-h, 0px) + 12px)' : 'calc(var(--bottom-nav-h, 84px) + 12px)' + /** + * Whether the credit is showing, on a phone where it starts as the (i). + * + * Held here rather than pushed into Leaflet's own container: Leaflet rewrites that + * container's markup from scratch whenever a layer with an attribution is added or + * removed, so anything injected into it dies on the next basemap switch. A class on + * the wrapper survives that, and the CSS lives with the rest of the mobile chrome. + */ + const [attribOpen, setAttribOpen] = useState(false) + const baseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default' const updateSetting = useSettingsStore(s => s.updateSetting) const isSatellite = baseLayer === 'satellite' @@ -996,7 +1007,7 @@ export const MapView = memo(function MapView({ return ( <> -
+
} + {/* The credit as the little (i), which is what both GL engines do on a narrow map + and Leaflet has no mode for. */} + {isMobile && ( + setAttribOpen(v => !v)} + bottomOffset={locationButtonBottom} + /> + )} {/* 20px off the sidebar, not 12: the pill is round and frosted, so at the smaller gap its shadow ran into the sidebar edge and the two read as one surface. */} diff --git a/client/src/components/Map/MapViewGL.test.tsx b/client/src/components/Map/MapViewGL.test.tsx index 059f295b9..e6258e4e9 100644 --- a/client/src/components/Map/MapViewGL.test.tsx +++ b/client/src/components/Map/MapViewGL.test.tsx @@ -2066,3 +2066,48 @@ describe('MapViewGL', () => { expect(glMap.setLayoutProperty).toHaveBeenCalledWith('trip-satellite-raster', 'visibility', 'none') }) }) +describe('MapViewGL attribution', () => { + const flushFrames = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 40)) }) + + /** The compact control as maplibre leaves it on a narrow map: collapsed class, open. */ + function compactAttribution(container: HTMLElement): HTMLElement { + const el = document.createElement('details') + el.className = 'maplibregl-ctrl maplibregl-ctrl-attrib maplibregl-compact maplibregl-compact-show' + el.setAttribute('open', '') + // Inside the map container itself, where maplibre puts its controls. + container.querySelectorAll('div')[1]?.appendChild(el) + return el + } + + it('FE-COMP-MAPVIEWGL-077: the credit starts as the little button, not as a ribbon', async () => { + const { container } = render() + await flushFrames() + const attrib = compactAttribution(container) + + const idle = glMap.once.mock.calls.find(c => c[0] === 'idle')?.[1] as (() => void) | undefined + expect(idle).toBeDefined() + act(() => { idle!() }) + + // Both engines open theirs on a phone and only tuck it away on the first drag, so + // the first thing on a screen the map fills is a two-line band across the bottom. + expect(attrib.classList.contains('maplibregl-compact-show')).toBe(false) + expect(attrib.hasAttribute('open')).toBe(false) + // Still there, still one tap away: nothing about the credit is removed. + expect(attrib.classList.contains('maplibregl-ctrl-attrib')).toBe(true) + }) + + it('FE-COMP-MAPVIEWGL-078: a map wide enough to show the credit in full is left alone', async () => { + const { container } = render() + await flushFrames() + const el = document.createElement('details') + // No `compact-show`, which is what a map over 640px reports. + el.className = 'maplibregl-ctrl maplibregl-ctrl-attrib' + el.setAttribute('open', '') + container.querySelectorAll('div')[1]?.appendChild(el) + + const idle = glMap.once.mock.calls.find(c => c[0] === 'idle')?.[1] as (() => void) | undefined + act(() => { idle!() }) + + expect(el.hasAttribute('open')).toBe(true) + }) +}) diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx index f21dddf04..f989434c0 100644 --- a/client/src/components/Map/MapViewGL.tsx +++ b/client/src/components/Map/MapViewGL.tsx @@ -1170,6 +1170,23 @@ export function MapViewGL({ maxWidth: '240px', className: 'trek-map-popup', }) + /* + * The credit starts as the little (i), not as a ribbon across the map. + * + * Both engines collapse their attribution below 640px, and both then open it + * anyway and wait for a drag before tucking it away. On a phone, where the map IS + * the screen, that means the first thing anybody sees is a two-line grey band + * over the bottom of it. Dragging is exactly what maplibre does on its own + * `drag` handler; this only starts where that would have ended up, so the credit + * is one tap away and nothing about it is removed. + */ + map.once('idle', () => { + const attrib = containerRef.current?.querySelector('.maplibregl-ctrl-attrib.maplibregl-compact-show') + ?? containerRef.current?.querySelector('.mapboxgl-ctrl-attrib.mapboxgl-compact-show') + attrib?.classList.remove('maplibregl-compact-show', 'mapboxgl-compact-show') + attrib?.removeAttribute('open') + }) + // Hand the map out so the trip planner can render its own compass pill next to // the POI pill (a custom round control instead of Mapbox's default top-right one). onMapReadyRef.current?.(map) diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx index dabd0fca5..505b5f82c 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx @@ -12,6 +12,7 @@ import { formatDistance } from '../../utils/units' import CustomSelect from '../shared/CustomSelect' import RoadtripCategoryPicker from './RoadtripCategoryPicker' import { serviceColor } from './roadtripModel' +import { alongLabel, offRouteLabel } from './corridorSearchModel' import { CORRIDOR_CATEGORY_BY_KEY } from './stopKinds' import { FS } from './typeScale' import { CORRIDOR_CATEGORY_KEYS, CORRIDOR_SECTION_KM, CORRIDOR_WIDTHS_KM, type RoadtripCorridor } from './useRoadtripCorridor' @@ -154,14 +155,11 @@ function ResultRow({ poi, onAdd, onFocus }: { poi: CorridorPoi; onAdd?: () => vo row still wraps cleanly at a narrow width because nothing has to break around a separator. */}
- - {t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })} - - - {poi.alongKm < 0.5 - ? t('roadtrip.poi.atStart') - : t('roadtrip.poi.alongRoute', { distance: formatDistance(poi.alongKm, distanceUnit) })} - + {/* Written by the shared model rather than here: the phone says the same two + things in one line, and the wording of a hit should not depend on which + shell is reading it. */} + {offRouteLabel(poi.offRouteKm, distanceUnit, t)} + {alongLabel(poi.alongKm, distanceUnit, t)} {/* What the charger offers, where OSM says. Socket names are proper nouns and stay as they are; the numbers around them are what decides whether a car can use it at all. A station that says nothing shows nothing rather than a row @@ -455,7 +453,9 @@ export default function RoadtripCorridorPanel({
+ )} + {trTab === 'dateien' && (
} label={t('common.upload')} onClick={() => setUploadFilesSignal(s => s + 1)} /> @@ -581,13 +679,16 @@ export default function MTripShell({ ) })()} - {trTab === 'plan' ? ( + {/* Same slot, same markup, same icons in both map tabs, so the switch does + not move or change shape when the tab does. The two views stay separate + states: coupling them would drag one tab into the other's half. */} + {MAP_TABS.has(trTab) ? ( - {view === 'plan' ? : } + {mapFront ? : } ) : ( diff --git a/client/src/mobile/screens/trip/dockTabs.test.ts b/client/src/mobile/screens/trip/dockTabs.test.ts new file mode 100644 index 000000000..27ce77f51 --- /dev/null +++ b/client/src/mobile/screens/trip/dockTabs.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { DOCK_CAP, DOCK_PRIORITY, dockTabIds, pickDockTabs } from './dockTabs' + +// FE-MOB-DOCK-001 to FE-MOB-DOCK-008 + +/** Alles, was ein voll ausgestatteter Trip anbieten kann, in der Reihenfolge des Docks. */ +const ALL = ['plan', 'roadtrip', 'transports', 'buchungen', 'finanzplan', 'listen'] + +const ids = (enabled: string[]) => pickDockTabs(new Set(enabled)).map((tab) => tab.id) + +describe('pickDockTabs', () => { + it('FE-MOB-DOCK-001: five seats beside More, so the packing list gives way to the drive', () => { + // Ein sechster Kreis laesst 3px Abstand, auf die kein Daumen zielt. + expect(DOCK_CAP).toBe(5) + expect(ids(ALL)).toEqual(['plan', 'roadtrip', 'transports', 'buchungen', 'finanzplan']) + expect(ids(ALL)).not.toContain('listen') + }) + + it('FE-MOB-DOCK-002: without the road trip the packing list has its seat back', () => { + expect(ids(ALL.filter((id) => id !== 'roadtrip'))).toEqual([ + 'plan', + 'transports', + 'buchungen', + 'finanzplan', + 'listen', + ]) + }) + + it('FE-MOB-DOCK-003: priority decides who is cut, not the order the tabs were enabled', () => { + const scrambled = ['listen', 'finanzplan', 'roadtrip', 'plan'] + expect(ids(scrambled)).toEqual(['plan', 'roadtrip', 'finanzplan', 'listen']) + }) + + it('FE-MOB-DOCK-004: a trip with few sections gets few seats, never padded', () => { + expect(ids(['plan', 'listen'])).toEqual(['plan', 'listen']) + expect(ids([])).toEqual([]) + }) + + it('FE-MOB-DOCK-005: a plugin tab never takes a seat, it belongs in the More sheet', () => { + expect(ids(['plugin:trip-todos', 'plan'])).toEqual(['plan']) + expect(ids(['plugin:trip-todos', 'plugin:weather'])).toEqual([]) + }) + + it('FE-MOB-DOCK-006: a seat is the priority entry itself, icon and all', () => { + // Das Dock zeichnet den Kreis aus tab.icon; eine umgebaute Kopie ohne Icon bliebe leer. + expect(pickDockTabs(new Set(ALL))).toEqual(DOCK_PRIORITY.slice(0, DOCK_CAP)) + expect(pickDockTabs(new Set(ALL))[1]).toBe(DOCK_PRIORITY[1]) + }) +}) + +describe('dockTabIds', () => { + it('FE-MOB-DOCK-007: names exactly the ids that got a seat, so the More sheet leaves them out', () => { + const enabled = new Set(ALL) + expect([...dockTabIds(enabled)]).toEqual(pickDockTabs(enabled).map((tab) => tab.id)) + expect(dockTabIds(enabled).has('listen')).toBe(false) + expect(dockTabIds(enabled).has('roadtrip')).toBe(true) + }) + + it('FE-MOB-DOCK-008: never names more than the dock can hold', () => { + expect(dockTabIds(new Set([...ALL, 'plugin:trip-todos'])).size).toBe(DOCK_CAP) + }) +}) diff --git a/client/src/mobile/screens/trip/dockTabs.ts b/client/src/mobile/screens/trip/dockTabs.ts new file mode 100644 index 000000000..8b30b0add --- /dev/null +++ b/client/src/mobile/screens/trip/dockTabs.ts @@ -0,0 +1,45 @@ +import { Map as MapIcon, TrainFront, Ticket, Wallet, PackageCheck, Route } from 'lucide-react' +import type { LucideIcon } from 'lucide-react' + +/** + * Which trip sections get a seat in the phone dock, and in which order. + * + * The dock is a fixed 62px pill with `justify-around` and no overflow, so seats are + * a real budget, not a preference: at 375px it measures 343px, 315px of it usable, + * and every circle is 42px. Five sections plus More leave 9px between them. A sixth + * takes that down to 3px, which is not a gap any thumb can aim at. + * + * Hence DOCK_CAP. Everything past it surfaces as a tile in the More sheet, which is + * where files, collaboration and plugin tabs have always lived. + * + * This list is the ONE source for both sides. It used to be two hand-kept copies + * (one here, one in MMehrSheet), and extending only one of them showed the same tab + * in the dock AND as a tile. + */ +export const DOCK_PRIORITY: { id: string; icon: LucideIcon }[] = [ + { id: 'plan', icon: MapIcon }, + { id: 'roadtrip', icon: Route }, + { id: 'transports', icon: TrainFront }, + { id: 'buchungen', icon: Ticket }, + { id: 'finanzplan', icon: Wallet }, + { id: 'listen', icon: PackageCheck }, +] + +/** Seats beside the More button. */ +export const DOCK_CAP = 5 + +/** + * The dock's seats for a trip, given the tabs that are actually enabled. + * + * Priority order decides who is cut, not enabled order: with the road trip addon on, + * the packing list moves into the More sheet rather than the drive never getting a + * seat. Turning the addon off puts it straight back. + */ +export function pickDockTabs(enabledTabIds: ReadonlySet): { id: string; icon: LucideIcon }[] { + return DOCK_PRIORITY.filter(tab => enabledTabIds.has(tab.id)).slice(0, DOCK_CAP) +} + +/** The ids that got a seat, for the More sheet to leave out. */ +export function dockTabIds(enabledTabIds: ReadonlySet): Set { + return new Set(pickDockTabs(enabledTabIds).map(tab => tab.id)) +} diff --git a/client/src/mobile/screens/trip/map/MMapArea.tsx b/client/src/mobile/screens/trip/map/MMapArea.tsx index a8c695249..3810bda71 100644 --- a/client/src/mobile/screens/trip/map/MMapArea.tsx +++ b/client/src/mobile/screens/trip/map/MMapArea.tsx @@ -5,8 +5,16 @@ import { TripRouteOverviewPill, TripRouteOverviewPanel } from '../../../../compo import { DawarichTrailPill } from '../../../../components/Map/DawarichTrailPill' import PoiCategoryPill from '../../../../components/Map/PoiCategoryPill' import { usePoiExplore } from '../../../../components/Map/usePoiExplore' +import { useMergedMapPois } from '../../../../components/Map/useMergedMapPois' +import { stageOf } from '../../../../components/Roadtrip/roadtripRowModel' +import { stageMapData } from '../../../../components/Roadtrip/stageMap' +import { useRoadtripSettings } from '../../../../hooks/useRoadtripSettings' import { useSettingsStore } from '../../../../store/settingsStore' import type { MMapAreaProps } from '../MTripShell' +import type { Poi } from '../../../../components/Map/poiCategories' + +/** One array, so an explore set switched off does not move every pin on the stage. */ +const NO_POIS: Poi[] = [] /** * Fullscreen map layer of the mobile trip screen (plan tab). Stays mounted for @@ -33,7 +41,47 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) { const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false const distanceUnit = useSettingsStore(s => s.settings.distance_unit) - const mapActive = shell.view === 'map' + const dayColorsOn = useRoadtripSettings(s => s.roadtrip_day_colors, planner.tripId) + + // One instance, two tabs. `mapFront` is true whenever the map is the front layer + // in either of them, so the floating chrome below keys off that rather than off + // `view`, which only ever meant the plan tab. + const mapActive = shell.mapFront + const onStage = shell.trTab === 'roadtrip' + + // The stage the road trip tab is looking at, and what the map draws for it. Passed + // through unconditionally while that tab is open, list half included: `focusPoints` + // going array → undefined → array is two dependency changes to React, and the + // second one would throw away whatever the traveller had panned to. + const stage = onStage ? stageOf(planner.roadtripRoutes.days, planner.selectedDayId) : null + const stageMap = onStage + ? stageMapData(planner.roadtripRoutes, stage, !!dayColorsOn) + : null + + // Only what the stage carries, so a trip's other 200 pins stay off a screen that + // is answering one question. Without a stage (the all-days view) every planned + // place comes back, which is what the drive looks like end to end. + const stagePlaces = stageMap && stage + ? planner.mapPlaces.filter(p => stageMap.placeIds.has(p.id)) + : planner.roadtripMapPlaces + + /** + * The pins, and they are not the same question on the two tabs. + * + * On the stage: what the corridor search found and what the fuel search is offering, + * both measured along the drive. The explore categories stay off it, and so does their + * bar. "What is around this piece of map" has no relationship to a route, and two + * search bars stacked over a 375px map is the clutter this tab exists to avoid. The + * plan tab is one tap away and shows the same map. + * + * Merged through the planner's own hook rather than concatenated here, because the + * sets overlap: the same petrol station found twice would be two pins on one roof. + */ + const pois = useMergedMapPois( + onStage ? planner.roadtripCorridor.visible : null, + onStage ? NO_POIS : poi.pois, + planner.refuel.offered, + ) return ( // `isolate` keeps the map's internal z-indexes (Leaflet panes, the z-1000 @@ -42,22 +90,37 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) { // The dock is 62px tall at safe-bottom + 12, so a 74px --bottom-nav-h puts // the round controls (which add their own 12px) a dock's gap above it — // close enough to the thumb to reach one-handed, clear of the dock itself. -
+ // The stage bar takes the band the round controls normally sit in, so on that + // tab they move up by its height plus its own gap. The lift is its OWN variable + // folded into the class rather than an inline --bottom-nav-h: the compass band + // below is identified by being the one element that sets that name inline, and a + // second one would make that ambiguous. +
shell.openSheet('transport', { reservationId: rid })} - pois={poi.pois} - onPoiClick={marker => planner.openAddPlaceFromPoi(marker, planner.selectedDayId)} + pois={pois} + // On the stage, through the road trip's own door: it recognises a corridor hit by + // the distance it carries and sends it to the draft sheet with the stop kind, the + // stay and the position in the chain already worked out. The plan tab has no + // chain to place anything in, so there it stays the plain place form. + onPoiClick={onStage + ? planner.handlePoiClick + : marker => planner.openAddPlaceFromPoi(marker, planner.selectedDayId)} onViewportChange={poi.onViewportChange} onMapReady={setGlMap} /> @@ -86,7 +155,7 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) { sits below the day-chip rail (safe-top + 50px + ~42px chip height) and takes the full width between the screen margins, so its segments are the same size as everything else the thumb aims at on this screen. */} - {mapActive && poiPillEnabled && ( + {mapActive && !onStage && poiPillEnabled && (
{!planner.roadtripActive && planner.overviewActive && ( +