} 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 && (
+
+
+ )
+ }
+ return (
+ // Deliberately not font-geist: `.m-root .font-geist` caps the tier at Medium,
+ // which would quietly undo the bold on a number that has to read at 12px.
+
+ {row.number}
+
+ )
+}
+
+function Mark({ icon, children, tone }: { icon: ReactNode; children: ReactNode; tone?: 'warn' }) {
+ return (
+
+ {icon}
+ {children}
+
+ )
+}
+
+/** The short form of a finding: the number only, the sentence waits in the sheet. */
+function warningMark(warning: ScheduleWarning, chrome: RowChrome): ReactNode {
+ const { t, unit } = chrome
+ if (warning.code === 'late') {
+ return }>{`+${warning.minutes ?? 0} min`}
+ }
+ if (warning.code === 'range') {
+ return }>{formatDistance(warning.sinceKm ?? 0, unit)}
+ }
+ if (warning.code === 'leg') {
+ return }>{formatDurationShort((warning.overMinutes ?? 0) * 60)}
+ }
+ return }>{t('roadtrip.warn.overnight')}
+}
+
+/**
+ * A stop of the stage. The whole row is the tap target, at least 46px tall, which is
+ * why it is a div with role rather than a button: the desktop needs controls nested
+ * inside its row, and a button inside a button is not markup.
+ */
+export function RtStopRow({ row, chrome, onOpen }: {
+ row: StopRow
+ chrome: RowChrome
+ onOpen: () => void
+}) {
+ const { t, unit } = chrome
+ const marks: ReactNode[] = []
+ if (row.dwellMinutes) {
+ marks.push(
+ }>{formatDurationShort(row.dwellMinutes * 60)},
+ )
+ }
+ if (row.warning) marks.push({warningMark(row.warning, chrome)} )
+ if (!marks.length && row.offRoadMeters) {
+ marks.push(
+ }>
+ {formatDistance(row.offRoadMeters / 1000, unit)}
+ ,
+ )
+ }
+
+ return (
+ { if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onOpen() } }}
+ className="grid cursor-pointer items-center gap-x-[10px] py-1" style={{ gridTemplateColumns: '34px 1fr auto' }}
+ >
+
+
+
+ {row.stop.name}
+
+ {marks.length > 0 && {marks} }
+
+ {row.time && (
+ // dir=ltr so a clock reads the same way round in an RTL locale.
+
+ {row.pinned && }
+ {row.time}
+
+ )}
+
+ )
+}
+
+const LEG_ICONS: Record = {
+ walking: Footprints,
+ cycling: Bike,
+}
+
+/** The drive between two stops. 12px rather than the desktop's 10: a four-hour leg
+ * deserves the space it takes. A div, never a button: tapping a leg to pick another
+ * route is a desktop affordance and costs up to five routing requests. */
+export function RtLegRow({ seg, mode, chrome }: {
+ seg: RouteSegment | undefined
+ mode: string | null
+ chrome: RowChrome
+}) {
+ const { t } = chrome
+ const Icon = mode && LEG_ICONS[mode] ? LEG_ICONS[mode] : mode?.startsWith('plugin:') ? Zap : CarFront
+ const text = seg
+ ? t('roadtrip.leg.driveText', { distance: seg.distanceText, time: seg.durationText ?? seg.drivingText })
+ : t('roadtrip.leg.pending')
+ return (
+
+ {/* flex-col, not flex: in a row the dashes would stretch sideways and read as
+ a barcode. --m-conn rather than --m-rowbr, because the row hairline is 4.5%
+ alpha and disappears in sunlight, and this line is what says "you drive here". */}
+
+
+
+
+
+ {text}
+
+
+
+ )
+}
+
+/**
+ * The one loud row of the chain: the tank runs out here.
+ *
+ * Full width and a 44px button, where the desktop has a 22px reserve light. This is
+ * the single situation on a drive that forces an action, so it gets the only shout
+ * on the screen.
+ */
+export function RtDryRow({ intoLegKm, chrome, electric, onSearch, offline, refuel, dayId, legIndex, onAccept }: {
+ intoLegKm: number
+ chrome: RowChrome
+ electric: boolean
+ onSearch?: () => void
+ offline: boolean
+ refuel: RefuelSearch
+ dayId: number
+ legIndex: number
+ /** Takes one of the offers onto the trip. Absent for somebody who may not edit days. */
+ onAccept?: (poi: RefuelCandidate) => void
+}) {
+ const { t, unit } = chrome
+ // One search is open at a time and it names the dry point it belongs to, so a band
+ // three legs down the chain does not light up for a question asked about this one.
+ const open = refuel.openFor === `${dayId}:${legIndex}`
+ const settled = open && !refuel.loading && refuel.outcome
+ return (
+
+
+ {electric ? : }
+
+ {electric ? t('roadtrip.refuel.dryElectric') : t('roadtrip.refuel.dry')}
+
+
+
+ {t('roadtrip.refuel.after', { distance: formatDistance(Math.round(intoLegKm), unit) })}
+
+ {onSearch && (
+ <>
+
+
+ {electric ? t('roadtrip.refuel.findElectric') : t('roadtrip.refuel.find')}
+
+ {/* Blunt rather than greyed out: the button keeps its shape and says why. */}
+ {offline && (
+
{t('mobileTrip.rtSearchOffline')}
+ )}
+ >
+ )}
+
+ {open && refuel.loading && (
+
{t('roadtrip.refuel.looking')}
+ )}
+
+ {/* The answer, in the band that asked. The desktop puts it in the rail row for the
+ same reason: a station offered somewhere other than where the tank runs out is
+ an offer somebody has to go and find. Three at most: this is an offer beside a
+ plan, and the search sheet above is where all of them live. */}
+ {settled && (refuel.results.length ? (
+
+ {refuel.results.slice(0, 3).map(poi => (
+ onAccept(poi) : undefined}
+ />
+ ))}
+
+ ) : (
+ /* Three sentences for three different facts. "Nothing on this stretch" after a
+ request that failed states something that was never checked. */
+
+ {refuel.outcome === 'none'
+ ? t('roadtrip.refuel.none')
+ : refuel.outcome === 'incomplete'
+ ? t('roadtrip.refuel.incomplete')
+ : t('roadtrip.refuel.failed')}
+
+ ))}
+
+ )
+}
+
+/**
+ * One station on offer: what it is, the two figures that decide it, and a 38px plus.
+ *
+ * The detour is what the list is sorted by, because everything in it is reachable
+ * already and what separates them is what the stop costs. What is left in the tank when
+ * the car draws level comes second, with that detour counted in. Both as plain labelled
+ * figures rather than the desktop's icon badges: a tooltip is the desktop's way of
+ * saying which number is which, and there is no hover here to carry it.
+ */
+function RefuelOffer({ poi, chrome, electric, onAccept }: {
+ poi: RefuelCandidate
+ chrome: RowChrome
+ electric: boolean
+ onAccept?: () => void
+}) {
+ const { t, unit } = chrome
+ const kind = STOP_KIND_BY_KEY[poi.category]
+ const KindIcon = kind?.Icon ?? Fuel
+ return (
+
+
+
+
+
+ {poi.name}
+
+ {[
+ t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, unit) }),
+ t('roadtrip.refuel.spare', { distance: formatDistance(Math.round(poi.spareKm), unit) }),
+ ].join(' · ')}
+
+
+ {onAccept && (
+
+
+
+ )}
+
+ )
+}
+
+/** Where a day ended for the night, or picked back up. Information, not a control. */
+export function RtAutoRow({ phase, time, chrome }: {
+ phase: 'end' | 'resume'
+ time: string | null
+ chrome: RowChrome
+}) {
+ const { t } = chrome
+ const Icon = phase === 'end' ? Moon : Sunrise
+ return (
+
+
+
+ {phase === 'end' ? t('roadtrip.window.stop') : t('roadtrip.window.resume')}
+
+ {time && {time} }
+
+ )
+}
+
+/**
+ * A night drive crossing into this card.
+ *
+ * The only place a stage shows stops that belong to another day. Without it the
+ * traveller counts wrong at a night crossing, because the card opens with stops it
+ * does not own.
+ */
+export function RtSpillRow({ fromDayNumber, departs, chrome, children }: {
+ fromDayNumber: number
+ departs: string | null
+ chrome: RowChrome
+ children?: ReactNode
+}) {
+ const { t } = chrome
+ return (
+
+
+
+
+ {t('roadtrip.spill.title', { number: fromDayNumber })}
+
+ {departs && (
+
+ {t('roadtrip.spill.departs', { time: departs })}
+
+ )}
+
+ {children &&
{children}
}
+
+ )
+}
diff --git a/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx b/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx
new file mode 100644
index 000000000..f9fa449ad
--- /dev/null
+++ b/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx
@@ -0,0 +1,415 @@
+import { useRef } from 'react'
+import { AlertTriangle, MapPin, Navigation } from 'lucide-react'
+import { useMPlanDaySwipe } from '../plan/useMPlanDaySwipe'
+import { useMRoadtrip } from './useMRoadtrip'
+import { useMRtCorridor } from './useMRtCorridor'
+import MRtCorridorBar from './MRtCorridorBar'
+import { RtAutoRow, RtDryRow, RtLegRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows'
+import MDancingTrek from '../../../components/MDancingTrek'
+import { dayColor } from '../../../../components/Roadtrip/dayColors'
+import { formatDurationShort } from '../../../../components/Roadtrip/roadtripModel'
+import { getNavigationTargets } from '../../../../components/Planner/placeNavigation'
+import { useRoadtripSettings } from '../../../../hooks/useRoadtripSettings'
+import { useSettingsStore } from '../../../../store/settingsStore'
+import { formatDistance } from '../../../../utils/units'
+import { isRtlLanguage } from '../../../../i18n'
+import type { MTripTabPanelProps } from '../MTripShell'
+import type { StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
+
+/**
+ * The road trip tab: one day of the drive, as a chain or on the map.
+ *
+ * Both halves are this one component because they share a stage, and the switch
+ * between them must not lose it. The map itself is not here: it is the shell's single
+ * instance, shared with the plan tab and lying underneath, so the map half of this
+ * screen is nothing but the stage bar floating over it.
+ *
+ * The stage picker is the shell's day-chip rail. This panel renders at z-20, below the
+ * chips at z-25, which is the whole of that: the chips were always mounted and only
+ * ever hidden because the other tabs cover them at z-30. With them come the auto
+ * scroll, the day tints, the label format and the second tap that opens the day sheet.
+ */
+export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
+ const { t, days } = planner
+ const panelRef = useRef(null)
+ const cardRef = useRef(null)
+ const rt = useMRoadtrip(planner)
+ const corridor = useMRtCorridor(planner, shell)
+ const unit = useSettingsStore(s => s.settings.distance_unit)
+ const dayColorsOn = useRoadtripSettings(s => s.roadtrip_day_colors, planner.tripId)
+ const chrome: RowChrome = { t, unit }
+
+ // The same gesture the day timeline uses, called rather than rebuilt: 370 lines of
+ // worked-out conflict avoidance, down to the dead 24px gutter that stops iOS from
+ // reading a swipe as its own back gesture.
+ const daySwipe = useMPlanDaySwipe({
+ days,
+ selectedDayId: planner.selectedDayId,
+ // skipFit: the map underneath stays mounted, and the stage's own focus points
+ // frame it as soon as the selection moves.
+ onSelectDay: dayId => planner.handleSelectDay(dayId, true),
+ panelRef,
+ cardRef,
+ editing: false,
+ dragging: false,
+ menuOpen: !!shell.sheet,
+ rtl: isRtlLanguage(planner.language),
+ describeDay: (i, n) => t('mobileTrip.dayAnnounce', { current: i + 1, total: n }),
+ })
+
+ const stage = rt.stage
+ const tint = stage && dayColorsOn ? dayColor(stage.dayNumber) : null
+
+ const openStop = (row: StopRow) => {
+ shell.openSheet('rtstop', { dayId: row.stop.ownerDayId, assignmentId: row.stop.assignmentId })
+ }
+
+ // The search bar sits in the same band on both halves, at the same offset, so the
+ // list/map switch never moves it: it is one stage seen two ways, and a control that
+ // jumps between them is a control you have to find twice. The band and the margins
+ // are the plan tab's POI bar, for the same reason.
+ //
+ // Only over a stage. Without one there is no drive to search along: the corridor would
+ // fall back to the trip's first routed day, which is not the one on screen, and every
+ // distance it answered with would be measured against a road nobody is looking at.
+ const searchBar = stage && (
+
+ shell.openSheet('rtsearch')} />
+
+ )
+
+ // ── Map half: the two bars, and nothing else. Everything between them belongs to the
+ // map instance the shell keeps mounted, so this layer must not swallow taps.
+ if (shell.rtView === 'map') {
+ return (
+
+ {searchBar}
+ {/* Just above the dock, the same gap everything else on this shell keeps from
+ it. Twenty pixels higher left a band of map between the two that read as a
+ gap rather than as breathing room, and pushed the map's own buttons into
+ the bar's top edge. */}
+
+ {/* `rt` is handed down rather than looked up again: useMRoadtrip owns a
+ 30s interval and a network subscription, and a second call would run a
+ second pair of them for the same screen. */}
+
+
+
+ )
+ }
+
+ // ── List half ────────────────────────────────────────────────────────────────
+ return (
+
+
{daySwipe.announcement}
+ {searchBar}
+
+ {rt.empty || !stage ? (
+
+ ) : (
+ <>
+ {/* Head card: the two figures a morning is planned around, at 30px, which is
+ what makes them readable at arm's length in a phone cradle. */}
+
+
+
+ {stageDateLabel(planner, stage.dayId) ?? t('roadtrip.day', { number: stage.dayNumber })}
+
+ {tint && (
+ // theme-lint-disable: the day colours are map paint, see dayColors.ts.
+
+ )}
+
+
+
+
+ {t('roadtrip.stay.leave')}
+
+
+ {firstDeparture(rt.rows) ?? '-'}
+
+
+
+
+ {t('roadtrip.stay.arrive')}
+
+
+ {lastArrival(rt.rows) ?? '-'}
+
+
+
+
+
+ {stage.distance > 0
+ ? t('roadtrip.leg.driveText', {
+ distance: formatDistance(stage.distance / 1000, unit),
+ time: formatDurationShort(stage.duration),
+ })
+ : t('roadtrip.summary.partial')}
+
+
+ {t('roadtrip.day.stopCount', { count: rt.stops })}
+
+
+ {stage.dayWarning && (
+
+
+
+ {t('roadtrip.limit.dayOver', {
+ time: formatDurationShort((stage.dayWarning.minutes - stage.dayWarning.limitMinutes) * 60),
+ })}
+
+ {/* The sentence the desktop hides in a title attribute. */}
+
+ {t('roadtrip.limit.hint')}
+
+
+ )}
+
+
+ {rt.upNext &&
}
+
+
+ {rt.rows.map((row, i) => {
+ if (row.kind === 'stop') return openStop(row)} />
+ if (row.kind === 'leg') return
+ if (row.kind === 'auto') return
+ if (row.kind === 'spill') {
+ return
+ }
+ return (
+ planner.askRefuel(stage.dayId, dryPointFor(planner, stage.dayId, row.legIndex))
+ : undefined}
+ onAccept={planner.can('day_edit', planner.trip)
+ ? poi => planner.acceptRefuel(stage.dayId, poi, dryPointFor(planner, stage.dayId, row.legIndex))
+ : undefined}
+ />
+ )
+ })}
+
+ >
+ )}
+
+
+ )
+}
+
+/**
+ * The stage bar over the map: which day, where to, what it costs.
+ *
+ * One line rather than a sheet that can be dragged open. A drag sheet brings a panel
+ * height the map fit has to account for and lifts the locate and style buttons with
+ * it, which is a bigger change than this half of the screen needs to be useful.
+ */
+function StageBar({ planner, shell, rt }: {
+ planner: MTripTabPanelProps['planner']
+ shell: MTripTabPanelProps['shell']
+ rt: ReturnType
+}) {
+ const { t } = planner
+ const unit = useSettingsStore(s => s.settings.distance_unit)
+ const dayColorsOn = useRoadtripSettings(s => s.roadtrip_day_colors, planner.tripId)
+ const stage = rt.stage
+
+ if (!stage) {
+ // No stage means the day filter is off, which on the map is the whole drive.
+ return (
+
+ {t('roadtrip.title')}
+
+ {formatDistance(planner.roadtripRoutes.totalDistance / 1000, unit)}
+
+
+ )
+ }
+
+ const tint = dayColorsOn ? dayColor(stage.dayNumber) : null
+ const named = stage.stops.filter(s => !s.automaticNight)
+ const last = named.length ? named[named.length - 1] : undefined
+
+ return (
+
+ {tint && (
+ // theme-lint-disable: map paint, see dayColors.ts.
+
+ )}
+
+
+ {last?.name ?? t('roadtrip.stop.none')}
+
+
+ {[t('roadtrip.day.stopCount', { count: rt.stops }), lastArrivalOf(stage)].filter(Boolean).join(' · ')}
+
+
+
+ {stage.distance > 0 ? formatDistance(stage.distance / 1000, unit) : t('roadtrip.leg.pending')}
+
+
+ )
+}
+
+/**
+ * What is coming up, measured against the clock rather than the plan.
+ *
+ * Without it the stage is a morning plan in a narrow column: leave at 09:15 instead of
+ * 08:30 and every figure on the screen is quietly wrong for the rest of the day. The
+ * planned time stays put and the delta sits beside it, because the planned time is the
+ * number you want to compare against.
+ */
+function UpNext({ planner, shell, rt, onOpen }: {
+ planner: MTripTabPanelProps['planner']
+ shell: MTripTabPanelProps['shell']
+ rt: ReturnType
+ onOpen: (row: StopRow) => void
+}) {
+ const { t } = planner
+ const next = rt.upNext
+ if (!next) return null
+ const late = next.minutesUntil < 0
+ const place = planner.places.find(p => p.id === next.row.stop.placeId)
+ const targets = place ? getNavigationTargets(place) : []
+
+ return (
+
+ onOpen(next.row)} className="w-full text-left">
+
+
+ {t('mobileTrip.upNext')}
+
+ {/* One pill, two readings: a countdown while the stop is still ahead, the
+ delay once its planned time has passed. There is no position source, so
+ "behind plan" is the strongest honest claim available. */}
+
+ {late
+ ? t('mobileTrip.rtBehind', { time: formatDurationShort(-next.minutesUntil * 60) })
+ : t('mobileTrip.inCountdown', { time: formatDurationShort(next.minutesUntil * 60) })}
+
+
+ {next.row.stop.name}
+ {next.row.time && (
+ {next.row.time}
+ )}
+
+
+
+
+ {t('places.navigate')}
+
+
{
+ planner.setSelectedPlaceId(next.row.stop.placeId)
+ if (shell.rtView === 'list') shell.toggleRtView()
+ }}
+ aria-label={t('mobileTrip.showOnMap')}
+ className="flex h-11 w-11 flex-none items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] text-m-ink backdrop-blur-[24px] backdrop-saturate-[1.7]"
+ >
+
+
+
+
+ )
+}
+
+/**
+ * An empty stage is not a failure.
+ *
+ * A day with one place has no drive, because a route needs two. So no summary card
+ * full of zeroes, no skeleton and no button, just the line and the one sentence that
+ * says out loud where the planning happens.
+ */
+function EmptyStage({ planner, loading }: { planner: MTripTabPanelProps['planner']; loading: boolean }) {
+ const { t } = planner
+ return (
+
+
+
+ {loading ? t('roadtrip.summary.partial') : t('roadtrip.empty.title')}
+
+ {!loading && (
+
+ {t('mobileTrip.rtPlanOnDesktop')}
+
+ )}
+
+ )
+}
+
+/* ── small helpers ────────────────────────────────────────────────────────── */
+
+/**
+ * When the day starts moving, which is when you LEAVE the first stop, not when you
+ * arrive at it. The two differ by the stay, and on a morning that begins at a hotel
+ * they differ by the whole night.
+ */
+function firstDeparture(rows: ReturnType['rows']): string | null {
+ for (const row of rows) {
+ if (row.kind !== 'stop') continue
+ return row.departure ?? row.time
+ }
+ return null
+}
+
+function lastArrival(rows: ReturnType['rows']): string | null {
+ for (let i = rows.length - 1; i >= 0; i--) {
+ const row = rows[i]
+ if (row.kind === 'stop' && row.time) return row.time
+ }
+ return null
+}
+
+function lastArrivalOf(stage: { schedule: { entries: { arrival: string | null }[] } }): string | null {
+ for (let i = stage.schedule.entries.length - 1; i >= 0; i--) {
+ const a = stage.schedule.entries[i]?.arrival
+ if (a) return a
+ }
+ return null
+}
+
+function stageDateLabel(planner: MTripTabPanelProps['planner'], dayId: number): string | null {
+ const day = planner.days.find(d => d.id === dayId)
+ if (!day?.date) return null
+ const date = new Date(`${day.date.slice(0, 10)}T00:00:00`)
+ if (Number.isNaN(date.getTime())) return null
+ return new Intl.DateTimeFormat(planner.language, { weekday: 'short', day: 'numeric', month: 'short' }).format(date)
+}
+
+/** The dry point the refuel search measures from, looked up by its leg. */
+function dryPointFor(planner: MTripTabPanelProps['planner'], dayId: number, legIndex: number) {
+ const day = planner.roadtripRoutes.days.find(d => d.dayId === dayId)
+ const point = day?.dryPoints?.find(p => p.legIndex === legIndex)
+ return point!
+}
diff --git a/client/src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx b/client/src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx
new file mode 100644
index 000000000..30dd8a307
--- /dev/null
+++ b/client/src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx
@@ -0,0 +1,88 @@
+import { AlertTriangle, Loader2, Search, WifiOff, X } from 'lucide-react'
+import MIconBtn from '../../../components/MIconBtn'
+import { formatDistance } from '../../../../utils/units'
+import { useSettingsStore } from '../../../../store/settingsStore'
+import type { MRtCorridorController } from './useMRtCorridor'
+import type { TripPlanner } from '../MTripShell'
+
+export interface MRtCorridorBarProps {
+ planner: TripPlanner
+ corridor: MRtCorridorController
+ /** Opens the sheet where the question is asked and the answer is read. */
+ onOpen: () => void
+}
+
+/**
+ * The one control that puts "what is on the way" on a phone screen.
+ *
+ * It sits in the same band as the plan tab's POI bar, on both halves of the road trip
+ * tab, and it never moves: the list and the map are two views of one stage, and a
+ * control that jumps when you switch between them is a control you have to find twice.
+ *
+ * It is a status line as much as a button. A corridor search is several requests over a
+ * mobile connection and can take the better part of half a minute, so the state it is in
+ * has to be readable without opening anything: running, answered, or nothing asked yet.
+ * The answer's own pins are on the map underneath either way.
+ */
+export default function MRtCorridorBar({ planner, corridor, onOpen }: MRtCorridorBarProps) {
+ const { t } = planner
+ const unit = useSettingsStore(s => s.settings.distance_unit)
+
+ const reachLabel = corridor.reach === 'stage'
+ ? t('roadtrip.poi.wholeDay')
+ : t('mobileTrip.rtReachAhead', { distance: formatDistance(corridor.reachKm, unit) })
+
+ let icon =
+ let title = t('roadtrip.poi.title')
+ let meta: string | null = reachLabel
+
+ if (corridor.offline) {
+ icon =
+ title = t('mobileTrip.rtSearchOffline')
+ meta = null
+ } else if (corridor.error) {
+ // Never "0 on the way" after a search that failed: nothing was checked, and a count
+ // of nothing reads as a stretch of road with no petrol station on it.
+ icon =
+ title = t('roadtrip.poi.failed')
+ meta = null
+ } else if (corridor.loading) {
+ icon =
+ title = t('roadtrip.poi.searching', { done: corridor.progress.done, total: corridor.progress.total })
+ meta = null
+ } else if (corridor.answered) {
+ title = t('roadtrip.poi.found', { count: corridor.hits.length })
+ }
+
+ // The trailing round button is a sibling rather than nested: a button inside a button
+ // is markup no browser and no screen reader agrees on. While a search runs it aborts
+ // it; once one has answered it throws the answer away, pins and all.
+ const trailing = corridor.loading || corridor.answered
+
+ return (
+
+
+ {icon}
+ {title}
+ {meta && (
+
+ {meta}
+
+ )}
+
+ {trailing && (
+
+
+
+ )}
+
+ )
+}
diff --git a/client/src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx b/client/src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx
new file mode 100644
index 000000000..154b21e54
--- /dev/null
+++ b/client/src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx
@@ -0,0 +1,268 @@
+import type { ReactNode } from 'react'
+import { AlertTriangle, Loader2, MapPin, Plus, Search, WifiOff, X } from 'lucide-react'
+import MSheet from '../../../components/MSheet'
+import MChip from '../../../components/MChip'
+import MIconBtn from '../../../components/MIconBtn'
+import { Eyebrow } from '../sheets/MTripSheetUi'
+import { useMRtCorridor, type MRtCorridorController } from './useMRtCorridor'
+import { corridorHitLabel } from '../../../../components/Roadtrip/corridorSearchModel'
+import { CORRIDOR_CATEGORIES, CORRIDOR_CATEGORY_BY_KEY } from '../../../../components/Roadtrip/stopKinds'
+import { serviceColor } from '../../../../components/Roadtrip/roadtripModel'
+import { formatDistance } from '../../../../utils/units'
+import { useSettingsStore } from '../../../../store/settingsStore'
+import type { CorridorPoi } from '../../../../components/Roadtrip/useCorridorPois'
+import type { MTripSheetsProps } from '../MTripShell'
+import type { DistanceUnit, TranslationFn } from '../../../../types'
+
+/**
+ * Asking "what is on the way" from the passenger seat.
+ *
+ * The desktop asks this in a 420px column with six controls: the day, the categories,
+ * the corridor width, a section of the drive, a plug type and a minimum power. Two of
+ * those are the question and four are ways of narrowing an answer already on screen,
+ * which is a desk activity. Here the question is two chips and a button, and the
+ * narrowing is gone: a phone shows ten rows, not a hundred, and the way to see fewer of
+ * them is to ask about less road.
+ *
+ * That is the second chip. A search covers one day either way, but a day can be three
+ * hundred kilometres and fifteen requests over a mobile connection, while the fifty
+ * ahead of the driver are three. "Ahead" is where the money is; "the whole stage" is
+ * there because somebody planning tomorrow evening's dinner is not driving right now.
+ */
+/**
+ * Rows a sheet prints before it starts being a scroll rather than an answer.
+ *
+ * A whole-stage search with four categories comes back with a couple of hundred, which
+ * is a list nobody reads on a 375px screen and a couple of hundred inline icons to draw.
+ * The rest are not lost: every one of them is a pin on the map behind this sheet, and
+ * asking about less road is the way to see fewer.
+ */
+const MAX_ROWS = 60
+
+export default function MRtCorridorSheet({ planner, shell }: MTripSheetsProps) {
+ const { t } = planner
+ const corridor = useMRtCorridor(planner, shell)
+ const unit = useSettingsStore(s => s.settings.distance_unit)
+ const open = shell.sheet?.id === 'rtsearch'
+
+ const reachLabel = t('mobileTrip.rtReachAhead', { distance: formatDistance(corridor.reachKm, unit) })
+
+ /**
+ * Taking a hit onto the trip.
+ *
+ * Through the planner's own POI handler, which is the same door the map's pins use:
+ * it works out where in the day's chain the place belongs from how far along the drive
+ * it sits, and hands the stop kind and the stay to the draft sheet. The sheet closes
+ * first so the draft is not opened underneath it.
+ */
+ const add = (poi: CorridorPoi) => {
+ shell.closeSheet()
+ planner.handlePoiClick(poi)
+ }
+
+ /** Brings a hit into view, which on a phone means the map half of this tab. */
+ const show = (poi: CorridorPoi) => {
+ shell.closeSheet()
+ planner.focusRoadtripPoint(poi.lat, poi.lng)
+ if (shell.rtView === 'list') shell.toggleRtView()
+ }
+
+ const canAdd = planner.can('place_edit', planner.trip)
+
+ return (
+
+
+
+
+
+
+
+
{t('roadtrip.poi.title')}
+
+ {corridor.reach === 'stage'
+ ? t('roadtrip.poi.wholeDay')
+ : corridor.anchored
+ ? t('mobileTrip.rtFromNext')
+ : t('mobileTrip.rtFromStart')}
+
+
+
+
+
+
+
+
+
+
{t('roadtrip.poi.looking')}
+
+ {CORRIDOR_CATEGORIES.map(({ key, labelKey, Icon }) => (
+ corridor.toggleCategory(key)}
+ >
+
+ {t(labelKey)}
+
+ ))}
+
+
+
{t('mobileTrip.rtReach')}
+
+ corridor.setReach('ahead')}>
+ {reachLabel}
+
+ corridor.setReach('stage')}>
+ {t('roadtrip.poi.wholeDay')}
+
+
+
+
+
+ {corridor.hits.length > 0 && (
+ <>
+
+ {corridor.hits.slice(0, MAX_ROWS).map((poi, i) => (
+ show(poi)}
+ onAdd={canAdd ? () => add(poi) : undefined}
+ />
+ ))}
+
+ {corridor.hits.length > MAX_ROWS && (
+ // Said out loud rather than silently cut: a list that stops at sixty
+ // without saying so reads as the whole answer. All of them are on the
+ // map underneath, which is the other half of this screen.
+
+ {t('roadtrip.poi.foundFiltered', { count: MAX_ROWS, total: corridor.hits.length })}
+
+ )}
+ >
+ )}
+
+
+
+
+ {corridor.loading
+ ?
+ : }
+ {corridor.loading
+ ? t('roadtrip.poi.searching', { done: corridor.progress.done, total: corridor.progress.total })
+ : t('roadtrip.poi.search')}
+
+
+
+ )
+}
+
+/**
+ * What the search has to say about itself.
+ *
+ * Every one of these lines exists because the alternative is a list that looks complete
+ * and is not: a stretch nobody asked about, a box that did not answer, an answer the
+ * server cut short. On a desk those are footnotes under a hundred rows; on a phone they
+ * are the difference between "there is no petrol station" and "we did not look".
+ */
+function StatusBand({ corridor, t }: { corridor: MRtCorridorController; t: TranslationFn }) {
+ const notes: string[] = []
+ if (corridor.capped) notes.push(t('roadtrip.poi.capped'))
+ if (corridor.failedAreas > 0) notes.push(t('roadtrip.poi.partial', { count: corridor.failedAreas }))
+ // The phone's own wording, because the desk's ends in "narrow the corridor" and the
+ // corridor width is a control this screen deliberately does not have. What it does
+ // have is the category chips, and fewer of them really is what makes a box fit.
+ if (corridor.truncatedAreas > 0) notes.push(t('mobileTrip.rtTruncated', { count: corridor.truncatedAreas }))
+
+ if (corridor.offline) return }>{t('mobileTrip.rtSearchOffline')}
+ if (corridor.error) return }>{t('roadtrip.poi.failed')}
+ if (corridor.categories.length === 0) return {t('roadtrip.poi.empty')}
+
+ const empty = corridor.answered && corridor.hits.length === 0
+ return (
+ <>
+ {empty && (
+
+ {corridor.reach === 'ahead' ? t('mobileTrip.rtNoneAhead') : t('mobileTrip.rtNoneOnStage')}
+
+ )}
+ {notes.map(note => (
+ }>{note}
+ ))}
+ >
+ )
+}
+
+function Note({ tone, icon, children }: { tone: 'warn' | 'calm'; icon?: ReactNode; children: ReactNode }) {
+ return (
+
+ {icon && {icon} }
+ {children}
+
+ )
+}
+
+/**
+ * One hit: what it is, where on the drive it sits, and the one action worth a button.
+ *
+ * Two targets and not three. The row shows it on the map, the plus takes it onto the
+ * trip, and everything the desktop puts beside those (the plug list, the price, the
+ * opening hours) belongs to the stop once it exists, where the stop sheet already
+ * prints it. Adding it twice would be the same duplication the corridor line avoids.
+ */
+function HitRow({ poi, unit, t, first, onShow, onAdd }: {
+ poi: CorridorPoi
+ unit: DistanceUnit
+ t: TranslationFn
+ first: boolean
+ onShow: () => void
+ onAdd?: () => void
+}) {
+ const Icon = CORRIDOR_CATEGORY_BY_KEY[poi.category]?.Icon ?? MapPin
+ const color = serviceColor(poi.category)
+ return (
+
+
+
+
+
+
+ {poi.name}
+
+ {corridorHitLabel(poi, unit, t)}
+
+
+
+ {onAdd && (
+
+
+
+ )}
+
+ )
+}
diff --git a/client/src/mobile/screens/trip/roadtrip/MRtDraftSheet.tsx b/client/src/mobile/screens/trip/roadtrip/MRtDraftSheet.tsx
new file mode 100644
index 000000000..2aad66a9b
--- /dev/null
+++ b/client/src/mobile/screens/trip/roadtrip/MRtDraftSheet.tsx
@@ -0,0 +1,181 @@
+import { useEffect, useRef, useState } from 'react'
+import { AlertTriangle, BedDouble, Hourglass, ParkingSquare } from 'lucide-react'
+import MSheet from '../../../components/MSheet'
+import MChip from '../../../components/MChip'
+import { Eyebrow, FormSheetHeader } from '../sheets/PlSheetChrome'
+import { STOP_KINDS, STOP_KIND_BY_KEY, isOvernightCategory } from '../../../../components/Roadtrip/stopKinds'
+import { formatDurationShort } from '../../../../components/Roadtrip/roadtripModel'
+import type { RoadtripStopDraft } from '../../../../components/Roadtrip/RoadtripStopPopup'
+import type { TripPlanner } from '../MTripShell'
+import type { RoadtripStopType } from '@trek/shared'
+
+export interface MRtDraftSheetProps {
+ planner: TripPlanner
+}
+
+/**
+ * How long to stand still, offered as the few answers anybody actually gives.
+ * The same six the desktop popup offers, so a stop added on the phone and one
+ * added at the desk are the same stop.
+ */
+const DWELL_CHOICES = [5, 10, 20, 30, 45, 60]
+
+/**
+ * Taking a hit found along the drive onto the trip, on the phone.
+ *
+ * Driven by `planner.stopDraft` rather than by `shell.sheet`, because the draft
+ * is what every entry point produces: the fuel search accepting a station
+ * (`acceptRefuel`), a corridor hit dropped on the map, and the chain opening a
+ * service stop it already owns all set that one piece of state and nothing
+ * else. Without a consumer here the fuel button on the phone visibly did
+ * nothing, because the draft was set and no surface read it.
+ *
+ * The write goes through `planner.saveStopDraft`, the same call the desktop
+ * popup makes. It owns the whole sequence a stop needs (the place, the
+ * assignment at the worked-out position, the via re-anchoring, the re-route)
+ * and reports its own failure with a toast, so there is no second path here for
+ * any of it to go wrong in.
+ */
+export default function MRtDraftSheet({ planner }: MRtDraftSheetProps) {
+ const { t, stopDraft, setStopDraft, saveStopDraft, stopDraftDuplicate } = planner
+
+ const [stopType, setStopType] = useState(null)
+ const [dwell, setDwell] = useState(30)
+ const [saving, setSaving] = useState(false)
+
+ // Hold the last draft so the panel keeps its content through the 280ms exit
+ // animation: saving clears the draft immediately, and rendering off the live
+ // value alone would blank the sheet mid-slide.
+ const heldRef = useRef(null)
+ if (stopDraft) heldRef.current = stopDraft
+ const draft = stopDraft ?? heldRef.current
+
+ // Seeded from what the hit brings with it: a charge is not a fuel stop, and
+ // each kind carries how long it usually takes. An edit answers with what the
+ // stop already says instead.
+ useEffect(() => {
+ if (!stopDraft) return
+ const suggested = STOP_KINDS.find(k => k.key === stopDraft.poi.category)
+ setStopType(stopDraft.editing ? stopDraft.editing.stopType : suggested?.key ?? null)
+ setDwell(stopDraft.editing?.dwellMinutes ?? suggested?.defaultMinutes ?? 30)
+ setSaving(false)
+ }, [stopDraft])
+
+ if (!draft) return null
+
+ const kind = STOP_KINDS.find(k => k.key === stopType)
+
+ /**
+ * A place somebody could sleep at is not offered here at all.
+ *
+ * Booking a night is a second write path (the room, the two days it spans,
+ * the check-in) and the phone has no surface for any of it yet. Saving such a
+ * hit as an ordinary pause would look like it worked while quietly losing the
+ * night, which is worse than saying where it can be done.
+ */
+ const overnightOnly = draft.overnight != null || isOvernightCategory(draft.poi.category)
+
+ const discard = () => setStopDraft(null)
+
+ const pickKind = (key: RoadtripStopType) => {
+ if (stopType === key) return
+ setStopType(key)
+ // Picking a kind picks its usual length too, until the user says otherwise.
+ // Tapping the kind already chosen changes nothing, so a time set by hand
+ // survives it.
+ setDwell(STOP_KIND_BY_KEY[key]?.defaultMinutes ?? dwell)
+ }
+
+ const submit = async (): Promise => {
+ if (saving) return
+ setSaving(true)
+ try {
+ await saveStopDraft({ stopType, dwellMinutes: dwell })
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+ {t('roadtrip.stop.landsOn', { day: draft.dayNumber, position: draft.position + 1 })}
+
+
+ {stopDraftDuplicate && (
+ // The full place form warns about duplicates; without the same warning
+ // here this would be the quickest way to add one petrol station twice.
+
+
+
{t('roadtrip.stop.duplicate', { name: stopDraftDuplicate })}
+
+ )}
+
+ {overnightOnly ? (
+
+
+
+ {t('mobileTrip.rtNightDesktopOnly')}
+
+
+ ) : (
+ <>
+
{t('roadtrip.stop.kind')}
+
+ {STOP_KINDS.map(({ key, labelKey, Icon }) => (
+ pickKind(key)}>
+
+ {t(labelKey)}
+
+ ))}
+
+
+
{t('roadtrip.stop.stay')}
+
+ {DWELL_CHOICES.map(minutes => (
+ setDwell(minutes)}
+ className="tabular-nums"
+ >
+
+ {formatDurationShort(minutes * 60)}
+
+ ))}
+
+ >
+ )}
+
+
+
+
+ {t('common.discard')}
+
+ {!overnightOnly && (
+ { void submit() }}
+ disabled={saving}
+ className="ml-auto flex h-11 items-center rounded-full bg-m-act px-[22px] text-[0.8125rem] font-semibold text-m-actfg disabled:opacity-40"
+ >
+ {t(draft.editing ? 'common.save' : 'roadtrip.poi.add')}
+
+ )}
+
+
+ )
+}
diff --git a/client/src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx b/client/src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx
new file mode 100644
index 000000000..00a343e98
--- /dev/null
+++ b/client/src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx
@@ -0,0 +1,237 @@
+import type { ReactNode } from 'react'
+import { Car, Fuel, Zap } from 'lucide-react'
+import MSheet from '../../../components/MSheet'
+import type { MTripSheetsProps } from '../MTripShell'
+import { useTranslation } from '../../../../i18n'
+import { useSettingsStore } from '../../../../store/settingsStore'
+import { useRoadtripSettings } from '../../../../hooks/useRoadtripSettings'
+import { useVehicleRange } from '../../../../components/Roadtrip/useVehicleRange'
+import { blockMask } from '../../../../components/Roadtrip/RangeStrip'
+import { dayWindow } from '../../../../components/Roadtrip/dayWindow'
+import { formatDurationShort, parseAvoid } from '../../../../components/Roadtrip/roadtripModel'
+import { valhallaAvailable } from '../../../../components/Map/valhallaRoute'
+import { convertDistance, formatDistance, getDistanceUnitLabel } from '../../../../utils/units'
+import { Eyebrow, TileHeader } from '../sheets/MTripSheetUi'
+import type { DistanceUnit } from '../../../../types'
+
+/**
+ * The figures the stage plans with, read out loud.
+ *
+ * The chain says a stretch is too long, or that there is nothing to fill up at before the
+ * tank runs dry, and on a phone there was nothing behind those sentences: the numbers they
+ * are measured against live in a desktop dialog with seven panels and two columns. Without
+ * them a warning is an opinion. So two of those panels come along, the two that decide what
+ * the stage is allowed to say, and nothing else does.
+ *
+ * Nothing here can be changed. That is the point rather than a gap: driving limits are set
+ * once, at a table, before anybody is in the car, and a form in the passenger seat is a way
+ * to break a trip while it is happening. So there is no field, no slider and no greyed-out
+ * button that looks like one, and the last line says plainly where the settings are and what
+ * else that screen can do that this one cannot.
+ */
+
+/**
+ * How many kilometres one block of the bar stands for.
+ *
+ * The same fifty as `RangeStrip`, whose `blockMask` cuts the bar here too. The mask comes
+ * from that module so the two pictures cannot drift apart; the figure the note quotes is
+ * the one thing it does not hand out, so it is stated once here.
+ */
+const BLOCK_KM = 50
+
+/** One value of the driving limits, with the row rule above every row but the first. */
+function LimitLine({ label, value, fallback, first }: {
+ label: string
+ value: string | null
+ fallback: string
+ first: boolean
+}) {
+ return (
+
+ {label}
+
+ {value ?? fallback}
+
+
+ )
+}
+
+/** One card of the sheet: eyebrow plus whatever the section is made of. */
+function InfoCard({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+ {label}
+ {children}
+
+ )
+}
+
+/**
+ * Driving figures sheet ('rtinfo'): the vehicle's range as the tank it leaves the factory
+ * with, and the limits every warning on the stage is measured against. Read-only.
+ */
+export default function MRtInfoSheet({ planner, shell }: MTripSheetsProps) {
+ const { t } = useTranslation()
+ const open = shell.sheet?.id === 'rtinfo'
+ const tripId = planner.tripId
+
+ const unitSetting = useSettingsStore(s => s.settings.distance_unit)
+ const unit: DistanceUnit = unitSetting === 'imperial' ? 'imperial' : 'metric'
+
+ // The same range the stage plans with: the vehicle's own figures when they add up,
+ // the typed one otherwise. Reading it through the shared hook is what keeps the sheet
+ // from becoming a second opinion about the tank.
+ const { vehicleKind, rangeKm } = useVehicleRange(tripId)
+ const legMinutes = useRoadtripSettings(s => s.roadtrip_leg_minutes, tripId)
+ const dayMinutes = useRoadtripSettings(s => s.roadtrip_day_minutes, tripId)
+ const dayStart = useRoadtripSettings(s => s.roadtrip_day_start, tripId)
+ const dayEnd = useRoadtripSettings(s => s.roadtrip_day_end, tripId)
+ const fillPercent = useRoadtripSettings(s => s.roadtrip_fill_percent, tripId)
+ const degradation = useRoadtripSettings(s => s.roadtrip_battery_degradation, tripId)
+ const avoidRaw = useRoadtripSettings(s => s.roadtrip_avoid, tripId)
+
+ const electric = vehicleKind === 'electric'
+ const VehicleIcon = electric ? Zap : vehicleKind === 'combustion' ? Fuel : Car
+
+ // Capped where the arithmetic caps it, so no picture can claim a battery is gone. The
+ // range above the bar already has this taken off it, which is exactly why it gets said.
+ const wear = electric ? Math.min(90, Math.max(0, degradation ?? 0)) : 0
+ const fill = fillPercent && fillPercent > 0 && fillPercent < 100 ? fillPercent : 100
+
+ // Split rather than formatted whole, so the unit can sit smaller beside the figure. The
+ // rounding is formatDistance's own, because the stage header uses that and the two must
+ // not disagree by a tenth.
+ const shown = rangeKm ? Math.round(convertDistance(rangeKm, unit) * 10) / 10 : null
+ const mask = blockMask(rangeKm)
+
+ const notes = [
+ shown === null ? t('roadtrip.limit.rangeEmptyHint') : null,
+ shown !== null && fill < 100
+ ? t('roadtrip.limit.afterFill', { percent: fill, distance: formatDistance((rangeKm ?? 0) * fill / 100, unit) })
+ : null,
+ mask ? t('roadtrip.limit.blockNote', { distance: formatDistance(BLOCK_KM, unit) }) : null,
+ wear > 0 ? t('roadtrip.limit.wearNote', { percent: wear }) : null,
+ ].filter((note): note is string => note !== null)
+
+ const unitLabel = getDistanceUnitLabel(unit)
+ const perLabel = t(electric ? 'roadtrip.limit.perCharge' : 'roadtrip.limit.perFill')
+ const barLabel = shown === null
+ ? t('roadtrip.limit.rangeEmpty')
+ : `${shown} ${unitLabel} ${perLabel}`
+
+ // Both times on one line, in the separator the desktop badge already uses. A dash
+ // between two clock readings is the one place a phone line breaks in the wrong spot.
+ const windowText = dayWindow(dayStart, dayEnd) ? `${dayStart} · ${dayEnd}` : null
+
+ // No second routing engine, no avoidance: the stored classes would be a promise the
+ // router cannot keep, so the row says the same "off" it would say with none set.
+ const avoiding = parseAvoid(avoidRaw)
+ const avoidText = valhallaAvailable() && avoiding.length > 0
+ ? t('roadtrip.avoid.badge', { count: avoiding.length })
+ : null
+
+ const off = t('roadtrip.limit.off')
+ const lines: { key: string; label: string; value: string | null; fallback: string }[] = [
+ {
+ key: 'leg',
+ label: t('roadtrip.limit.legLabel'),
+ value: legMinutes ? formatDurationShort(legMinutes * 60) : null,
+ fallback: off,
+ },
+ {
+ key: 'day',
+ label: t('roadtrip.limit.dayLabel'),
+ value: dayMinutes ? formatDurationShort(dayMinutes * 60) : null,
+ fallback: off,
+ },
+ { key: 'window', label: t('roadtrip.window.title'), value: windowText, fallback: off },
+ {
+ key: 'fill',
+ label: t('roadtrip.limit.fillLabel'),
+ value: fill < 100 ? `${fill} %` : null,
+ // "off" is the wrong word for a tank: not saying how far you fill it means you fill
+ // it right up, which is what the arithmetic then does.
+ fallback: t('roadtrip.limit.fillFull'),
+ },
+ { key: 'avoid', label: t('roadtrip.avoid.section'), value: avoidText, fallback: off },
+ ]
+
+ return (
+
+
+ }
+ title={{t('mobileTrip.rtInfoTitle')} }
+ onClose={shell.closeSheet}
+ closeLabel={t('common.close')}
+ />
+
+
+
+ {/* ── Vehicle: how far it goes, drawn as the tank it leaves the factory with ── */}
+
+
+
+
+
+ {shown ?? '-'}
+
+ {shown !== null && (
+ {unitLabel}
+ )}
+
+
+ {shown === null ? t('roadtrip.limit.rangeEmpty') : perLabel}
+
+
+
+
+ {/* Cut as a mask rather than painted over, so the gaps are real holes and
+ the glass behind the sheet shows through them. */}
+
+
+
+ {notes.length > 0 && (
+
+ {notes.join(' · ')}
+
+ )}
+
+
+
+ {/* ── Limits: what every warning on the stage is measured against ── */}
+
+
+
+ {lines.map((line, i) => (
+
+ ))}
+
+
+
+
+
+ {t('mobileTrip.rtDesktopNote')}
+
+
+
+ )
+}
diff --git a/client/src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx b/client/src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx
new file mode 100644
index 000000000..8485ce6f5
--- /dev/null
+++ b/client/src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx
@@ -0,0 +1,285 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { ArrowRight, Minus, Plus } from 'lucide-react'
+import MSheet from '../../../components/MSheet'
+import MChip from '../../../components/MChip'
+import type { MTripSheetsProps } from '../MTripShell'
+import { useTranslation } from '../../../../i18n'
+import { useSettingsStore } from '../../../../store/settingsStore'
+import { formatClock, formatDurationShort, parseClock } from '../../../../components/Roadtrip/roadtripModel'
+import { stageOf } from '../../../../components/Roadtrip/roadtripRowModel'
+import { formatClockTime } from '../../../../utils/formatters'
+import { FormSheetHeader } from '../sheets/PlSheetChrome'
+import { INNER_CLS } from '../sheets/MTripSheetUi'
+
+/** What the chain hands over when a stop's stay is tapped. */
+interface RtStaySheetPayload {
+ placeId?: number
+ /** The stay the place carries right now; absent and zero mean the same thing. */
+ minutes?: number | null
+ name?: string
+}
+
+/** The step the two buttons move in. Same five minutes as the desktop dialog. */
+const STEP = 5
+
+const DAY_MINUTES = 24 * 60
+
+/**
+ * As far as a stay goes, in minutes: a full day, like the desktop slider.
+ *
+ * Kept even though there is no slider here: the plus button has to stop somewhere, and
+ * a stay longer than a day is a second day rather than a longer stop.
+ */
+const MAX = DAY_MINUTES
+
+/**
+ * The lengths a stop usually takes, so the common answer is one tap.
+ *
+ * The same eight the desktop offers. They are the whole of the coarse control here: the
+ * desktop's slider runs 0 to 1440, which on a 343px phone row is four minutes a pixel,
+ * and a control that cannot hit the value it is dragged to is worse than no control.
+ */
+const PRESETS = [15, 30, 45, 60, 90, 120, 480, 720]
+
+const clampMinutes = (value: number): number => Math.min(MAX, Math.max(0, Math.round(value)))
+
+/**
+ * How long the traveller stays at one stop, on the phone ('rtstay', payload
+ * { placeId, minutes, name }).
+ *
+ * Writes `places.duration_minutes` through `planner.setRoadtripStay`, which is the same
+ * single write the desktop dialog makes. Zero rather than null clears it: the update
+ * statement folds a null into "leave it alone", so a null could give a stop a stay but
+ * never take one away.
+ *
+ * Two things this sheet deliberately does not do:
+ *
+ * 1. The two buttons do not auto-repeat on hold. Five-minute steps under a repeat land
+ * forty minutes past the intended value about as often as they land on it, and the
+ * eight presets already cover every length anybody holds a button for.
+ * 2. The preview moves this stop's own departure and nothing else. Every later arrival
+ * shifts too, but that is the routing round's answer after the write, and computing
+ * second time here would be two sources for one number, and they would disagree the
+ * first time a rule changed.
+ */
+export default function MRtStaySheet({ planner, shell }: MTripSheetsProps) {
+ const { t } = useTranslation()
+ const open = shell.sheet?.id === 'rtstay'
+ const payload = (shell.sheet?.payload ?? {}) as RtStaySheetPayload
+ const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
+ const canEdit = planner.can('place_edit', planner.trip)
+
+ // Hold the last payload so the name and the number survive the exit animation
+ // instead of blinking out while the sheet slides away.
+ const heldRef = useRef(null)
+ if (open && typeof payload.placeId === 'number') heldRef.current = payload
+ const stop = open && typeof payload.placeId === 'number' ? payload : heldRef.current
+
+ /** What the place carries now: the value the draft starts from and falls back to. */
+ const stored = clampMinutes(stop?.minutes ?? 0)
+
+ const [minutes, setMinutes] = useState(stored)
+ const [saving, setSaving] = useState(false)
+
+ // Reopened on a different stop, so it starts from that stop's own value rather than
+ // from whatever the last one was left on.
+ useEffect(() => {
+ if (!open) return
+ setMinutes(clampMinutes(payload.minutes ?? 0))
+ setSaving(false)
+ }, [open, payload.placeId, payload.minutes])
+
+ /**
+ * When the drive gets here.
+ *
+ * Read off the stage (the one day both halves of the road trip tab show) rather than
+ * passed in, so the arrival on screen is the arrival the chain drew, from the same
+ * routing round, for the same day. A place planned on several days has an arrival per
+ * day, and the stage is the day the tap came from.
+ */
+ const stage = stageOf(planner.roadtripRoutes.days, planner.selectedDayId)
+ const stopPlaceId = stop?.placeId
+ const arrival = useMemo(() => {
+ if (!stage || stopPlaceId == null) return null
+ const index = stage.stops.findIndex(s => s.placeId === stopPlaceId)
+ if (index === -1) return null
+ return stage.schedule.entries[index]?.arrival ?? null
+ }, [stage, stopPlaceId])
+
+ // What the stay does to this stop: the arrival is fixed by the drive, the departure is
+ // the one end this sheet moves.
+ const preview = useMemo(() => {
+ const at = parseClock(arrival)
+ if (at === null) return null
+ return {
+ arrive: formatClockTime(formatClock(at), is12h),
+ leave: formatClockTime(formatClock(at + minutes), is12h),
+ // `formatClock` wraps modulo 24 h, so a stay running past midnight reads as a small
+ // number again. Without the carry the sheet would quietly promise "leave 01:00" for
+ // a departure the chain places on the next day.
+ carry: Math.floor((at + minutes) / DAY_MINUTES) - Math.floor(at / DAY_MINUTES),
+ }
+ }, [arrival, minutes, is12h])
+
+ const nudge = (delta: number) => setMinutes(m => clampMinutes(m + delta))
+
+ /**
+ * Writes the draft, then hands the traveller back to the stop they came from.
+ *
+ * Reopening 'rtstop' is the point of the save: the times that just moved are shown
+ * there, in the chain's own reading, so the change lands where it was asked for.
+ * Closing without saving deliberately does not reopen it: nothing moved.
+ *
+ * `setRoadtripStay` reports its own failures and resolves either way, so the catch is
+ * the second line rather than the first: what it guards is the sheet being left showing
+ * a number the place does not have.
+ */
+ const save = async () => {
+ const placeId = stop?.placeId
+ if (!canEdit || saving || placeId == null) return
+ const value = minutes
+ setSaving(true)
+ try {
+ await planner.setRoadtripStay(placeId, value)
+ shell.openSheet('rtstop', { ...stop, placeId, minutes: value })
+ } catch {
+ setMinutes(stored)
+ planner.toast.error(t('common.unknownError'))
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const stepCls =
+ 'flex h-11 w-11 flex-none items-center justify-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-ink disabled:opacity-35'
+ const captionCls = 'font-geist text-[0.5625rem] font-bold uppercase tracking-[.09em] text-m-faint'
+
+ return (
+
+ {stop && (
+ <>
+
+
+
+ {/* Which stop, and what the number actually attaches to. The stay is a column
+ on the place, not on the day's assignment, so a place planned twice keeps
+ one stay for both, and saying so here is cheaper than the surprise. */}
+ {stop.name && (
+
{stop.name}
+ )}
+
+ {t('mobileTrip.rtStayScope')}
+
+
+ {/* The number, the way a timer shows one, with minus and plus either side so a
+ value can be nudged without a control to aim at. */}
+
+
nudge(-STEP)}
+ disabled={!canEdit || saving || minutes <= 0}
+ aria-label={t('mobileTrip.rtStayLess', { count: STEP })}
+ className={stepCls}
+ >
+
+
+
+
+ {minutes}
+
+
+ {t('roadtrip.stay.custom')}
+
+
+
nudge(STEP)}
+ disabled={!canEdit || saving || minutes >= MAX}
+ aria-label={t('mobileTrip.rtStayMore', { count: STEP })}
+ className={stepCls}
+ >
+
+
+
+
+ {/* The eight usual answers. Read as durations rather than as raw minutes: a
+ tile saying 480 is a number to work out, one saying 8 h is an answer. */}
+ {canEdit && (
+
+ {PRESETS.map(value => (
+ setMinutes(value)}
+ className="h-11 w-full justify-center tabular-nums"
+ >
+ {formatDurationShort(value * 60)}
+
+ ))}
+
+ )}
+
+ {/* Arrival, arrow, new departure. Only this stop's own two ends. */}
+ {preview && (
+
+
+ {t('roadtrip.stay.arrive')}
+
+ {preview.arrive}
+
+
+
+
+ {t('roadtrip.stay.leave')}
+
+ {preview.leave}
+ {preview.carry > 0 && (
+
+ {`+${preview.carry}`}
+ {` ${t('roadtrip.warn.overnight')}`}
+
+ )}
+
+
+
+ )}
+
+
+
+ {/* Clearing is the same write as setting one, so it only fills the number in;
+ the save below is still the one way anything is written. */}
+ setMinutes(0)}
+ disabled={!canEdit || saving || minutes === 0}
+ className="inline-flex h-11 items-center rounded-full px-3 text-[0.8125rem] font-semibold text-[color:var(--m-st-danger)] disabled:opacity-40"
+ >
+ {t('roadtrip.stay.none')}
+
+ { void save() }}
+ disabled={!canEdit || saving}
+ className="ml-auto inline-flex h-11 items-center rounded-full bg-m-act px-[18px] text-[0.8125rem] font-semibold text-m-actfg disabled:opacity-40"
+ >
+ {t('common.save')}
+
+
+ >
+ )}
+
+ )
+}
diff --git a/client/src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx b/client/src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx
new file mode 100644
index 000000000..d25211a33
--- /dev/null
+++ b/client/src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx
@@ -0,0 +1,395 @@
+import { useEffect, useMemo, useRef, useState, type ReactNode, type Ref } from 'react'
+import {
+ AlertTriangle, Clock, Footprints, Fuel, Hourglass, MapPin, Moon, Navigation, X,
+} from 'lucide-react'
+import MSheet from '../../../components/MSheet'
+import MIconBtn from '../../../components/MIconBtn'
+import MListRow from '../../../components/MListRow'
+import MToggle from '../../../components/MToggle'
+import type { MTripSheetsProps } from '../MTripShell'
+import { useTranslation } from '../../../../i18n'
+import { useSettingsStore } from '../../../../store/settingsStore'
+import ChargingInfo from '../../../../components/Roadtrip/ChargingInfo'
+import { formatDurationShort } from '../../../../components/Roadtrip/roadtripModel'
+import { roadtripRows, stageOf, type StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
+import { STOP_KIND_BY_KEY } from '../../../../components/Roadtrip/stopKinds'
+import { getNavigationTargets, openNavigationTarget } from '../../../../components/Planner/placeNavigation'
+import { NavigationMenu } from '../../../../components/shared/NavigationMenu'
+import { formatDistance } from '../../../../utils/units'
+import { Eyebrow, displayTime } from '../sheets/MTripSheetUi'
+import type { DistanceUnit } from '../../../../types'
+import type { RoadtripDay, ScheduleWarning } from '@trek/shared/roadtrip'
+
+/**
+ * One stop of the stage, written out.
+ *
+ * The chain gives a stop a disc, a name, a clock and at most two marks, because that is
+ * what a 375px line carries while somebody is driving. Everything the marks stand for is
+ * here instead, as sentences: why the clock reads what it reads, how long the stay is,
+ * how far the road stops short, and which limit the drive to here went past.
+ *
+ * Read-mostly on purpose. Deleting a stop, reordering the day, changing what kind of stop
+ * it is and how full the tank leaves it are desktop work, done at a table before anybody
+ * is in the car, and they are not here at all: not greyed out, not hidden behind a
+ * confirmation, simply absent, because a dead control on a phone is a promise the screen
+ * cannot keep. What is left are the three things a passenger does decide on the road:
+ * navigate there, stay longer, stop for the night here.
+ */
+
+interface RtStopSheetPayload {
+ dayId?: number
+ assignmentId?: number
+}
+
+/** The stop the sheet is about, and the card it is drawn on. */
+interface Located {
+ day: RoadtripDay
+ row: StopRow
+ /** Index into the CARD's stops, which is what every finding is filed under. */
+ index: number
+}
+
+/**
+ * Finds the stop across the routed days.
+ *
+ * The caller names the day a stop is stored on (`ownerDayId`), which after a night drive
+ * is not the card that draws it: a stop set off from on Tuesday and reached on Wednesday
+ * appears in Wednesday's `stops`. So the named day is tried first and the rest after it,
+ * rather than trusting one lookup that is right most of the time.
+ */
+function locate(days: readonly RoadtripDay[], dayId: number | null, assignmentId: number | null): Located | null {
+ if (assignmentId == null) return null
+ const named = stageOf(days, dayId)
+ const search = named ? [named, ...days.filter(d => d !== named)] : days
+ for (const day of search) {
+ const index = day.stops.findIndex(s => s.assignmentId === assignmentId && !s.automaticNight)
+ if (index < 0) continue
+ const row = roadtripRows(day).find(
+ (r): r is StopRow => r.kind === 'stop' && r.stop.assignmentId === assignmentId,
+ )
+ if (row) return { day, row, index }
+ }
+ return null
+}
+
+/** One finding: an icon, a sentence, and the figure it is about when that reads better apart. */
+function Finding({ icon, label, value, warn = false }: {
+ icon: ReactNode
+ label: string
+ value?: string
+ warn?: boolean
+}) {
+ return (
+
+ {icon}
+
+ {label}
+
+ {value && (
+
+ {value}
+
+ )}
+
+ )
+}
+
+/** One 56px action tile. Without a handler it stays a plain box instead of a dead button. */
+function ActionTile({ icon, label, value, onClick, tileRef }: {
+ icon: ReactNode
+ label: string
+ value?: string
+ onClick?: () => void
+ tileRef?: Ref
+}) {
+ const inner = (
+ <>
+ {icon}
+
+ {label}
+ {value && (
+ {value}
+ )}
+
+ >
+ )
+ const box = 'flex h-14 min-w-0 items-center gap-2.5 rounded-[14px] bg-[color:var(--m-ic)] px-3 text-left'
+ if (!onClick) return {inner}
+ return {inner}
+}
+
+/**
+ * Stop detail sheet ('rtstop', payload { dayId, assignmentId }) of the road trip tab.
+ */
+export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) {
+ const { t, locale } = useTranslation()
+ const open = shell.sheet?.id === 'rtstop'
+ const payload = (shell.sheet?.payload ?? {}) as RtStopSheetPayload
+
+ const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
+ const unitSetting = useSettingsStore(s => s.settings.distance_unit)
+ const unit: DistanceUnit = unitSetting === 'imperial' ? 'imperial' : 'metric'
+
+ const days = planner.roadtripRoutes.days
+ const live = useMemo(
+ () => locate(days, payload.dayId ?? null, payload.assignmentId ?? null),
+ [days, payload.dayId, payload.assignmentId],
+ )
+
+ // Hold the last stop so the card keeps its content through the 280ms exit.
+ const heldRef = useRef(null)
+ if (live) heldRef.current = live
+ const located = live ?? heldRef.current
+
+ const [navOpen, setNavOpen] = useState(false)
+ const navBtnRef = useRef(null)
+ const [saving, setSaving] = useState(false)
+ // The end-day switch, as it should read while the write is in the air.
+ const [pending, setPending] = useState<{ from: boolean; to: boolean } | null>(null)
+ useEffect(() => {
+ if (!open) { setNavOpen(false); setPending(null) }
+ }, [open])
+ useEffect(() => { setPending(null) }, [payload.assignmentId])
+
+ const stop = located?.row.stop ?? null
+ const place = planner.places.find(p => p.id === stop?.placeId) ?? null
+
+ /**
+ * Addressed by stop, not by selection.
+ *
+ * `planner.roadtripEndDay` derives itself from the place selection, which is what the
+ * desktop inspector needs. Here that would mean moving the selection to open a sheet,
+ * and the place inspector hangs off the same value and would come up underneath. So
+ * the stage sheet calls the stop-addressed writer directly.
+ */
+ const canEndDay = stop != null && planner.dailyTimesActive && planner.can('day_edit', planner.trip)
+ const endDayTruth = !!stop?.endDay
+ // The optimistic value is released the moment the planner's own answer moves off what
+ // it was taken from, so a landed write shows the real state rather than a copy of it.
+ const endDayActive = pending && pending.from === endDayTruth ? pending.to : endDayTruth
+
+ if (!located || !stop) {
+ return
+ }
+
+ const { day, row, index } = located
+ const kind = stop.stopType ? STOP_KIND_BY_KEY[stop.stopType] : undefined
+ const KindIcon = kind?.Icon
+
+ const entry = row.entry
+ const arrive = displayTime(entry?.arrival, locale, timeFormat)
+ const leave = displayTime(entry?.departure, locale, timeFormat)
+
+ // Every finding filed at this stop, not just the one the chain had room for: being late
+ // and driving too far are separate answers and the sheet is where both fit.
+ const warnings: ScheduleWarning[] = [
+ ...day.schedule.warnings.filter(w => w.index === index && w.code === 'late'),
+ ...day.driveWarnings.filter(w => w.index === index && (w.code === 'leg' || w.code === 'range')),
+ ]
+ const warningText = (w: ScheduleWarning): string => {
+ if (w.code === 'late') return t('roadtrip.warn.late', { minutes: w.minutes ?? 0 })
+ if (w.code === 'range') return t('roadtrip.limit.range', { distance: formatDistance(w.sinceKm ?? 0, unit) })
+ return t('roadtrip.limit.legOver', { time: formatDurationShort((w.overMinutes ?? 0) * 60) })
+ }
+ const warningIcon = (w: ScheduleWarning): ReactNode => {
+ if (w.code === 'late') return
+ if (w.code === 'range') return
+ return
+ }
+
+ // One app offered means no picker: the tap opens it, exactly as the place sheet does.
+ // The stop's own name and position stand in when the place row is not to hand, so the
+ // one control a driver actually needs is there whatever else the device is missing.
+ const navTargets = getNavigationTargets(place ?? { name: stop.name, lat: stop.lat, lng: stop.lng })
+ const openDirections = () => {
+ if (navTargets.length === 1) openNavigationTarget(navTargets[0])
+ else if (navTargets.length > 1) setNavOpen(true)
+ }
+
+ const stayMinutes = stop.dwellMinutes
+ const canEditStay = planner.can('place_edit', planner.trip)
+ const editStay = () => {
+ shell.openSheet('rtstay', { placeId: stop.placeId, minutes: stayMinutes })
+ }
+
+ const toggleEndDay = async (next: boolean) => {
+ if (!canEndDay || saving) return
+ setPending({ from: endDayTruth, to: next })
+ setSaving(true)
+ try {
+ await planner.setRoadtripEndDay(stop)
+ } catch (err: unknown) {
+ setPending(null)
+ planner.toast.error(err instanceof Error ? err.message : t('common.unknownError'))
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const showOnMap = () => {
+ if (shell.rtView === 'list') shell.toggleRtView()
+ planner.setSelectedPlaceId(stop.placeId)
+ shell.closeSheet()
+ }
+
+ const tiles = (navTargets.length > 0 ? 1 : 0) + 1
+
+ return (
+
+ {/* ── Header: the disc the chain draws, the name, the way out ── */}
+
+
+ {row.service ? (
+ kind && KindIcon ? (
+
+
+
+ ) : (
+
+
+
+ )
+ ) : (
+ // Deliberately not font-geist: that tier caps the weight at Medium and would
+ // quietly undo the bold on a number that has to read at 13px.
+
+ {row.number}
+
+ )}
+
+ {stop.name}
+
+
+
+
+
+
+
+
+ {/* ── The two clock readings, and where they come from ── */}
+ {(arrive || leave) && (
+
+
+ {arrive && (
+
+
{t('roadtrip.stay.arrive')}
+ {/* dir=ltr so a clock reads the same way round in an RTL locale. */}
+
+ {arrive}
+
+
+ )}
+ {leave && (
+
+
{t('roadtrip.stay.leave')}
+
+ {leave}
+
+
+ )}
+
+ {/* The desktop says this in a tooltip, which a phone has nowhere to put. */}
+
+ {entry?.anchored ? t('roadtrip.stop.pinned') : t('roadtrip.stop.computed')}
+
+
+ )}
+
+ {/* ── What this stop costs, and what the drive to it went past ── */}
+ {(!!stayMinutes || !!row.offRoadMeters || warnings.length > 0) && (
+
+ {!!stayMinutes && (
+ }
+ label={t('roadtrip.stop.stayShort')}
+ value={formatDurationShort(stayMinutes * 60)}
+ />
+ )}
+ {!!row.offRoadMeters && (
+ }
+ label={t('roadtrip.stop.offRoad', { distance: formatDistance(row.offRoadMeters / 1000, unit) })}
+ />
+ )}
+ {warnings.map(w => (
+
+ ))}
+
+ )}
+
+ {/* ── Charging: availability and price, polled only while the sheet is up ── */}
+ {open && stop.stopType === 'charging' && (
+
+
+
+ )}
+
+ {/* ── The two decisions a passenger makes on the road ── */}
+
1 ? 'grid-cols-2' : 'grid-cols-1'}`}>
+ {navTargets.length > 0 && (
+
}
+ label={t('inspector.navigation')}
+ value={navTargets.length === 1 ? navTargets[0].label : undefined}
+ onClick={openDirections}
+ />
+ )}
+
}
+ label={t('roadtrip.stop.stayShort')}
+ value={stayMinutes ? formatDurationShort(stayMinutes * 60) : t('roadtrip.stay.none')}
+ onClick={canEditStay ? editStay : undefined}
+ />
+
+ {navOpen && (
+
setNavOpen(false)}
+ title={t('inspector.openWith')}
+ />
+ )}
+
+ {/* ── Stopping here for the night. Offline too: it is a plan, not a lookup. ── */}
+ {canEndDay && (
+
+
+
+ {t('roadtrip.window.endHere')}
+
+ { void toggleEndDay(next) }}
+ ariaLabel={t('roadtrip.window.endHere')}
+ disabled={saving}
+ />
+
+ )}
+
+ {/* ── Out to the map, with the stop marked on it ── */}
+
+
+
+
+
+ )
+}
diff --git a/client/src/mobile/screens/trip/roadtrip/useMRoadtrip.ts b/client/src/mobile/screens/trip/roadtrip/useMRoadtrip.ts
new file mode 100644
index 000000000..f9b16d356
--- /dev/null
+++ b/client/src/mobile/screens/trip/roadtrip/useMRoadtrip.ts
@@ -0,0 +1,88 @@
+import { useEffect, useMemo, useState } from 'react'
+import { destinationCount, roadtripRows, stageOf, upNextStop } from '../../../../components/Roadtrip/roadtripRowModel'
+import { useRoadtripSettings } from '../../../../hooks/useRoadtripSettings'
+import { useSettingsStore } from '../../../../store/settingsStore'
+import { isEffectivelyOffline, onNetworkModeChange } from '../../../../sync/networkMode'
+import type { RoadtripDay } from '@trek/shared/roadtrip'
+import type { TripPlanner } from '../MTripShell'
+import type { RoadtripRow } from '../../../../components/Roadtrip/roadtripRowModel'
+
+/** Minutes since midnight, local time. */
+const nowMinutes = (): number => {
+ const d = new Date()
+ return d.getHours() * 60 + d.getMinutes()
+}
+
+/** The same 30 s tick the day timeline runs on, so the two never disagree by a minute. */
+const TICK_MS = 30_000
+
+export interface MRoadtripController {
+ stage: RoadtripDay | null
+ rows: RoadtripRow[]
+ stops: number
+ /** True while the routing round is still working through the trip's days. */
+ loading: boolean
+ /** The trip has the addon on but nothing routable: one place on a day is not a drive. */
+ empty: boolean
+ offline: boolean
+ electric: boolean
+ /** Next destination the plan still owes, today only. Negative minutesUntil is late. */
+ upNext: ReturnType
+ isToday: boolean
+}
+
+/**
+ * Everything the stage screen needs, kept out of its markup.
+ *
+ * The clock is the only moving part: a screen that shows a morning's plan all day
+ * quietly lies once you leave twenty minutes late, and the fix is arithmetic on the
+ * schedule rather than a request. Nothing here asks the network for anything.
+ */
+export function useMRoadtrip(planner: TripPlanner): MRoadtripController {
+ const { roadtripRoutes, selectedDayId, days } = planner
+ const vehicle = useRoadtripSettings(s => s.roadtrip_vehicle, planner.tripId)
+ const [minutes, setMinutes] = useState(nowMinutes)
+ const [offline, setOffline] = useState(isEffectivelyOffline)
+
+ useEffect(() => {
+ const id = window.setInterval(() => setMinutes(nowMinutes()), TICK_MS)
+ return () => window.clearInterval(id)
+ }, [])
+
+ useEffect(() => onNetworkModeChange(() => setOffline(isEffectivelyOffline())), [])
+
+ const stage = useMemo(
+ () => stageOf(roadtripRoutes.days, selectedDayId),
+ [roadtripRoutes.days, selectedDayId],
+ )
+
+ const rows = useMemo(() => (stage ? roadtripRows(stage) : []), [stage])
+
+ // "Today" is the stage's own date, not the selected day's index: a trip can be
+ // planned for next year, and a countdown on a day in March is noise.
+ const isToday = useMemo(() => {
+ const date = days.find(d => d.id === selectedDayId)?.date
+ if (!date) return false
+ return date.slice(0, 10) === new Date().toISOString().slice(0, 10)
+ }, [days, selectedDayId])
+
+ return {
+ stage,
+ rows,
+ stops: stage ? destinationCount(stage) : 0,
+ loading: roadtripRoutes.loading,
+ empty: !roadtripRoutes.loading && roadtripRoutes.days.length === 0,
+ offline,
+ electric: vehicle === 'electric',
+ upNext: upNextStop(stage, minutes, isToday),
+ isToday,
+ }
+}
+
+/** The trip's own distance unit, for every figure the stage prints. */
+export function useDistanceUnit() {
+ return useSettingsStore(s => s.settings.distance_unit)
+}
+
+/** Kept for the tick's identity in tests. */
+export const ROADTRIP_TICK_MS = TICK_MS
diff --git a/client/src/mobile/screens/trip/roadtrip/useMRtCorridor.ts b/client/src/mobile/screens/trip/roadtrip/useMRtCorridor.ts
new file mode 100644
index 000000000..da731eccb
--- /dev/null
+++ b/client/src/mobile/screens/trip/roadtrip/useMRtCorridor.ts
@@ -0,0 +1,146 @@
+import { useCallback, useEffect, useMemo } from 'react'
+import { anchorKmFor, corridorWindow, PHONE_REACH_KM, type CorridorReach } from '../../../../components/Roadtrip/corridorSearchModel'
+import { stageOf, upNextStop } from '../../../../components/Roadtrip/roadtripRowModel'
+import { useNetworkMode } from '../../../../hooks/useNetworkMode'
+import type { CorridorPoi } from '../../../../components/Roadtrip/useCorridorPois'
+import type { MTripShellApi, TripPlanner } from '../MTripShell'
+
+/** Minutes since midnight, local time: the same reading the stage screen takes. */
+const nowMinutes = (): number => {
+ const d = new Date()
+ return d.getHours() * 60 + d.getMinutes()
+}
+
+export interface MRtCorridorController {
+ /** What is being looked for. Several at once: they ride one query per box. */
+ categories: string[]
+ toggleCategory: (key: string) => void
+ /** How much of the stage the next search covers. */
+ reach: CorridorReach
+ setReach: (value: CorridorReach) => void
+ /** How far "ahead" reaches, in kilometres of driving. */
+ reachKm: number
+ /**
+ * True when "ahead" would start at the stop the clock says is next, false when it
+ * starts at the beginning of the stage. The sheet says which, because that difference
+ * is the whole meaning of the answer.
+ */
+ anchored: boolean
+ /** Asks. Does nothing without a route, a category and a connection. */
+ run: () => void
+ /** Throws the answer away, pins included. */
+ clear: () => void
+ canSearch: boolean
+ loading: boolean
+ progress: { done: number; total: number }
+ /** What was found, in driving order, and the same array the map draws. */
+ hits: CorridorPoi[]
+ /** True once a search has run and left something to read. */
+ answered: boolean
+ offline: boolean
+ error: boolean
+ capped: boolean
+ failedAreas: number
+ truncatedAreas: number
+}
+
+/**
+ * The corridor search, as a phone asks it.
+ *
+ * The state itself is the planner's: the map draws `corridor.visible`, so a search
+ * living inside a sheet would lose its pins the moment the sheet closed. What is added
+ * here is the two things a desk does not need: which stretch of the day to ask about,
+ * and where that stretch starts.
+ *
+ * Called from two places (the bar over the stage, and the sheet), so it holds no state
+ * of its own: the reach lives on the shell and everything else on the planner. The one
+ * thing that would need a clock (where "ahead" begins) is read at the moment the
+ * button is pressed rather than ticked, so this hook costs a screen nothing while
+ * nobody is searching.
+ *
+ * The stage on screen decides the day. Switching stages therefore drops the results,
+ * and that is not a special case handled here: `useCorridorPois` clears whenever the
+ * routed line changes, so an answer can never outlive the question it belongs to.
+ */
+export function useMRtCorridor(planner: TripPlanner, shell: MTripShellApi): MRtCorridorController {
+ const corridor = planner.roadtripCorridor
+ const { setDayId, clear: clearCorridor, search, categories, toggleCategory, stopsAlongKm } = corridor
+ const { offline } = useNetworkMode()
+ const reach = shell.rtReach
+
+ const stage = useMemo(
+ () => stageOf(planner.roadtripRoutes.days, planner.selectedDayId),
+ [planner.roadtripRoutes.days, planner.selectedDayId],
+ )
+
+ // The chip rail names the stage, so the stage names the drive being searched. Written
+ // as an effect rather than as a prop because `dayId` is also the desktop day picker's
+ // state, and only one of the two shells is ever mounted.
+ const stageDayId = stage ? String(stage.dayId) : ''
+ useEffect(() => {
+ if (stageDayId) setDayId(stageDayId)
+ }, [stageDayId, setDayId])
+
+ /**
+ * Whether the clock can say where the drive has got to.
+ *
+ * Only on the day itself, and only once one stop is behind: on the first stop of the
+ * morning "ahead" and "the whole stage" are the same stretch, and claiming a position
+ * nobody gave us would be worse than starting at the beginning.
+ */
+ const upNextIndexNow = useCallback((): number => {
+ if (!stage) return -1
+ const date = planner.days.find(d => d.id === planner.selectedDayId)?.date
+ const isToday = !!date && date.slice(0, 10) === new Date().toISOString().slice(0, 10)
+ const next = upNextStop(stage, nowMinutes(), isToday)
+ if (!next) return -1
+ return stage.stops.findIndex(s => s.assignmentId === next.row.stop.assignmentId)
+ }, [stage, planner.days, planner.selectedDayId])
+
+ const anchored = upNextIndexNow() > 0
+
+ const canSearch = !offline && categories.length > 0 && search.spine.length > 1
+
+ const runSearch = search.search
+ const run = useCallback(() => {
+ if (!canSearch) return
+ const index = upNextIndexNow()
+ runSearch(corridorWindow(anchorKmFor(stopsAlongKm, index > 0 ? index : null), reach))
+ }, [canSearch, runSearch, upNextIndexNow, stopsAlongKm, reach])
+
+ /**
+ * Changing the reach throws the answer away on purpose.
+ *
+ * Hits found in the fifty kilometres ahead are not an answer to "the whole stage", and
+ * a list that stays put while the chip above it says something else is the kind of
+ * quiet wrongness that gets a stop planned in the wrong place. The categories behave
+ * the same way one level down, in `useCorridorPois`.
+ */
+ const { setRtReach } = shell
+ const setReach = useCallback((value: CorridorReach) => {
+ if (value === reach) return
+ setRtReach(value)
+ clearCorridor()
+ }, [reach, setRtReach, clearCorridor])
+
+ return {
+ categories,
+ toggleCategory,
+ reach,
+ setReach,
+ reachKm: PHONE_REACH_KM,
+ anchored,
+ run,
+ clear: clearCorridor,
+ canSearch,
+ loading: search.loading,
+ progress: search.progress,
+ hits: corridor.visible,
+ answered: !search.loading && search.progress.total > 0,
+ offline,
+ error: search.error,
+ capped: search.capped,
+ failedAreas: search.failedAreas,
+ truncatedAreas: search.truncatedAreas,
+ }
+}
diff --git a/client/src/mobile/screens/trip/sheets/MMehrSheet.tsx b/client/src/mobile/screens/trip/sheets/MMehrSheet.tsx
index df164c23c..d4149eb5a 100644
--- a/client/src/mobile/screens/trip/sheets/MMehrSheet.tsx
+++ b/client/src/mobile/screens/trip/sheets/MMehrSheet.tsx
@@ -2,13 +2,12 @@ import { ChevronRight, FileDown, Settings, Share2 } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import MSheet from '../../../components/MSheet'
import type { MTripSheetsProps } from '../MTripShell'
+import { dockTabIds } from '../dockTabs'
import { useTranslation } from '../../../../i18n'
-/** The sections living in the dock — everything else surfaces in this sheet. */
-const DOCK_IDS = new Set(['plan', 'transports', 'buchungen', 'finanzplan', 'listen'])
-
/** Demo tile tints per legacy tab id; plugins share the files neutral. */
const TILE_COLORS: Record = {
+ roadtrip: '#0a84ff',
transports: '#4A7DDB',
buchungen: '#9B5DE5',
listen: '#2FA9A0',
@@ -27,7 +26,10 @@ export default function MMehrSheet({ planner, shell }: MTripSheetsProps) {
const open = shell.sheet?.id === 'mehr'
const canEditTrip = planner.can('trip_edit', planner.trip)
- const gridTabs = planner.TRIP_TABS.filter(tab => !DOCK_IDS.has(tab.id))
+ // Derived from the same priority list the dock seats itself from, so a section
+ // can never show up in both places at once.
+ const seated = dockTabIds(new Set(planner.TRIP_TABS.map(tab => tab.id)))
+ const gridTabs = planner.TRIP_TABS.filter(tab => !seated.has(tab.id))
const tileStat = (id: string): string | null => {
if (id === 'dateien') {
diff --git a/client/src/mobile/screens/trip/sheets/MTripSheets.tsx b/client/src/mobile/screens/trip/sheets/MTripSheets.tsx
index 65832416c..a7b74a63e 100644
--- a/client/src/mobile/screens/trip/sheets/MTripSheets.tsx
+++ b/client/src/mobile/screens/trip/sheets/MTripSheets.tsx
@@ -23,6 +23,11 @@ import MNoteSheet, { type MNoteSheetPayload } from './MNoteSheet'
import MImportSheet from './MImportSheet'
import MExportSheet from './MExportSheet'
import MMehrSheet from './MMehrSheet'
+import MRtStopSheet from '../roadtrip/MRtStopSheet'
+import MRtStaySheet from '../roadtrip/MRtStaySheet'
+import MRtInfoSheet from '../roadtrip/MRtInfoSheet'
+import MRtCorridorSheet from '../roadtrip/MRtCorridorSheet'
+import MRtDraftSheet from '../roadtrip/MRtDraftSheet'
import type { BookingExpenseRequest } from '../../../../components/Planner/BookingCostsSection.types'
import type { BudgetItem } from '../../../../types'
import type { MTripSheetsProps } from '../MTripShell'
@@ -63,6 +68,16 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
+ {/* The stage's own sheets. Each one checks shell.sheet?.id itself, the draft
+ sheet hangs off planner.stopDraft the way the place editor hangs off its flag. */}
+
+
+
+ {/* Before the draft sheet, not after: both sit at the same z, so the one mounted
+ later paints on top, and taking a hit onto the trip opens the draft OVER the
+ search it came from. */}
+
+
case 'listen':
return
+ // The only tab with two halves of its own: the chain, or the same map showing
+ // the stage. Both are this one component, because they share a stage and the
+ // switch between them must not lose it.
+ case 'roadtrip':
+ return
default:
return {null}
}
diff --git a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx
index 71e6f4eef..3f8c892d1 100644
--- a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx
+++ b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx
@@ -1,5 +1,5 @@
import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo'
-// FE-TP-ROAD-001 to FE-TP-ROAD-074
+// FE-TP-ROAD-001 to FE-TP-ROAD-096
import React from 'react'
import { renderHook, act, waitFor } from '@testing-library/react'
import { TranslationProvider } from '../../i18n/TranslationContext'
@@ -87,6 +87,10 @@ const rt = vi.hoisted(() => {
remove: vi.fn(async () => {}),
reanchor: vi.fn(async () => {}),
}
+ // What the routing round was last called with. The round itself is a fixture, so
+ // the arguments are the only place the planner's own decisions are visible: which
+ // days it is willing to route, and which assignment list it builds them from.
+ const routesArgs = { current: [] as unknown[] }
const routes = {
days: [] as Array>,
quietDays: [] as unknown[],
@@ -141,11 +145,13 @@ const rt = vi.hoisted(() => {
ask: vi.fn(),
close: vi.fn(),
}
- return { vias, routes, corridor, alt }
+ return { vias, routes, corridor, alt, routesArgs }
})
vi.mock('../../components/Roadtrip/useRoadtripVias', () => ({ useRoadtripVias: () => rt.vias }))
-vi.mock('../../components/Roadtrip/useRoadtripRoutes', () => ({ useRoadtripRoutes: () => rt.routes }))
+vi.mock('../../components/Roadtrip/useRoadtripRoutes', () => ({
+ useRoadtripRoutes: (...args: unknown[]) => { rt.routesArgs.current = args; return rt.routes },
+}))
vi.mock('../../components/Roadtrip/useRoadtripCorridor', () => ({ useRoadtripCorridor: () => rt.corridor }))
vi.mock('../../components/Roadtrip/useRouteAlternatives', () => ({ useRouteAlternatives: () => rt.alt }))
vi.mock('../../components/Roadtrip/useFollowTrack', () => ({
@@ -295,6 +301,7 @@ beforeEach(() => {
rt.vias.stale = false
rt.vias.editable = true
rt.routes.days = []
+ rt.routesArgs.current = []
rt.routes.lines = []
rt.routes.lineDays = []
rt.corridor.day = undefined
@@ -1926,3 +1933,159 @@ it('preserves exclusive service stops when reordering the visible Days stops', a
await act(async () => result.current.handleReorder(5, [13, 11]))
expect(actions.reorderAssignments).toHaveBeenCalledWith(42, 5, [13, 12, 11])
})
+
+/**
+ * The phone's own way into the drive.
+ *
+ * Road trip MODE is a data switch, not a view switch: `assignments` and `places` are
+ * derived from it for the whole hook, and every permanently mounted sheet of the phone
+ * shell reads those same two lists. The phone has no control that turns the mode back
+ * off, so it never turns it on. `roadtripMode = storedRoadtripMode && !isMobile` stays
+ * exactly as it is, and the tab feeds the routing round instead.
+ */
+describe('useTripPlanner road trip: the phone feed', () => {
+ let desktopWidth: number
+
+ beforeEach(() => {
+ desktopWidth = window.innerWidth
+ Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 390 })
+ })
+
+ afterEach(() => {
+ Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: desktopWidth })
+ })
+
+ /** The days the routing round was handed. Empty means it is standing down. */
+ const fedDays = () => rt.routesArgs.current[1] as Array<{ id: number }>
+ /** The assignment list the drive is built from. */
+ const fedAssignments = () => rt.routesArgs.current[2] as Record>
+
+ /** The routed shape of one day, which `renderRoadtrip` builds for the desk suites. */
+ const routeDay = (dayId: number) => {
+ rt.routes.days = [{
+ dayId,
+ dayNumber: 1,
+ schedule: { entries: [] },
+ legs: [],
+ stops: [0, 1, 2].map((i) => ({
+ assignmentId: i + 1, placeId: i + 1, lat: 53 - i, lng: 10 + i,
+ ownerDayId: dayId, ownerIndex: i,
+ })),
+ }]
+ }
+
+ /** Mount on a phone and wait until the addon feed has answered. */
+ async function renderPhone() {
+ const rendered = renderHook(() => useTripPlanner(), { wrapper })
+ await act(async () => { await Promise.resolve() })
+ // The drive tab exists on the phone and nowhere else, so its arrival doubles as
+ // the proof that the addon feed landed.
+ await waitFor(() => expect(rendered.result.current.TRIP_TABS.some(tab => tab.id === 'roadtrip')).toBe(true))
+ return rendered
+ }
+
+ it('FE-TP-ROAD-090: the phone opens the feed and leaves the mode alone', async () => {
+ seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] })
+ sessionStorage.setItem('trip-tab-42', 'roadtrip')
+
+ const { result } = await renderPhone()
+
+ // The session still carries the switch from an earlier, wider window, which is
+ // the whole point: narrowing past the breakpoint must not bring the mode along.
+ expect(sessionStorage.getItem('trip-roadtrip-42')).toBe('1')
+ expect(result.current.roadtripMode).toBe(false)
+ expect(result.current.roadtripActive).toBe(false)
+ expect(result.current.roadtripFeedActive).toBe(true)
+ })
+
+ it('FE-TP-ROAD-091: a phone that never opened the drive routes nothing', async () => {
+ seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] })
+
+ const { result } = await renderPhone()
+
+ expect(result.current.activeTab).toBe('plan')
+ expect(result.current.roadtripFeedActive).toBe(false)
+ // A day costs a rate-limited routing request, so the tab has to be asked for first.
+ expect(fedDays()).toHaveLength(0)
+ })
+
+ it('FE-TP-ROAD-092: with the drive tab open the round is handed the trip days', async () => {
+ seedTrip({ days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 })] })
+ sessionStorage.setItem('trip-tab-42', 'roadtrip')
+
+ const { result } = await renderPhone()
+
+ expect(result.current.roadtripFeedActive).toBe(true)
+ expect(fedDays().map(d => d.id)).toEqual([5, 6])
+ })
+
+ it('FE-TP-ROAD-093: the round is fed the STORED list, the day sheet keeps the filtered one', async () => {
+ const town = buildPlace({ id: 101, name: 'Bergen' })
+ const fuelStop = buildPlace({ id: 102, name: 'Tankstelle', stop_type: 'fuel' })
+ const hotel = buildPlace({ id: 103, name: 'Fjordhotell' })
+ seedTrip({
+ places: [town, fuelStop, hotel],
+ days: [buildDay({ id: 5, day_number: 1 })],
+ assignments: {
+ '5': [
+ buildAssignment({ id: 11, day_id: 5, order_index: 0, place: town }),
+ buildAssignment({ id: 12, day_id: 5, order_index: 1, place: fuelStop }),
+ buildAssignment({ id: 13, day_id: 5, order_index: 2, place: hotel, accommodation_id: 9 }),
+ ],
+ },
+ })
+ useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_service_stops_in_days: false } }))
+ sessionStorage.setItem('trip-tab-42', 'roadtrip')
+
+ const { result } = await renderPhone()
+
+ // What the day sheet, the day list and the PDF export read: the pump is switched
+ // out of the plan, and the booked night is already its own overnight block.
+ expect(result.current.assignments['5'].map(v => v.id)).toEqual([11])
+ // What the drive is built from: the pump it stops at and the night it ends on.
+ expect(fedAssignments()['5'].map(v => v.id)).toEqual([11, 12, 13])
+ })
+
+ it('FE-TP-ROAD-094: leaving the drive tab does not throw the routed legs away', async () => {
+ seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] })
+ sessionStorage.setItem('trip-tab-42', 'roadtrip')
+
+ const { result } = await renderPhone()
+ act(() => { result.current.handleTabChange('plan') })
+
+ expect(result.current.activeTab).toBe('plan')
+ expect(result.current.roadtripFeedActive).toBe(true)
+ expect(fedDays().map(d => d.id)).toEqual([5])
+ })
+ it('FE-TP-ROAD-095: a hit found on the phone lands in the chain, not in the full place form', async () => {
+ // The sharp one. `roadtripActive` is false on a phone by design, so gating the
+ // corridor branch on it sent every hit the stage map found into the ordinary place
+ // form, losing the stop kind, the stay, and the position worked out just above it.
+ seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] })
+ sessionStorage.setItem('trip-tab-42', 'roadtrip')
+ rt.corridor.day = { dayId: 5, dayNumber: 1 }
+ rt.corridor.insertIndexFor.mockReturnValue(2)
+ routeDay(5)
+
+ const { result } = await renderPhone()
+ act(() => { result.current.handlePoiClick(poi() as never) })
+
+ expect(result.current.roadtripActive).toBe(false)
+ expect(result.current.stopDraft).toMatchObject({ dayId: 5, position: 2, dayNumber: 1 })
+ expect(result.current.showPlaceForm).toBe(false)
+ })
+
+ it('FE-TP-ROAD-096: an ordinary POI on the phone still carries the stage it was tapped on', async () => {
+ seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] })
+ sessionStorage.setItem('trip-tab-42', 'roadtrip')
+ rt.corridor.day = { dayId: 5, dayNumber: 1 }
+
+ const { result } = await renderPhone()
+ const { alongKm: _drop, offRouteKm: _drop2, ...plain } = poi()
+ act(() => { result.current.handlePoiClick(plain as never) })
+
+ // Without the day it lands in the unplanned pool, which the stage does not show.
+ expect(result.current.showPlaceForm).toBe(true)
+ expect(result.current.placeFormDayId).toBe(5)
+ })
+})
diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts
index 11069bc95..f6d7e2f0f 100644
--- a/client/src/pages/tripPlanner/useTripPlanner.ts
+++ b/client/src/pages/tripPlanner/useTripPlanner.ts
@@ -9,7 +9,7 @@ import { useSettingsStore } from '../../store/settingsStore'
import { dayColor } from '../../components/Roadtrip/dayColors'
import { getCached, fetchPhoto } from '../../services/photoService'
import { useToast } from '../../components/shared/Toast'
-import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
+import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train, Route } from 'lucide-react'
import { resolvePluginIcon } from '../../components/shared/PluginIcon'
import { useTranslation, translateApiError } from '../../i18n'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi, mapsApi, placesApi } from '../../api/client'
@@ -34,6 +34,7 @@ import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
import { useDawarichTrail } from '../../components/Map/useDawarichTrail'
import { collapsedDayDates } from '../../components/Map/dawarichTrail'
import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor'
+import { PHONE_CORRIDOR_OPTIONS } from '../../components/Roadtrip/corridorSearchModel'
import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
import { useRefuelSearch } from '../../components/Roadtrip/useRefuelSearch'
import type { RefuelCandidate } from '../../components/Roadtrip/refuelSuggestion'
@@ -222,6 +223,9 @@ export function useTripPlanner() {
{ id: 'plan', label: t(TRIP_TAB_LABEL_KEYS.plan), icon: Map },
{ id: 'transports', label: t(TRIP_TAB_LABEL_KEYS.transports), icon: Train },
{ id: 'buchungen', label: t(TRIP_TAB_LABEL_KEYS.buchungen), shortLabel: t('trip.tabs.reservationsShort'), icon: Ticket },
+ // Phone only: the desktop reaches the drive through the mode switch beside the
+ // day plan, and a second entry point there would be a tab nobody needs.
+ ...(enabledAddons.roadtrip && isMobile ? [{ id: 'roadtrip', label: t(TRIP_TAB_LABEL_KEYS.roadtrip), icon: Route }] : []),
...(enabledAddons.packing ? [{ id: 'listen', label: t(TRIP_TAB_LABEL_KEYS.listen), shortLabel: t('trip.tabs.listsShort'), icon: PackageCheck }] : []),
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t(TRIP_TAB_LABEL_KEYS.finanzplan), icon: Wallet }] : []),
...(enabledAddons.documents ? [{ id: 'dateien', label: t(TRIP_TAB_LABEL_KEYS.dateien), icon: FolderOpen }] : []),
@@ -625,12 +629,30 @@ export function useTripPlanner() {
// Road trip mode reads the whole trip, not the selected day, so it owns its own legs.
// Passing no days while the mode is off keeps it inert — no routing requests, no state.
const roadtripActive = !!enabledAddons.roadtrip && roadtripMode
+ // The phone's own way into the drive, and the reason line 148 above stays as it is.
+ //
+ // `roadtripMode` is not a view switch, it is a data switch: `assignments` and
+ // `places` are derived from it for the whole hook, and every permanently mounted
+ // sheet of the phone shell reads those same lists. Letting the phone flip it would
+ // bring back exactly the regressions the comment up there describes, on paths that
+ // never touch a tab. The second tap on a day chip opens the day sheet, the More
+ // button opens the PDF export.
+ //
+ // So the phone feeds the routing round instead, and nothing else. Once the tab has
+ // been opened the feed stays on for as long as the trip is: a tab switch must not
+ // throw away legs that cost a rate-limited request each.
+ const roadtripTabSeen = useRef(false)
+ if (activeTab === 'roadtrip') roadtripTabSeen.current = true
+ const roadtripFeedActive = !!enabledAddons.roadtrip
+ && (roadtripMode || (isMobile && (activeTab === 'roadtrip' || roadtripTabSeen.current)))
const roadtripPreferencesState = useLoadRoadtripSettings(tripId, !!enabledAddons.roadtrip)
useEffect(() => {
if (roadtripPreferencesState.failed) toast.error(t('common.error'))
}, [roadtripPreferencesState.failed, toast, t])
const dailyTimesActive = !!dayWindow(roadtripSettings.roadtrip_day_start, roadtripSettings.roadtrip_day_end)
- const dayBoundaries = useDayBoundaries(tripId, roadtripActive && dailyTimesActive, assignments)
+ // Fed from the same flag as the routing round: without the boundaries the phone
+ // would draw a drive that never ends for the night.
+ const dayBoundaries = useDayBoundaries(tripId, roadtripFeedActive && dailyTimesActive, assignments)
useEffect(() => { if (dayBoundaries.stale) toast.error(t('trip.toast.loadError')) }, [dayBoundaries.stale, toast, t])
const resetDayBoundaries = dayBoundaries.editable && dayBoundaries.boundaries.length && can('day_edit', trip) ? async () => {
try {
@@ -648,8 +670,11 @@ export function useTripPlanner() {
const refuel = useRefuelSearch()
const roadtripRoutes = useRoadtripRoutes(
tripId,
- roadtripActive && roadtripPreferencesState.ready ? days : EMPTY_DAYS,
- assignments,
+ roadtripFeedActive && roadtripPreferencesState.ready ? days : EMPTY_DAYS,
+ // Deliberately the stored list, not the filtered one: the drive wants the
+ // service stops and the night a lodging booking put on the map, and the filter
+ // above only exists to keep those out of a day list read as a plan.
+ roadtripFeedActive ? storedAssignments : assignments,
routeProfile,
roadtripVias.byDay,
dayBoundaries.boundaries,
@@ -658,7 +683,10 @@ export function useTripPlanner() {
// Lives here rather than in the panel because the map draws what it finds.
// The trip comes with it for the vehicle: an electric car looks for chargers rather
// than for pumps, and that preference is stored per trip.
- const roadtripCorridor = useRoadtripCorridor(roadtripRoutes, tripId)
+ // The phone searches under a smaller ceiling: fewer boxes, a capped retry pass and a
+ // deadline, because a search that keeps the radio warm costs battery somebody is
+ // navigating on. Which stretch of the day it asks about is decided per search.
+ const roadtripCorridor = useRoadtripCorridor(roadtripRoutes, tripId, isMobile ? PHONE_CORRIDOR_OPTIONS : undefined)
// Applying a track is a long job — a routing round trip per refinement — so it lives
// above the dialog: a component that unmounted halfway would leave the day holding
// half a chain of vias.
@@ -815,7 +843,12 @@ export function useTripPlanner() {
if (!can('place_edit', trip)) return
// A corridor hit knows how far along the drive it sits, so it can go straight into
// the chain in driving order instead of being dragged there afterwards.
- const hit = roadtripActive && 'alongKm' in poi ? (poi as unknown as CorridorPoi) : null
+ //
+ // Gated on the FEED, not on road trip mode: the phone never turns that mode on (it
+ // is a data switch the mobile sheets cannot survive, see `roadtripFeedActive`), so
+ // reading it here sent every hit found on the stage map into the full place form
+ // instead, losing the stop kind, the stay, and the position worked out just above.
+ const hit = roadtripFeedActive && 'alongKm' in poi ? (poi as unknown as CorridorPoi) : null
if (hit && roadtripDayId != null) {
const displayed = roadtripRoutes.days.find(d => d.dayId === roadtripDayId)
const insert = displayed && roadtripInsertion(displayed, roadtripInsertIndexFor(hit))
@@ -834,8 +867,8 @@ export function useTripPlanner() {
}
const selected = roadtripRoutes.days.find(d => d.dayId === roadtripDayId)
const target = selected && roadtripInsertion(selected, selected.stops.length)
- openAddPlaceFromPoi(poi, roadtripActive ? target?.dayId ?? roadtripDayId : undefined)
- }, [openAddPlaceFromPoi, roadtripActive, roadtripDayId, roadtripDayNumber, roadtripInsertIndexFor, roadtripRoutes.days, overnightOptions, can, trip])
+ openAddPlaceFromPoi(poi, roadtripFeedActive ? target?.dayId ?? roadtripDayId : undefined)
+ }, [openAddPlaceFromPoi, roadtripFeedActive, roadtripDayId, roadtripDayNumber, roadtripInsertIndexFor, roadtripRoutes.days, overnightOptions, can, trip])
/**
* The stops of a day as the road trip counts them, in the order it drives them.
@@ -2402,7 +2435,7 @@ export function useTripPlanner() {
selectedDayId, isLoading, tripActions, can, canUploadFiles,
pushUndo, undo, canUndo, lastActionLabel, handleUndo,
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
- roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
+ roadtripMode, toggleRoadtripMode, roadtripActive, roadtripFeedActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
overviewShown, toggleOverview, overviewActive, tripOverview,
dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichHiddenDates, dawarichEnabled: !!enabledAddons.dawarich,
followTrack, roadtripViaCounts,
@@ -2426,6 +2459,10 @@ export function useTripPlanner() {
refuel, askRefuel, acceptRefuel,
routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints, roadtripMapVias, focusRoadtripPoint,
stayDraft, setStayDraft, editRoadtripStay, setRoadtripStay, roadtripEndDay, roadtripStay,
+ // Addressed by stop rather than by selection: the phone's stage sheet knows which
+ // stop it is showing, and going through the place selection there would open the
+ // permanently mounted place inspector underneath it.
+ setRoadtripEndDay, dailyTimesActive,
highlightedAlternative, setHighlightedAlternative,
moveRoadtripStopToDay,
dropPoiOnRoute,
diff --git a/client/tests/helpers/mobileTrip.ts b/client/tests/helpers/mobileTrip.ts
index d7c8d8955..a5723f1e7 100644
--- a/client/tests/helpers/mobileTrip.ts
+++ b/client/tests/helpers/mobileTrip.ts
@@ -201,6 +201,42 @@ export function buildPlanner(overrides: Partial = {}): TripPlanner
toggleDawarichTrail: vi.fn(),
dawarichTrail: { track: null, status: 'idle', reload: vi.fn() },
+ // The road trip addon's routing round. The shell reads days/totalDistance for
+ // its stage header on every render, addon on or off, so the empty-but-valid
+ // shape belongs in the base rather than in the overrides of one suite.
+ roadtripRoutes: {
+ days: [], quietDays: [], lines: [], lineDays: [], accessLines: [], vias: [], segments: [],
+ totalDistance: 0, totalDuration: 0, totalStops: 0, loading: false,
+ },
+
+ // The corridor search, idle. Both the search bar over the stage and the search
+ // sheet read it on every render of the road trip tab, addon on or off, so the
+ // empty-but-valid shape belongs here rather than in one suite's overrides.
+ roadtripCorridor: {
+ dayId: '', setDayId: vi.fn(), day: undefined,
+ categories: ['fuel'], toggleCategory: vi.fn(),
+ widthKm: 5, setWidthKm: vi.fn(),
+ search: {
+ results: [], progress: { done: 0, total: 0 }, loading: false, capped: false,
+ failedSources: [], failedAreas: 0, truncatedAreas: 0, error: false, spine: [],
+ search: vi.fn(), clear: vi.fn(),
+ },
+ nameFilter: '', setNameFilter: vi.fn(), anchors: [],
+ section: null, setSection: vi.fn(), sectionKm: 50, setSectionKm: vi.fn(),
+ socketFilter: '', setSocketFilter: vi.fn(), minKw: 0, setMinKw: vi.fn(),
+ visible: [], insertIndexFor: vi.fn(() => 0), stopsAlongKm: [], clear: vi.fn(),
+ },
+ // The fuel search, idle: the dry band in the chain reads it per leg.
+ refuel: {
+ openFor: null, loading: false, outcome: null, results: [], offered: [],
+ ask: vi.fn(), close: vi.fn(),
+ },
+ askRefuel: vi.fn(),
+ acceptRefuel: vi.fn(),
+ mapFocusPoints: [],
+ focusRoadtripPoint: vi.fn(),
+ handlePoiClick: vi.fn(),
+
route: null,
routeSegments: [],
routeInfo: null,
@@ -245,6 +281,11 @@ export function buildPlanner(overrides: Partial = {}): TripPlanner
export function buildShell(overrides: Partial = {}): MTripShellApi {
const base: MTripShellApi = {
view: 'plan',
+ rtView: 'list',
+ mapFront: false,
+ toggleRtView: vi.fn(),
+ rtReach: 'ahead',
+ setRtReach: vi.fn(),
mode: 'go',
trTab: 'plan',
setTrTab: vi.fn(),
diff --git a/client/tests/unit/mobile/trip/MMapArea.test.tsx b/client/tests/unit/mobile/trip/MMapArea.test.tsx
index 628944385..c36c91a01 100644
--- a/client/tests/unit/mobile/trip/MMapArea.test.tsx
+++ b/client/tests/unit/mobile/trip/MMapArea.test.tsx
@@ -5,7 +5,7 @@ import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/
import type { CompassMap } from '../../../../src/components/Map/MapCompassPill'
import { useSettingsStore } from '../../../../src/store/settingsStore'
-// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-007
+// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-014
const mocks = vi.hoisted(() => ({
poi: {} as Record,
@@ -39,7 +39,10 @@ const COMPASS: CompassMap = {
function renderArea(shellOver: Partial = {}, plannerOver: Partial = {}) {
const planner = buildPlanner(plannerOver)
- const shell = buildShell({ view: 'map', ...shellOver })
+ // `mapFront` is what the floating chrome keys off since the map became one
+ // instance shared by the plan tab and the road trip tab. `view` alone no longer
+ // says whether the map is the front layer.
+ const shell = buildShell({ view: 'map', mapFront: true, ...shellOver })
return { planner, shell, ...render( ) }
}
@@ -78,7 +81,23 @@ describe('MMapArea', () => {
// The dock is 62px tall at safe-bottom + 12; the controls add their own 12.
expect((container.firstElementChild as HTMLElement).className)
- .toContain('[--bottom-nav-h:calc(env(safe-area-inset-bottom,0px)+74px)]')
+ .toContain('[--bottom-nav-h:calc(env(safe-area-inset-bottom,0px)+74px+var(--m-stage-lift,0px))]')
+ // No lift off the stage: the plan tab has no bar in that band.
+ expect((container.firstElementChild as HTMLElement).style.getPropertyValue('--m-stage-lift')).toBe('0px')
+ })
+
+ it('FE-MOB-MAPAREA-013: on the stage the controls clear the stage bar by one gap', () => {
+ const { container } = renderArea({ trTab: 'roadtrip', mapFront: true })
+
+ // The bar is 61px tall and keeps a gap on both sides, so the round controls land
+ // above it rather than on its top edge.
+ expect((container.firstElementChild as HTMLElement).style.getPropertyValue('--m-stage-lift')).toBe('76px')
+ })
+
+ it('FE-MOB-MAPAREA-014: behind the chain there is no bar to clear, so nothing lifts', () => {
+ const { container } = renderArea({ trTab: 'roadtrip', mapFront: false })
+
+ expect((container.firstElementChild as HTMLElement).style.getPropertyValue('--m-stage-lift')).toBe('0px')
})
it('FE-MOB-MAPAREA-004: a renderer that cannot rotate gets no compass', () => {
@@ -98,7 +117,7 @@ describe('MMapArea', () => {
})
it('FE-MOB-MAPAREA-006: no floating chrome while the timeline covers the map', () => {
- const { container } = renderArea({ view: 'plan' })
+ const { container } = renderArea({ view: 'plan', mapFront: false })
expect(screen.queryByLabelText('Cafés')).not.toBeInTheDocument()
expect(compassBand(container)).toBeNull()
@@ -113,4 +132,62 @@ describe('MMapArea', () => {
expect(shell.openSheet).toHaveBeenCalledWith('transport', { reservationId: 7 })
})
+ it('FE-MOB-MAPAREA-008: on the stage the pins are the search and the fuel offers, and nothing else', () => {
+ const corridorHit = { osm_id: 'c1', name: 'Shell', lat: 53.5, lng: 9.8, category: 'fuel' }
+ const offered = { osm_id: 'r1', name: 'Aral', lat: 53.4, lng: 9.7, category: 'fuel' }
+ mocks.poi = { ...mocks.poi, pois: [{ osm_id: 'e1', name: 'Café', lat: 53.3, lng: 9.6, category: 'cafe' }] }
+ const base = buildPlanner()
+ renderArea({ trTab: 'roadtrip' }, {
+ roadtripCorridor: { ...base.roadtripCorridor, visible: [corridorHit] },
+ refuel: { ...base.refuel, offered: [offered] },
+ } as unknown as Partial)
+
+ const ids = (mocks.props.pois as Array<{ osm_id: string }>).map(p => p.osm_id)
+ expect(ids).toEqual(expect.arrayContaining(['c1', 'r1']))
+ // "What is around this piece of map" is a different question from "what is on the
+ // way", and its bar is off this tab, so its pins would be pins nobody could clear.
+ expect(ids).not.toContain('e1')
+ expect(screen.queryByLabelText('Cafés')).not.toBeInTheDocument()
+ })
+
+ it('FE-MOB-MAPAREA-009: off the stage a corridor hit is not drawn at all', () => {
+ const base = buildPlanner()
+ renderArea({ trTab: 'plan' }, {
+ roadtripCorridor: { ...base.roadtripCorridor, visible: [{ osm_id: 'c1', lat: 53.5, lng: 9.8, category: 'fuel' }] },
+ } as unknown as Partial)
+
+ // A pin with no route under it cannot explain where on the drive it sits.
+ expect((mocks.props.pois as Array<{ osm_id: string }>).map(p => p.osm_id)).not.toContain('c1')
+ })
+
+ it('FE-MOB-MAPAREA-010: a pin tapped on the stage goes through the road trip door', () => {
+ const { planner } = renderArea({ trTab: 'roadtrip' })
+
+ const marker = { osm_id: 'c1', name: 'Shell', lat: 53.5, lng: 9.8, category: 'fuel', alongKm: 82 }
+ ;(mocks.props.onPoiClick as (m: unknown) => void)(marker)
+
+ expect(planner.handlePoiClick).toHaveBeenCalledWith(marker)
+ expect(planner.openAddPlaceFromPoi).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-MAPAREA-011: on the plan tab it stays the plain place form, carrying the day', () => {
+ const { planner } = renderArea({ trTab: 'plan' }, { selectedDayId: 5 })
+
+ const marker = { osm_id: 'e1', name: 'Café', lat: 53.3, lng: 9.6, category: 'cafe' }
+ ;(mocks.props.onPoiClick as (m: unknown) => void)(marker)
+
+ expect(planner.openAddPlaceFromPoi).toHaveBeenCalledWith(marker, 5)
+ expect(planner.handlePoiClick).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-MAPAREA-012: a focused hit takes the camera; with nothing pending the stage frames itself', () => {
+ const focused = renderArea({ trTab: 'roadtrip' }, { mapFocusPoints: [[53.5, 9.8]] })
+ expect(mocks.props.focusPoints).toEqual([[53.5, 9.8]])
+ focused.unmount()
+
+ renderArea({ trTab: 'roadtrip' })
+ // The stage's own points, which is an array either way: going array → undefined →
+ // array is two dependency changes, and the second throws away the traveller's pan.
+ expect(Array.isArray(mocks.props.focusPoints)).toBe(true)
+ })
})
diff --git a/client/tests/unit/mobile/trip/MMehrSheet.test.tsx b/client/tests/unit/mobile/trip/MMehrSheet.test.tsx
index 2ec34d5bf..07a8fcad5 100644
--- a/client/tests/unit/mobile/trip/MMehrSheet.test.tsx
+++ b/client/tests/unit/mobile/trip/MMehrSheet.test.tsx
@@ -5,9 +5,9 @@ import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/
import type { TripFile, TripMember } from '../../../../src/types'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { resetAllStores } from '../../../helpers/store'
-import { fireEvent, render, screen } from '../../../helpers/render'
+import { fireEvent, render, screen, within } from '../../../helpers/render'
-// FE-MOB-MEHR-001 to FE-MOB-MEHR-012
+// FE-MOB-MEHR-001 to FE-MOB-MEHR-014
//
// The sheet takes its copy from the real TranslationProvider, so the visible
// strings are asserted in English.
@@ -133,4 +133,46 @@ describe('MMehrSheet', () => {
const rows = screen.getByRole('button', { name: 'Share Trip' }).parentElement
expect(rows).toHaveClass('mt-2')
})
+
+ // Both sides read the same priority list (dockTabs.ts). They used to be two
+ // hand-kept copies, and extending only one showed the same section in the dock AND
+ // as a tile here.
+ describe('the dock overflow', () => {
+ /** The sections with the road trip addon on, in the order the planner builds them. */
+ const WITH_ROADTRIP = [
+ { id: 'plan', label: 'Plan', icon: FolderOpen },
+ { id: 'transports', label: 'Transport', icon: FolderOpen },
+ { id: 'buchungen', label: 'Bookings', icon: FolderOpen },
+ { id: 'roadtrip', label: 'Road trip', icon: FolderOpen },
+ { id: 'listen', label: 'Lists', icon: FolderOpen },
+ { id: 'finanzplan', label: 'Budget', icon: FolderOpen },
+ { id: 'dateien', label: 'Files', icon: FolderOpen },
+ { id: 'collab', label: 'Collaboration', icon: Users },
+ ]
+
+ it('FE-MOB-MEHR-013: the section the drive pushed out of the dock lands here', () => {
+ renderSheet({ TRIP_TABS: WITH_ROADTRIP as TripPlanner['TRIP_TABS'] })
+
+ // Six sections do not fit the dock, so the packing list gives up its seat.
+ expect(screen.getByRole('button', { name: 'Lists' })).toBeInTheDocument()
+ // The drive itself never overflows: it sits second in the priority list.
+ expect(screen.queryByRole('button', { name: 'Road trip' })).not.toBeInTheDocument()
+ // Exactly the three the dock could not seat, nothing shown twice.
+ const grid = screen.getByRole('button', { name: 'Lists' }).parentElement
+ expect(within(grid as HTMLElement).getAllByRole('button')).toHaveLength(3)
+ })
+
+ it('FE-MOB-MEHR-014: a dock with room keeps the packing list, addon or no addon', () => {
+ renderSheet({
+ TRIP_TABS: WITH_ROADTRIP.filter(
+ tab => tab.id !== 'finanzplan' && tab.id !== 'dateien' && tab.id !== 'collab',
+ ) as TripPlanner['TRIP_TABS'],
+ })
+
+ // Five sections still fit beside the More button, so nothing overflows and the
+ // sheet is the action rows alone.
+ expect(screen.queryByRole('button', { name: 'Lists' })).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Share Trip' }).parentElement).not.toHaveClass('mt-2')
+ })
+ })
})
diff --git a/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx b/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx
new file mode 100644
index 000000000..c211d91e3
--- /dev/null
+++ b/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx
@@ -0,0 +1,381 @@
+import { describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen } from '../../../helpers/render'
+import {
+ RtAutoRow, RtDryRow, RtLegRow, RtSpillRow, RtStopRow, type RowChrome,
+} from '../../../../src/mobile/screens/trip/roadtrip/MRoadtripRows'
+import type { StopRow } from '../../../../src/components/Roadtrip/roadtripRowModel'
+import type { RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip'
+import type { TranslationFn } from '../../../../src/types'
+import type { RefuelSearch } from '../../../../src/components/Roadtrip/useRefuelSearch'
+import type { RefuelCandidate } from '../../../../src/components/Roadtrip/refuelSuggestion'
+
+// FE-MOB-RTROW-001 to FE-MOB-RTROW-030
+
+// Same echo strategy as tests/helpers/mobileTrip: assertions stay on keys, not copy.
+const t: TranslationFn = (key, params) =>
+ params ? `${key}:${Object.values(params).join(',')}` : key
+
+const chrome: RowChrome = { t, unit: 'metric' }
+
+function stopRow(over: Partial = {}): StopRow {
+ return {
+ kind: 'stop',
+ stop: {
+ assignmentId: 501, ownerDayId: 2, ownerIndex: 0, placeId: 101,
+ name: 'Kyoto Station', lat: 34.98, lng: 135.75,
+ time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null,
+ },
+ number: 2,
+ service: false,
+ entry: undefined,
+ time: '12:40',
+ pinned: false,
+ warning: null,
+ dwellMinutes: null,
+ offRoadMeters: null,
+ ...over,
+ } as StopRow
+}
+
+const SEG: RouteSegment = {
+ mid: [35.4, 138.6], from: [35.7, 139.8], to: [34.98, 135.75],
+ distance: 210_000, duration: 9_600,
+ // The two differ on purpose: durationText is the routed time, drivingText the
+ // fallback the older segments carry.
+ walkingText: '42 h', drivingText: '2 h 55 min', distanceText: '210 km', durationText: '2 h 40 min',
+}
+
+describe('RtStopRow', () => {
+ it('FE-MOB-RTROW-001: gives a destination its number and no kind icon', () => {
+ const { container } = render( )
+
+ expect(screen.getByText('2')).toBeInTheDocument()
+ expect(container.querySelector('.lucide-fuel')).toBeNull()
+ })
+
+ it('FE-MOB-RTROW-002: gives a service stop its kind symbol in the kind colour instead of a number', () => {
+ const { container } = render(
+ ,
+ )
+
+ const disc = container.querySelector('.lucide-fuel')?.parentElement as HTMLElement
+ expect(disc).toBeTruthy()
+ // #E8590C is SERVICE_COLORS.fuel, the colour the stop wears on the map too.
+ expect(disc).toHaveStyle({ background: '#E8590C' })
+ expect(screen.queryByText('2')).toBeNull()
+ })
+
+ it('FE-MOB-RTROW-003: keeps at most two marks, a dwell and a warning push the road distance out', () => {
+ const { container } = render(
+ ,
+ )
+
+ expect(screen.getByText('45 min')).toBeInTheDocument()
+ expect(screen.getByText('+25 min')).toBeInTheDocument()
+ expect(screen.queryByText('120 m')).toBeNull()
+ expect(container.querySelector('.lucide-footprints')).toBeNull()
+ })
+
+ it('FE-MOB-RTROW-004: shows the road distance only when the row has neither dwell nor warning', () => {
+ render( )
+
+ expect(screen.getByText('120 m')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTROW-005: gives a pinned time the pin and the full colour', () => {
+ render( )
+
+ expect(screen.getByLabelText('roadtrip.stop.pinned')).toBeInTheDocument()
+ expect(screen.getByText('12:40').className).toContain('text-m-ink')
+ })
+
+ it('FE-MOB-RTROW-006: leaves a computed time unpinned and muted', () => {
+ render( )
+
+ expect(screen.queryByLabelText('roadtrip.stop.pinned')).toBeNull()
+ const time = screen.getByText('12:40')
+ expect(time.className).toContain('text-m-faint')
+ expect(time.className).not.toContain('text-m-ink')
+ })
+
+ it('FE-MOB-RTROW-007: prints no clock for a stop the schedule could not time', () => {
+ render( )
+
+ expect(screen.queryByText('12:40')).toBeNull()
+ expect(screen.getByText('Kyoto Station')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTROW-008: opens the stop on a tap and on the keyboard', () => {
+ const onOpen = vi.fn()
+ render( )
+
+ const row = screen.getByRole('button')
+ fireEvent.click(row)
+ expect(onOpen).toHaveBeenCalledTimes(1)
+
+ fireEvent.keyDown(row, { key: 'Enter' })
+ expect(onOpen).toHaveBeenCalledTimes(2)
+
+ fireEvent.keyDown(row, { key: ' ' })
+ expect(onOpen).toHaveBeenCalledTimes(3)
+ })
+
+ it('FE-MOB-RTROW-009: ignores a key press that came from something inside the row', () => {
+ const onOpen = vi.fn()
+ render( )
+
+ fireEvent.keyDown(screen.getByText('Kyoto Station'), { key: 'Enter' })
+
+ expect(onOpen).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-RTROW-010: writes a late warning as the minutes it costs', () => {
+ const warning: ScheduleWarning = { index: 1, code: 'late', minutes: 25 }
+ const { container } = render( )
+
+ expect(screen.getByText('+25 min')).toBeInTheDocument()
+ expect(container.querySelector('.lucide-alert-triangle')).not.toBeNull()
+ })
+
+ it('FE-MOB-RTROW-011: writes a range warning as the distance driven since the last fill', () => {
+ const warning: ScheduleWarning = { index: 1, code: 'range', sinceKm: 520 }
+ const { container } = render( )
+
+ expect(screen.getByText('520 km')).toBeInTheDocument()
+ expect(container.querySelector('.lucide-fuel')).not.toBeNull()
+ })
+
+ it('FE-MOB-RTROW-012: writes a leg warning as the time it runs over', () => {
+ const warning: ScheduleWarning = { index: 1, code: 'leg', overMinutes: 35 }
+ render( )
+
+ expect(screen.getByText('35 min')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTROW-013: writes an overnight warning as a word, since it has no figure', () => {
+ const warning: ScheduleWarning = { index: 1, code: 'overnight' }
+ const { container } = render( )
+
+ expect(screen.getByText('roadtrip.warn.overnight')).toBeInTheDocument()
+ expect(container.querySelector('.lucide-moon')).not.toBeNull()
+ })
+})
+
+describe('RtLegRow', () => {
+ it('FE-MOB-RTROW-014: is not a control, no button, no role, nothing focusable', () => {
+ const { container } = render( )
+
+ expect(screen.queryByRole('button')).toBeNull()
+ expect(container.querySelector('button')).toBeNull()
+ expect(container.querySelector('[role]')).toBeNull()
+ expect(container.querySelector('[tabindex]')).toBeNull()
+ })
+
+ it('FE-MOB-RTROW-015: prints the drive and the car icon for a routed leg, falling back to the driving text', () => {
+ const bare = render( )
+ expect(screen.getByText('roadtrip.leg.driveText:210 km,2 h 55 min')).toBeInTheDocument()
+ bare.unmount()
+
+ const { container } = render( )
+
+ expect(screen.getByText('roadtrip.leg.driveText:210 km,2 h 40 min')).toBeInTheDocument()
+ expect(container.querySelector('.lucide-car-front')).not.toBeNull()
+ })
+
+ it('FE-MOB-RTROW-016: says the leg is pending while no segment has come back', () => {
+ render( )
+
+ expect(screen.getByText('roadtrip.leg.pending')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTROW-017: swaps the icon for a walked leg and for a plugin mode', () => {
+ const walk = render( )
+ expect(walk.container.querySelector('.lucide-footprints')).not.toBeNull()
+ walk.unmount()
+
+ const plugin = render( )
+ expect(plugin.container.querySelector('.lucide-zap')).not.toBeNull()
+ })
+})
+
+describe('RtDryRow', () => {
+ const idle = {
+ openFor: null, loading: false, outcome: null, results: [], offered: [],
+ ask: vi.fn(), close: vi.fn(),
+ } as unknown as RefuelSearch
+ const props = { intoLegKm: 82, chrome, electric: false, offline: false, refuel: idle, dayId: 7, legIndex: 1 }
+
+ it('FE-MOB-RTROW-018: names the fuel wording and the distance into the leg for a combustion car', () => {
+ render( )
+
+ expect(screen.getByText('roadtrip.refuel.dry')).toBeInTheDocument()
+ expect(screen.getByText('roadtrip.refuel.after:82 km')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'roadtrip.refuel.find' })).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTROW-019: swaps to the charging wording for an electric car', () => {
+ render( )
+
+ expect(screen.getByText('roadtrip.refuel.dryElectric')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'roadtrip.refuel.findElectric' })).toBeInTheDocument()
+ expect(screen.queryByText('roadtrip.refuel.dry')).toBeNull()
+ })
+
+ it('FE-MOB-RTROW-020: searches from the button', () => {
+ const onSearch = vi.fn()
+ render( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'roadtrip.refuel.find' }))
+ expect(onSearch).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-MOB-RTROW-021: disables the search offline and says why underneath it', () => {
+ const onSearch = vi.fn()
+ render( )
+
+ const button = screen.getByRole('button', { name: 'roadtrip.refuel.find' })
+ expect(button).toBeDisabled()
+ expect(screen.getByText('mobileTrip.rtSearchOffline')).toBeInTheDocument()
+
+ fireEvent.click(button)
+ expect(onSearch).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-RTROW-022: drops the button entirely without write permission, offline line included', () => {
+ render( )
+
+ expect(screen.queryByRole('button')).toBeNull()
+ expect(screen.queryByText('mobileTrip.rtSearchOffline')).toBeNull()
+ // The warning itself stays: it is information, not an action.
+ expect(screen.getByText('roadtrip.refuel.dry')).toBeInTheDocument()
+ })
+ const offer = (over: Record = {}) => ({
+ osm_id: 'n1', name: 'Shell Ebina', lat: 35.44, lng: 139.39, category: 'fuel',
+ poi_type: 'amenity=fuel', address: null, website: null, phone: null,
+ opening_hours: null, cuisine: null, source: 'openstreetmap',
+ offRouteKm: 0.4, alongKm: 70, spareKm: 22, ...over,
+ }) as unknown as RefuelCandidate
+
+ const answering = (over: Partial = {}) => ({
+ ...idle, openFor: '7:1', ...over,
+ } as unknown as RefuelSearch)
+
+ it('FE-MOB-RTROW-027: says it is looking while the search runs', () => {
+ render( )
+
+ expect(screen.getByText('roadtrip.refuel.looking')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTROW-028: offers what it found, with the detour and what is left in the tank', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText('Shell Ebina')).toBeInTheDocument()
+ expect(screen.getByText('roadtrip.poi.offRoute:400 m · roadtrip.refuel.spare:22 km')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTROW-029: an offer three legs away does not light up this band', () => {
+ // One search is open at a time and it names the dry point it belongs to.
+ render(
+ ,
+ )
+
+ expect(screen.queryByText('Shell Ebina')).toBeNull()
+ })
+
+ it('FE-MOB-RTROW-030: taking an offer goes through the planner, and never without permission', () => {
+ const onAccept = vi.fn()
+ const refuel = answering({ outcome: 'found', results: [offer()] })
+ const view = render( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'roadtrip.refuel.add:Shell Ebina' }))
+ expect(onAccept).toHaveBeenCalledWith(expect.objectContaining({ osm_id: 'n1' }))
+ view.unmount()
+
+ render( )
+ expect(screen.queryByRole('button', { name: 'roadtrip.refuel.add:Shell Ebina' })).toBeNull()
+ // The offer is still worth seeing: a reader can look up a station even if they
+ // cannot put it on the trip.
+ expect(screen.getByText('Shell Ebina')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTROW-031: three sentences for three different facts, and none of them "nothing here"', () => {
+ const none = render( )
+ expect(screen.getByText('roadtrip.refuel.none')).toBeInTheDocument()
+ none.unmount()
+
+ const cut = render( )
+ expect(screen.getByText('roadtrip.refuel.incomplete')).toBeInTheDocument()
+ cut.unmount()
+
+ render( )
+ // A request that failed never checked the stretch, so it must not read as empty.
+ expect(screen.getByText('roadtrip.refuel.failed')).toBeInTheDocument()
+ expect(screen.queryByText('roadtrip.refuel.none')).toBeNull()
+ })
+})
+
+describe('RtAutoRow', () => {
+ it('FE-MOB-RTROW-023: marks the end of a day with the moon and the time it stopped', () => {
+ const { container } = render( )
+
+ expect(screen.getByText('roadtrip.window.stop')).toBeInTheDocument()
+ expect(screen.getByText('22:00')).toBeInTheDocument()
+ expect(container.querySelector('.lucide-moon')).not.toBeNull()
+ })
+
+ it('FE-MOB-RTROW-024: marks the morning with the sunrise and no clock when there is none', () => {
+ const { container } = render( )
+
+ expect(screen.getByText('roadtrip.window.resume')).toBeInTheDocument()
+ expect(container.querySelector('.lucide-sunrise')).not.toBeNull()
+ expect(screen.queryByText('22:00')).toBeNull()
+ })
+})
+
+describe('RtSpillRow', () => {
+ it('FE-MOB-RTROW-025: names the day the night drive came from and when it set off', () => {
+ render(
+
+ Fuji Viewpoint
+ ,
+ )
+
+ expect(screen.getByText('roadtrip.spill.title:1')).toBeInTheDocument()
+ expect(screen.getByText('roadtrip.spill.departs:23:10')).toBeInTheDocument()
+ expect(screen.getByText('Fuji Viewpoint')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTROW-026: leaves the departure off when the night drive has no time', () => {
+ render( )
+
+ expect(screen.getByText('roadtrip.spill.title:1')).toBeInTheDocument()
+ expect(screen.queryByText(/roadtrip\.spill\.departs/)).toBeNull()
+ })
+})
diff --git a/client/tests/unit/mobile/trip/MRoadtripTab.test.tsx b/client/tests/unit/mobile/trip/MRoadtripTab.test.tsx
new file mode 100644
index 000000000..d3b7b58bb
--- /dev/null
+++ b/client/tests/unit/mobile/trip/MRoadtripTab.test.tsx
@@ -0,0 +1,475 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, within } from '../../../helpers/render'
+import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
+import MRoadtripTab from '../../../../src/mobile/screens/trip/roadtrip/MRoadtripTab'
+import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
+import type { Day, Place } from '../../../../src/types'
+import type { RoadtripDay, RoadtripRoutes, RouteSegment } from '@trek/shared/roadtrip'
+
+// FE-MOB-RTTAB-001 to FE-MOB-RTTAB-031
+
+// The preference store is a zustand slice keyed by user and trip; the tab only ever
+// reads two flags out of it, so the hook is the smaller seam.
+const mocks = vi.hoisted(() => ({
+ prefs: {} as Record,
+ swipe: {} as { onSelectDay: (id: number) => void; describeDay: (i: number, n: number) => string },
+}))
+
+vi.mock('../../../../src/hooks/useRoadtripSettings', () => ({
+ useRoadtripSettings: (select: (p: Record) => unknown) => select(mocks.prefs),
+}))
+
+// The swipe itself is the plan tab's, tested there; this keeps the real hook and only
+// records what the road trip tab hands it.
+vi.mock('../../../../src/mobile/screens/trip/plan/useMPlanDaySwipe', async importOriginal => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useMPlanDaySwipe: (config: Parameters[0]) => {
+ mocks.swipe = config as unknown as typeof mocks.swipe
+ return actual.useMPlanDaySwipe(config)
+ },
+ }
+})
+
+const DAYS = [
+ { id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null },
+ { id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: null },
+] as unknown as Day[]
+
+const PLACES = [
+ { id: 101, trip_id: 7, name: 'Fuji Viewpoint', lat: 35.36, lng: 138.73 },
+ { id: 103, trip_id: 7, name: 'Kyoto Station', lat: 34.98, lng: 135.75 },
+] as unknown as Place[]
+
+const seg = (distanceText: string, durationText: string): RouteSegment => ({
+ mid: [35.4, 138.6], from: [35.7, 139.8], to: [34.98, 135.75],
+ distance: 210_000, duration: 9_600,
+ walkingText: '42 h', drivingText: durationText, distanceText, durationText,
+})
+
+/**
+ * One stage with all five row kinds on it.
+ *
+ * The first stop is stored on day 1: it was reached after midnight, so it is drawn on
+ * this card behind a spill band while still belonging to the day before. That is the
+ * case the sheet id has to survive.
+ */
+function stage(over: Partial = {}): RoadtripDay {
+ return {
+ dayId: 2,
+ dayNumber: 2,
+ date: '2026-05-02',
+ title: null,
+ stops: [
+ {
+ assignmentId: 501, ownerDayId: 1, ownerIndex: 3, placeId: 101, name: 'Fuji Viewpoint',
+ lat: 35.36, lng: 138.73, time: null, dwellMinutes: 45,
+ legMode: null, incomingLegMode: null, stopType: null,
+ },
+ {
+ assignmentId: 502, ownerDayId: 2, ownerIndex: 0, placeId: 102, name: 'Shell Ebina',
+ lat: 35.44, lng: 139.39, time: null, dwellMinutes: 10,
+ legMode: null, incomingLegMode: null, stopType: 'fuel',
+ },
+ {
+ assignmentId: 503, ownerDayId: 2, ownerIndex: 1, placeId: 103, name: 'Kyoto Station',
+ lat: 34.98, lng: 135.75, time: null, dwellMinutes: null,
+ legMode: null, incomingLegMode: null, stopType: null,
+ },
+ {
+ assignmentId: 504, ownerDayId: 2, ownerIndex: 2, placeId: 104, name: 'Day end',
+ lat: 34.9, lng: 135.7, time: null, dwellMinutes: null,
+ legMode: null, incomingLegMode: null, stopType: null,
+ automaticNight: { phase: 'end', fromDayNumber: 2 },
+ },
+ ],
+ legs: [seg('62 km', '1 h'), seg('210 km', '2 h 40 min'), undefined],
+ schedule: {
+ entries: [
+ { arrival: '08:30', departure: '09:15', anchored: true, dayOffset: 0 },
+ { arrival: '10:05', departure: '10:15', anchored: false, dayOffset: 0 },
+ { arrival: '12:40', departure: null, anchored: false, dayOffset: 0 },
+ { arrival: '22:00', departure: null, anchored: false, dayOffset: 0 },
+ ],
+ warnings: [],
+ },
+ legVias: [[], [], []],
+ geometry: [],
+ distance: 412_000,
+ duration: 18_000,
+ driveWarnings: [{ index: 2, code: 'late', minutes: 25 }],
+ dayWarning: null,
+ spills: [{ at: 0, count: 1, fromDayNumber: 1, departure: '23:10', leg: undefined }],
+ dryPoints: [{ legIndex: 1, intoLegKm: 82, drivenMeters: 82_000, sinceKm: 520, lat: 35.2, lng: 137.5 }],
+ ...over,
+ } as unknown as RoadtripDay
+}
+
+function routes(over: Partial = {}): RoadtripRoutes {
+ return {
+ days: [stage()],
+ quietDays: [], lines: [], lineDays: [], accessLines: [], vias: [], segments: [],
+ totalDistance: 980_000, totalDuration: 54_000, totalStops: 4, loading: false,
+ ...over,
+ } as unknown as RoadtripRoutes
+}
+
+function planner(over: Partial = {}): TripPlanner {
+ return buildPlanner({
+ tripId: 7,
+ days: DAYS,
+ places: PLACES,
+ selectedDayId: 2,
+ roadtripRoutes: routes(),
+ askRefuel: vi.fn(),
+ ...over,
+ } as Partial)
+}
+
+function renderTab(p: TripPlanner = planner(), shell: MTripShellApi = buildShell()) {
+ const view = render( )
+ return { ...view, planner: p, shell }
+}
+
+/** Freezes the clock and hands back the stage's own date, whatever the runner's zone. */
+function freezeAt(hour: number, minute: number): string {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date(2026, 4, 2, hour, minute))
+ return new Date().toISOString().slice(0, 10)
+}
+
+function today(date: string): Partial {
+ return {
+ days: DAYS.map(d => (d.id === 2 ? { ...d, date } : d)) as unknown as Day[],
+ roadtripRoutes: routes({ days: [stage({ date })] }),
+ }
+}
+
+describe('MRoadtripTab', () => {
+ beforeEach(() => {
+ mocks.prefs = {}
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ describe('list half', () => {
+ it('FE-MOB-RTTAB-001: heads the stage with its departure, its arrival, the day total and the stop count', () => {
+ renderTab()
+
+ // Die ABFAHRT des ersten Stopps (09:15), nicht seine Ankunft (08:30): die beiden
+ // trennt der Aufenthalt, und an einem Morgen im Hotel die ganze Nacht.
+ const leave = screen.getByText('roadtrip.stay.leave').parentElement as HTMLElement
+ expect(within(leave).getByText('09:15')).toBeInTheDocument()
+ const arrive = screen.getByText('roadtrip.stay.arrive').parentElement as HTMLElement
+ expect(within(arrive).getByText('12:40')).toBeInTheDocument()
+
+ expect(screen.getByText('roadtrip.leg.driveText:412 km,5 h')).toBeInTheDocument()
+ expect(screen.getByText('roadtrip.day.stopCount:2')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTTAB-002: dates the head card, and falls back to the day number without a date', () => {
+ renderTab()
+ expect(screen.getByText(/May 2/)).toBeInTheDocument()
+
+ const undated = planner({ days: DAYS.map(d => ({ ...d, date: null })) as unknown as Day[] })
+ const second = render( )
+ expect(within(second.container).getByText('roadtrip.day:2')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTTAB-003: draws one row per stop, in the order of the model', () => {
+ renderTab()
+
+ const names = screen.getAllByText(/^(Fuji Viewpoint|Shell Ebina|Kyoto Station)$/).map(n => n.textContent)
+ expect(names).toEqual(['Fuji Viewpoint', 'Shell Ebina', 'Kyoto Station'])
+ // The petrol stop takes no number, so the destination after it is still 2.
+ expect(screen.getByText('1')).toBeInTheDocument()
+ expect(screen.getByText('2')).toBeInTheDocument()
+ expect(screen.queryByText('3')).toBeNull()
+ // And the automatic night is a band, not a stop.
+ expect(screen.queryByText('Day end')).toBeNull()
+ expect(screen.getByText('roadtrip.window.stop')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTTAB-004: opens a stop on the day it is stored on, not the card it is drawn on', () => {
+ const { shell } = renderTab()
+
+ fireEvent.click(screen.getByText('Fuji Viewpoint'))
+
+ expect(shell.openSheet).toHaveBeenCalledWith('rtstop', { dayId: 1, assignmentId: 501 })
+ })
+
+ it('FE-MOB-RTTAB-005: opens a stop of this day with this day', () => {
+ const { shell } = renderTab()
+
+ fireEvent.click(screen.getByText('Kyoto Station'))
+
+ expect(shell.openSheet).toHaveBeenCalledWith('rtstop', { dayId: 2, assignmentId: 503 })
+ })
+
+ it('FE-MOB-RTTAB-006: writes the day warning out with the sentence the desktop hides in a tooltip', () => {
+ const over = stage({ dayWarning: { code: 'dayDriving', minutes: 620, limitMinutes: 540 } })
+ renderTab(planner({ roadtripRoutes: routes({ days: [over] }) }))
+
+ expect(screen.getByText('roadtrip.limit.dayOver:1 h 20 min')).toBeInTheDocument()
+ expect(screen.getByText('roadtrip.limit.hint')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTTAB-007: leaves the day warning off a stage that stays inside its limit', () => {
+ renderTab()
+
+ expect(screen.queryByText(/roadtrip\.limit\.dayOver/)).toBeNull()
+ expect(screen.queryByText('roadtrip.limit.hint')).toBeNull()
+ })
+
+ it('FE-MOB-RTTAB-008: shows the empty stage without a summary card full of zeroes', () => {
+ renderTab(planner({ roadtripRoutes: routes({ days: [] }) }))
+
+ expect(screen.getByText('roadtrip.empty.title')).toBeInTheDocument()
+ expect(screen.getByText('mobileTrip.rtPlanOnDesktop')).toBeInTheDocument()
+ expect(screen.queryByText('roadtrip.stay.leave')).toBeNull()
+ expect(screen.queryByText(/roadtrip\.day\.stopCount/)).toBeNull()
+ })
+
+ it('FE-MOB-RTTAB-009: shows the partial hint instead of the empty state while the round is still running', () => {
+ renderTab(planner({ roadtripRoutes: routes({ days: [], loading: true }) }))
+
+ expect(screen.getByText('roadtrip.summary.partial')).toBeInTheDocument()
+ expect(screen.queryByText('roadtrip.empty.title')).toBeNull()
+ expect(screen.queryByText('mobileTrip.rtPlanOnDesktop')).toBeNull()
+ })
+
+ it('FE-MOB-RTTAB-010: paints the day tint only once the day colours are on', () => {
+ const plain = renderTab()
+ expect(plain.container.querySelector('span.h-2.w-2.rounded-full')).toBeNull()
+ plain.unmount()
+
+ mocks.prefs = { roadtrip_day_colors: true }
+ const tinted = renderTab()
+ const dot = tinted.container.querySelector('span.h-2.w-2.rounded-full') as HTMLElement
+ // dayColor(2).line, the second of the eight, see dayColors.ts.
+ expect(dot).toHaveStyle({ background: '#ff9f0a' })
+ })
+
+ it('FE-MOB-RTTAB-011: dashes the two figures for a stage the routing could not time', () => {
+ const untimed = stage({
+ schedule: { entries: [0, 1, 2, 3].map(() => ({ arrival: null, departure: null, anchored: false, dayOffset: 0 })), warnings: [] },
+ } as unknown as Partial)
+ renderTab(planner({ roadtripRoutes: routes({ days: [untimed] }) }))
+
+ const leave = screen.getByText('roadtrip.stay.leave').parentElement as HTMLElement
+ expect(within(leave).getByText('-')).toBeInTheDocument()
+ const arrive = screen.getByText('roadtrip.stay.arrive').parentElement as HTMLElement
+ expect(within(arrive).getByText('-')).toBeInTheDocument()
+ // The chain itself still stands, it just carries no clocks.
+ expect(screen.getByText('Kyoto Station')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTTAB-012: says the day total is still partial while the distance is zero', () => {
+ renderTab(planner({ roadtripRoutes: routes({ days: [stage({ distance: 0 })] }) }))
+
+ expect(screen.getByText('roadtrip.summary.partial')).toBeInTheDocument()
+ expect(screen.queryByText(/roadtrip\.leg\.driveText:412/)).toBeNull()
+ })
+ })
+
+ describe('up next', () => {
+ it('FE-MOB-RTTAB-013: counts down to the next stop still ahead, on the day it actually is', () => {
+ const date = freezeAt(7, 0)
+ renderTab(planner(today(date)))
+
+ const card = screen.getByText('mobileTrip.upNext').closest('section') as HTMLElement
+ expect(within(card).getByText('Fuji Viewpoint')).toBeInTheDocument()
+ expect(within(card).getByText('mobileTrip.inCountdown:1 h 30 min')).toBeInTheDocument()
+ expect(within(card).getByText('08:30')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTTAB-014: shows no countdown on a day that is not today', () => {
+ const date = freezeAt(7, 0)
+ const shifted = new Date(`${date}T00:00:00Z`)
+ shifted.setUTCDate(shifted.getUTCDate() + 7)
+ renderTab(planner(today(shifted.toISOString().slice(0, 10))))
+
+ // Same clock, same stage, a week further on: the countdown is about the date.
+ expect(screen.queryByText('mobileTrip.upNext')).toBeNull()
+ expect(screen.getByText('roadtrip.stay.leave')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTTAB-015: shows no countdown once the last stop is behind you', () => {
+ const date = freezeAt(23, 30)
+ renderTab(planner(today(date)))
+
+ expect(screen.queryByText('mobileTrip.upNext')).toBeNull()
+ })
+
+ it('FE-MOB-RTTAB-016: opens the stop from the countdown card', () => {
+ const date = freezeAt(7, 0)
+ const { shell } = renderTab(planner(today(date)))
+
+ fireEvent.click(screen.getByText('mobileTrip.upNext'))
+
+ expect(shell.openSheet).toHaveBeenCalledWith('rtstop', { dayId: 1, assignmentId: 501 })
+ })
+
+ it('FE-MOB-RTTAB-017: sends the next stop to the map half and switches to it', () => {
+ const date = freezeAt(7, 0)
+ const p = planner(today(date))
+ const { shell } = renderTab(p)
+
+ fireEvent.click(screen.getByLabelText('mobileTrip.showOnMap'))
+
+ expect(p.setSelectedPlaceId).toHaveBeenCalledWith(101)
+ expect(shell.toggleRtView).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-MOB-RTTAB-018: leaves the navigate link dead when the stop has no place row behind it', () => {
+ const date = freezeAt(7, 0)
+ const enabled = renderTab(planner(today(date)))
+ expect(screen.getByText('places.navigate').closest('a')).toHaveAttribute('aria-disabled', 'false')
+ enabled.unmount()
+
+ renderTab(planner({ ...today(date), places: [] }))
+ const link = screen.getByText('places.navigate').closest('a') as HTMLElement
+ expect(link).toHaveAttribute('aria-disabled', 'true')
+ expect(link).toHaveAttribute('href', '#')
+ })
+ })
+
+ describe('refuel', () => {
+ it('FE-MOB-RTTAB-019: asks for a refuel with the dry point of the leg the band sits on', () => {
+ const p = planner()
+ renderTab(p)
+
+ fireEvent.click(screen.getByRole('button', { name: 'roadtrip.refuel.find' }))
+
+ expect(p.askRefuel).toHaveBeenCalledWith(2, expect.objectContaining({ legIndex: 1, intoLegKm: 82 }))
+ })
+
+ it('FE-MOB-RTTAB-020: offers no refuel search to a member who cannot edit the day', () => {
+ renderTab(planner({ can: vi.fn(() => false) }))
+
+ expect(screen.getByText('roadtrip.refuel.dry')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'roadtrip.refuel.find' })).toBeNull()
+ })
+
+ it('FE-MOB-RTTAB-021: reads the vehicle out of the trip preferences', () => {
+ mocks.prefs = { roadtrip_vehicle: 'electric' }
+ renderTab()
+
+ expect(screen.getByText('roadtrip.refuel.dryElectric')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'roadtrip.refuel.findElectric' })).toBeInTheDocument()
+ })
+ })
+
+ describe('map half', () => {
+ const mapShell = () => buildShell({ rtView: 'map' })
+
+ it('FE-MOB-RTTAB-022: renders the stage bar and nothing that would cover the map', () => {
+ const { container } = renderTab(planner(), mapShell())
+
+ expect((container.firstChild as HTMLElement).className).toContain('pointer-events-none')
+ expect(screen.queryByText('roadtrip.stay.leave')).toBeNull()
+ expect(screen.queryByText('Shell Ebina')).toBeNull()
+ expect(screen.queryByText('roadtrip.refuel.dry')).toBeNull()
+ expect(screen.queryByText('roadtrip.window.stop')).toBeNull()
+ })
+
+ it('FE-MOB-RTTAB-023: names the last stop of the day, its count, its arrival and its distance', () => {
+ renderTab(planner(), mapShell())
+
+ const bar = screen.getByRole('button', { name: /Kyoto Station/ })
+ expect(within(bar).getByText('Kyoto Station')).toBeInTheDocument()
+ expect(within(bar).getByText('roadtrip.day.stopCount:2 · 22:00')).toBeInTheDocument()
+ expect(within(bar).getByText('412 km')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTTAB-024: drops the arrival from the bar when the day has none, and the pending distance with it', () => {
+ const untimed = stage({
+ distance: 0,
+ schedule: { entries: [0, 1, 2, 3].map(() => ({ arrival: null, departure: null, anchored: false, dayOffset: 0 })), warnings: [] },
+ } as unknown as Partial)
+ renderTab(planner({ roadtripRoutes: routes({ days: [untimed] }) }), mapShell())
+
+ const bar = screen.getByRole('button', { name: /roadtrip.leg.pending/ })
+ expect(within(bar).getByText('roadtrip.day.stopCount:2')).toBeInTheDocument()
+ expect(within(bar).getByText('roadtrip.leg.pending')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTTAB-025: switches back to the chain from the bar', () => {
+ const { shell } = renderTab(planner(), mapShell())
+
+ fireEvent.click(screen.getByRole('button', { name: /Kyoto Station/ }))
+
+ expect(shell.toggleRtView).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-MOB-RTTAB-026: falls back to the whole drive when no day is picked, and offers nothing to tap', () => {
+ renderTab(planner({ selectedDayId: null }), mapShell())
+
+ expect(screen.getByText('roadtrip.title')).toBeInTheDocument()
+ expect(screen.getByText('980 km')).toBeInTheDocument()
+ // Not a dead button: with no stage there is nothing for a tap to open, so the
+ // whole-drive line is a plain block. The search bar above it stays a button.
+ expect(screen.getByText('roadtrip.title').closest('button')).toBeNull()
+ })
+ })
+
+ describe('the corridor search', () => {
+ const mapShell = () => buildShell({ rtView: 'map' })
+
+ it('FE-MOB-RTTAB-028: the search bar stands in the same band on both halves', () => {
+ const list = renderTab()
+ const inList = screen.getByText('roadtrip.poi.title').closest('div[class*="top-[calc"]')
+ expect(inList).not.toBeNull()
+ list.unmount()
+
+ renderTab(planner(), mapShell())
+ const onMap = screen.getByText('roadtrip.poi.title').closest('div[class*="top-[calc"]')
+ // Literally the same offset: one stage seen two ways, and a control that jumps
+ // between them is a control you have to find twice.
+ expect(onMap?.className).toBe(inList?.className)
+ })
+
+ it('FE-MOB-RTTAB-029: it opens the search sheet', () => {
+ const { shell } = renderTab()
+
+ fireEvent.click(screen.getByText('roadtrip.poi.title'))
+ expect(shell.openSheet).toHaveBeenCalledWith('rtsearch')
+ })
+
+ it('FE-MOB-RTTAB-030: the chain starts below the bar rather than under it', () => {
+ const { container } = renderTab()
+
+ const scroller = container.querySelector('[class*="overflow-y-auto"]') as HTMLElement
+ // 96px band plus a 44px bar plus its gap. A padding left at the old 102 would
+ // put the head card's first line behind the glass.
+ expect(scroller.className).toContain('pt-[calc(var(--m-safe-top,12px)+150px)]')
+ })
+ it('FE-MOB-RTTAB-031: no stage, no search bar, and the chain keeps its old top', () => {
+ // Without a stage the corridor falls back to the trip's first routed day, which
+ // is not the one on screen: every distance it answered with would be measured
+ // against a road nobody is looking at.
+ const { container } = renderTab(planner({ selectedDayId: null }))
+
+ expect(screen.queryByText('roadtrip.poi.title')).toBeNull()
+ const scroller = container.querySelector('[class*="overflow-y-auto"]') as HTMLElement
+ expect(scroller.className).toContain('pt-[calc(var(--m-safe-top,12px)+102px)]')
+ })
+ })
+
+ describe('day swipe', () => {
+ it('FE-MOB-RTTAB-027: steps the stage without refitting the map, and announces the day it landed on', () => {
+ const p = planner()
+ renderTab(p)
+
+ mocks.swipe.onSelectDay(1)
+
+ // The second argument is skipFit: the map stays where it is, the stage's own
+ // focus points frame it.
+ expect(p.handleSelectDay).toHaveBeenCalledWith(1, true)
+ expect(mocks.swipe.describeDay(0, 2)).toBe('mobileTrip.dayAnnounce:1,2')
+ })
+ })
+})
diff --git a/client/tests/unit/mobile/trip/MRtCorridorBar.test.tsx b/client/tests/unit/mobile/trip/MRtCorridorBar.test.tsx
new file mode 100644
index 000000000..2444098f9
--- /dev/null
+++ b/client/tests/unit/mobile/trip/MRtCorridorBar.test.tsx
@@ -0,0 +1,96 @@
+import { describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen } from '../../../helpers/render'
+import { buildPlanner } from '../../../helpers/mobileTrip'
+import MRtCorridorBar from '../../../../src/mobile/screens/trip/roadtrip/MRtCorridorBar'
+import type { MRtCorridorController } from '../../../../src/mobile/screens/trip/roadtrip/useMRtCorridor'
+import type { CorridorPoi } from '../../../../src/components/Roadtrip/useCorridorPois'
+
+// FE-MOB-RTBAR-001 to FE-MOB-RTBAR-009
+
+const hits = (count: number) =>
+ Array.from({ length: count }, (_, i) => ({ osm_id: `h${i}` })) as unknown as CorridorPoi[]
+
+function corridor(over: Partial = {}): MRtCorridorController {
+ return {
+ categories: ['fuel'], toggleCategory: vi.fn(),
+ reach: 'ahead', setReach: vi.fn(), reachKm: 50, anchored: false,
+ run: vi.fn(), clear: vi.fn(), canSearch: true,
+ loading: false, progress: { done: 0, total: 0 }, hits: [], answered: false,
+ offline: false, error: false, capped: false, failedAreas: 0, truncatedAreas: 0,
+ ...over,
+ }
+}
+
+function renderBar(over: Partial = {}) {
+ const c = corridor(over)
+ const onOpen = vi.fn()
+ const view = render( )
+ return { ...view, corridor: c, onOpen }
+}
+
+describe('MRtCorridorBar', () => {
+ it('FE-MOB-RTBAR-001: idle it names the question and how far the next search reaches', () => {
+ renderBar()
+
+ expect(screen.getByText('roadtrip.poi.title')).toBeInTheDocument()
+ expect(screen.getByText('mobileTrip.rtReachAhead:50 km')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTBAR-002: a whole-stage search says so instead of quoting a distance', () => {
+ renderBar({ reach: 'stage' })
+
+ expect(screen.getByText('roadtrip.poi.wholeDay')).toBeInTheDocument()
+ expect(screen.queryByText('mobileTrip.rtReachAhead:50 km')).toBeNull()
+ })
+
+ it('FE-MOB-RTBAR-003: idle there is nothing to clear, so no second button', () => {
+ renderBar()
+
+ // The bar is one target until there is an answer or a run to stop. A permanently
+ // present X would be a control that does nothing most of the time.
+ expect(screen.getAllByRole('button')).toHaveLength(1)
+ })
+
+ it('FE-MOB-RTBAR-004: running it counts the boxes and offers to stop', () => {
+ const { corridor: c } = renderBar({ loading: true, progress: { done: 2, total: 5 } })
+
+ expect(screen.getByText('roadtrip.poi.searching:2,5')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'common.cancel' }))
+ expect(c.clear).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-MOB-RTBAR-005: answered it counts what is on the way and offers to drop it', () => {
+ const { corridor: c } = renderBar({ answered: true, hits: hits(7) })
+
+ expect(screen.getByText('roadtrip.poi.found:7')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'roadtrip.poi.clearResults' }))
+ expect(c.clear).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-MOB-RTBAR-006: an answer of none still counts, rather than looking unasked', () => {
+ renderBar({ answered: true, hits: [] })
+
+ expect(screen.getByText('roadtrip.poi.found:0')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTBAR-007: offline it says why instead of pretending it can search', () => {
+ renderBar({ offline: true, canSearch: false })
+
+ expect(screen.getByText('mobileTrip.rtSearchOffline')).toBeInTheDocument()
+ expect(screen.queryByText('roadtrip.poi.title')).toBeNull()
+ })
+
+ it('FE-MOB-RTBAR-008: the bar itself opens the sheet', () => {
+ const { onOpen } = renderBar()
+
+ fireEvent.click(screen.getByText('roadtrip.poi.title'))
+ expect(onOpen).toHaveBeenCalledTimes(1)
+ })
+ it('FE-MOB-RTBAR-009: a failed search is not a count of nothing', () => {
+ renderBar({ answered: true, error: true, hits: [] })
+
+ expect(screen.getByText('roadtrip.poi.failed')).toBeInTheDocument()
+ // "0 on the way" after a search that never checked reads as an empty stretch of road.
+ expect(screen.queryByText('roadtrip.poi.found:0')).toBeNull()
+ })
+})
diff --git a/client/tests/unit/mobile/trip/MRtCorridorSheet.test.tsx b/client/tests/unit/mobile/trip/MRtCorridorSheet.test.tsx
new file mode 100644
index 000000000..a48bc9eaa
--- /dev/null
+++ b/client/tests/unit/mobile/trip/MRtCorridorSheet.test.tsx
@@ -0,0 +1,188 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen } from '../../../helpers/render'
+import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
+import MRtCorridorSheet from '../../../../src/mobile/screens/trip/roadtrip/MRtCorridorSheet'
+import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
+import type { MRtCorridorController } from '../../../../src/mobile/screens/trip/roadtrip/useMRtCorridor'
+import type { CorridorPoi } from '../../../../src/components/Roadtrip/useCorridorPois'
+
+// FE-MOB-RTSRCH-001 to FE-MOB-RTSRCH-015
+
+// The controller is exercised on its own in useMRtCorridor.test; here the sheet is
+// the unit, so what it reads is handed to it rather than derived from a planner.
+const mocks = vi.hoisted(() => ({ corridor: {} as MRtCorridorController }))
+vi.mock('../../../../src/mobile/screens/trip/roadtrip/useMRtCorridor', () => ({
+ useMRtCorridor: () => mocks.corridor,
+}))
+
+const hit = (over: Partial = {}): CorridorPoi => ({
+ osm_id: 'n1', name: 'Shell Ebina', lat: 35.44, lng: 139.39,
+ category: 'fuel', poi_type: 'amenity=fuel',
+ address: null, website: null, phone: null, opening_hours: null, cuisine: null,
+ source: 'openstreetmap', offRouteKm: 0.4, alongKm: 82,
+ ...over,
+} as unknown as CorridorPoi)
+
+function corridor(over: Partial = {}): MRtCorridorController {
+ return {
+ categories: ['fuel'], toggleCategory: vi.fn(),
+ reach: 'ahead', setReach: vi.fn(), reachKm: 50, anchored: false,
+ run: vi.fn(), clear: vi.fn(), canSearch: true,
+ loading: false, progress: { done: 0, total: 0 }, hits: [], answered: false,
+ offline: false, error: false, capped: false, failedAreas: 0, truncatedAreas: 0,
+ ...over,
+ }
+}
+
+function renderSheet(
+ over: Partial = {},
+ p: Partial = {},
+ shellOver: Partial = {},
+) {
+ mocks.corridor = corridor(over)
+ const planner = buildPlanner({ can: () => true, ...p } as Partial)
+ const shell = buildShell({ sheet: { id: 'rtsearch' }, ...shellOver })
+ const view = render( )
+ return { ...view, planner, shell, corridor: mocks.corridor }
+}
+
+beforeEach(() => { mocks.corridor = corridor() })
+
+describe('MRtCorridorSheet', () => {
+ it('FE-MOB-RTSRCH-001: offers every corridor category, with the active ones pressed', () => {
+ renderSheet({ categories: ['fuel', 'charging'] })
+
+ // Several at once, not one at a time: the kinds ride one query per box, so a
+ // second category is free and a picker that forces one is three searches.
+ expect(screen.getByText('roadtrip.poi.fuel')).toBeInTheDocument()
+ expect(screen.getByText('roadtrip.poi.charging')).toBeInTheDocument()
+ expect(screen.getByText('roadtrip.poi.rest')).toBeInTheDocument()
+ expect(screen.getByText('poi.cat.hotels')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTSRCH-002: tapping a category toggles it', () => {
+ const { corridor: c } = renderSheet()
+
+ fireEvent.click(screen.getByText('roadtrip.poi.charging'))
+ expect(c.toggleCategory).toHaveBeenCalledWith('charging')
+ })
+
+ it('FE-MOB-RTSRCH-003: the two reaches are the whole of the narrowing', () => {
+ renderSheet()
+
+ expect(screen.getByText('mobileTrip.rtReach')).toBeInTheDocument()
+ expect(screen.getByText('mobileTrip.rtReachAhead:50 km')).toBeInTheDocument()
+ expect(screen.getByText('roadtrip.poi.wholeDay')).toBeInTheDocument()
+ // No corridor width, no section, no plug type, no minimum power: four ways of
+ // narrowing an answer already on screen are a desk activity.
+ expect(screen.queryByText('roadtrip.poi.within')).toBeNull()
+ expect(screen.queryByText('roadtrip.poi.anySocket')).toBeNull()
+ })
+
+ it('FE-MOB-RTSRCH-004: picking a reach goes through the controller', () => {
+ const { corridor: c } = renderSheet()
+
+ fireEvent.click(screen.getByText('roadtrip.poi.wholeDay'))
+ expect(c.setReach).toHaveBeenCalledWith('stage')
+ })
+
+ it('FE-MOB-RTSRCH-005: says where "ahead" starts, because that is what it means', () => {
+ const fromStart = renderSheet()
+ expect(screen.getByText('mobileTrip.rtFromStart')).toBeInTheDocument()
+ fromStart.unmount()
+
+ renderSheet({ anchored: true })
+ expect(screen.getByText('mobileTrip.rtFromNext')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTSRCH-006: the search button asks, and counts the boxes while it runs', () => {
+ const idle = renderSheet()
+ fireEvent.click(screen.getByRole('button', { name: /roadtrip.poi.search/ }))
+ expect(idle.corridor.run).toHaveBeenCalledTimes(1)
+ idle.unmount()
+
+ renderSheet({ loading: true, progress: { done: 1, total: 3 } })
+ expect(screen.getByRole('button', { name: /roadtrip.poi.searching:1,3/ })).toBeDisabled()
+ })
+
+ it('FE-MOB-RTSRCH-007: a search nothing can answer is a dead button, so it is disabled', () => {
+ renderSheet({ canSearch: false, categories: [] })
+
+ expect(screen.getByRole('button', { name: /roadtrip.poi.search/ })).toBeDisabled()
+ expect(screen.getByText('roadtrip.poi.empty')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTSRCH-008: offline it says so rather than counting an empty answer', () => {
+ renderSheet({ offline: true, canSearch: false, answered: true })
+
+ expect(screen.getByText('mobileTrip.rtSearchOffline')).toBeInTheDocument()
+ expect(screen.queryByText('mobileTrip.rtNoneAhead')).toBeNull()
+ })
+
+ it('FE-MOB-RTSRCH-009: a failed search is not an empty stretch of road', () => {
+ renderSheet({ error: true, answered: true })
+
+ expect(screen.getByText('roadtrip.poi.failed')).toBeInTheDocument()
+ expect(screen.queryByText('mobileTrip.rtNoneAhead')).toBeNull()
+ })
+
+ it('FE-MOB-RTSRCH-010: nothing ahead points at the second tap; nothing on the stage does not', () => {
+ const ahead = renderSheet({ answered: true, hits: [] })
+ expect(screen.getByText('mobileTrip.rtNoneAhead')).toBeInTheDocument()
+ ahead.unmount()
+
+ renderSheet({ answered: true, hits: [], reach: 'stage' })
+ expect(screen.getByText('mobileTrip.rtNoneOnStage')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTSRCH-011: says what the search could not cover, so a short list is not read as a complete one', () => {
+ renderSheet({ answered: true, hits: [hit()], capped: true, failedAreas: 2, truncatedAreas: 1 })
+
+ expect(screen.getByText('roadtrip.poi.capped')).toBeInTheDocument()
+ expect(screen.getByText('roadtrip.poi.partial:2')).toBeInTheDocument()
+ // The phone's own wording: the desk's ends in "narrow the corridor", and the
+ // corridor width is a control this screen does not have.
+ expect(screen.getByText('mobileTrip.rtTruncated:1')).toBeInTheDocument()
+ expect(screen.queryByText('roadtrip.poi.truncated:1')).toBeNull()
+ })
+
+ it('FE-MOB-RTSRCH-012: a hit says what it is and where on the drive it sits', () => {
+ renderSheet({ answered: true, hits: [hit()] })
+
+ expect(screen.getByText('Shell Ebina')).toBeInTheDocument()
+ expect(screen.getByText('roadtrip.poi.alongRoute:82 km · roadtrip.poi.offRoute:400 m')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTSRCH-013: tapping a hit shows it on the map, which means closing and switching', () => {
+ const { planner, shell } = renderSheet({ answered: true, hits: [hit()] })
+
+ fireEvent.click(screen.getByText('Shell Ebina'))
+
+ expect(shell.closeSheet).toHaveBeenCalledTimes(1)
+ expect(planner.focusRoadtripPoint).toHaveBeenCalledWith(35.44, 139.39)
+ expect(shell.toggleRtView).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-MOB-RTSRCH-014: adding a hit goes through the road trip door, and never without permission', () => {
+ const allowed = renderSheet({ answered: true, hits: [hit()] })
+ fireEvent.click(screen.getByRole('button', { name: 'roadtrip.poi.add' }))
+ // The planner's own handler: it reads the distance the hit carries and sends it to
+ // the draft sheet with the stop kind and the position in the chain worked out.
+ expect(allowed.planner.handlePoiClick).toHaveBeenCalledWith(expect.objectContaining({ osm_id: 'n1' }))
+ expect(allowed.shell.closeSheet).toHaveBeenCalledTimes(1)
+ allowed.unmount()
+
+ renderSheet({ answered: true, hits: [hit()] }, { can: () => false })
+ expect(screen.queryByRole('button', { name: 'roadtrip.poi.add' })).toBeNull()
+ })
+ it('FE-MOB-RTSRCH-015: a very long answer stops at sixty rows and says how many there were', () => {
+ const many = Array.from({ length: 84 }, (_, i) => hit({ osm_id: `n${i}`, name: `Stop ${i}` }))
+ renderSheet({ answered: true, hits: many })
+
+ expect(screen.getByText('Stop 59')).toBeInTheDocument()
+ expect(screen.queryByText('Stop 60')).toBeNull()
+ // Silently cutting the list would read as the whole answer; the rest are pins on
+ // the map behind this sheet.
+ expect(screen.getByText('roadtrip.poi.foundFiltered:60,84')).toBeInTheDocument()
+ })
+})
diff --git a/client/tests/unit/mobile/trip/MRtDraftSheet.test.tsx b/client/tests/unit/mobile/trip/MRtDraftSheet.test.tsx
new file mode 100644
index 000000000..747586673
--- /dev/null
+++ b/client/tests/unit/mobile/trip/MRtDraftSheet.test.tsx
@@ -0,0 +1,172 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import MRtDraftSheet from '../../../../src/mobile/screens/trip/roadtrip/MRtDraftSheet'
+import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
+import type { RoadtripStopDraft } from '../../../../src/components/Roadtrip/RoadtripStopPopup'
+import { buildPlanner } from '../../../helpers/mobileTrip'
+import { resetAllStores } from '../../../helpers/store'
+import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
+
+// FE-MOB-RTDRAFT-001 to FE-MOB-RTDRAFT-015
+//
+// The sheet takes its copy from `planner.t`, which the fixture echoes, so the
+// assertions are on keys rather than on the English wording. Durations come out
+// of formatDurationShort and are real text.
+
+function draftOf(category: string, over: Partial = {}): RoadtripStopDraft {
+ return {
+ poi: {
+ osm_id: `node/${category}`,
+ name: 'Aral Dammtor',
+ lat: 53.5,
+ lng: 9.9,
+ category,
+ poi_type: category,
+ address: 'B4, Hamburg',
+ website: null,
+ phone: null,
+ opening_hours: null,
+ cuisine: null,
+ offRouteKm: 0.2,
+ alongKm: 43,
+ },
+ dayId: 11,
+ dayNumber: 1,
+ position: 2,
+ ...over,
+ } as RoadtripStopDraft
+}
+
+function renderSheet(overrides: Record = {}) {
+ const planner = buildPlanner({
+ stopDraft: draftOf('fuel'),
+ setStopDraft: vi.fn(),
+ saveStopDraft: vi.fn(async () => undefined),
+ stopDraftDuplicate: null,
+ ...overrides,
+ } as unknown as Partial)
+ render( )
+ return { planner }
+}
+
+const kind = (key: string) => screen.getByRole('button', { name: `roadtrip.poi.${key}` })
+const dwell = (label: string) => screen.getByRole('button', { name: label })
+const isActive = (el: HTMLElement) => el.classList.contains('bg-m-act')
+
+describe('MRtDraftSheet', () => {
+ beforeEach(() => {
+ resetAllStores()
+ })
+
+ it('FE-MOB-RTDRAFT-001: renders nothing while no draft is in play', () => {
+ renderSheet({ stopDraft: null })
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTDRAFT-002: opens as soon as the planner carries a draft, whoever set it', () => {
+ renderSheet()
+ expect(screen.getByRole('dialog', { name: 'Aral Dammtor' })).toBeInTheDocument()
+ expect(screen.getByText('B4, Hamburg')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTDRAFT-003: says which day and which position the stop lands on, counted from one', () => {
+ renderSheet()
+ expect(screen.getByText('roadtrip.stop.landsOn:1,3')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTDRAFT-004: preselects the kind from the hit and the stay from that kind', () => {
+ renderSheet()
+ expect(isActive(kind('fuel'))).toBe(true)
+ expect(isActive(dwell('10 min'))).toBe(true)
+ })
+
+ it('FE-MOB-RTDRAFT-005: a charging hit is a charge, not a fuel stop, and takes its own half hour', () => {
+ renderSheet({ stopDraft: draftOf('charging') })
+ expect(isActive(kind('charging'))).toBe(true)
+ expect(isActive(dwell('30 min'))).toBe(true)
+ })
+
+ it('FE-MOB-RTDRAFT-006: an unknown category leaves the kind open and falls back to 30 minutes', () => {
+ renderSheet({ stopDraft: draftOf('pharmacy') })
+ for (const key of ['fuel', 'charging', 'rest', 'food', 'sights']) {
+ expect(isActive(kind(key))).toBe(false)
+ }
+ expect(isActive(dwell('30 min'))).toBe(true)
+ })
+
+ it('FE-MOB-RTDRAFT-007: changing the kind pulls its usual length along', () => {
+ renderSheet()
+ fireEvent.click(kind('rest'))
+ expect(isActive(kind('rest'))).toBe(true)
+ expect(isActive(dwell('20 min'))).toBe(true)
+ expect(isActive(dwell('10 min'))).toBe(false)
+ })
+
+ it('FE-MOB-RTDRAFT-008: tapping the kind already chosen leaves a hand-set length standing', () => {
+ renderSheet()
+ fireEvent.click(kind('charging'))
+ expect(isActive(dwell('30 min'))).toBe(true)
+ fireEvent.click(dwell('45 min'))
+ fireEvent.click(kind('charging'))
+ expect(isActive(dwell('45 min'))).toBe(true)
+ expect(isActive(dwell('30 min'))).toBe(false)
+ })
+
+ it('FE-MOB-RTDRAFT-009: saving hands the kind and the length to the planner', async () => {
+ const { planner } = renderSheet()
+ fireEvent.click(kind('charging'))
+ fireEvent.click(dwell('45 min'))
+ fireEvent.click(screen.getByRole('button', { name: 'roadtrip.poi.add' }))
+ await waitFor(() => expect(planner.saveStopDraft).toHaveBeenCalledWith({ stopType: 'charging', dwellMinutes: 45 }))
+ })
+
+ it('FE-MOB-RTDRAFT-010: an edit answers with what the stop already says, and saves rather than adds', async () => {
+ const { planner } = renderSheet({
+ stopDraft: draftOf('fuel', { editing: { placeId: 908, dwellMinutes: 20, stopType: 'rest_area' } }),
+ })
+ expect(isActive(kind('rest'))).toBe(true)
+ expect(isActive(dwell('20 min'))).toBe(true)
+ expect(screen.queryByRole('button', { name: 'roadtrip.poi.add' })).not.toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'common.save' }))
+ await waitFor(() => expect(planner.saveStopDraft).toHaveBeenCalledWith({ stopType: 'rest_area', dwellMinutes: 20 }))
+ })
+
+ it('FE-MOB-RTDRAFT-011: warns about a stop this trip already has', () => {
+ renderSheet({ stopDraftDuplicate: 'Aral A1' })
+ expect(screen.getByText('roadtrip.stop.duplicate:Aral A1')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTDRAFT-012: a hotel is a night, so it points at the desktop and offers no save', () => {
+ renderSheet({ stopDraft: draftOf('hotel') })
+ expect(screen.getByText('mobileTrip.rtNightDesktopOnly')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'roadtrip.poi.add' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'common.save' })).not.toBeInTheDocument()
+ // Only the way out and the way to drop it.
+ expect(screen.getAllByRole('button')).toHaveLength(2)
+ expect(screen.getByRole('button', { name: 'common.close' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'common.discard' })).toBeInTheDocument()
+ // Neither question is asked, because neither answer could be written.
+ expect(screen.queryByText('roadtrip.stop.kind')).not.toBeInTheDocument()
+ expect(screen.queryByText('roadtrip.stop.stay')).not.toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTDRAFT-013: a campsite is treated the same way, because a night can be booked there too', () => {
+ renderSheet({ stopDraft: draftOf('campsite') })
+ expect(screen.getByText('mobileTrip.rtNightDesktopOnly')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'roadtrip.poi.add' })).not.toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTDRAFT-014: a draft that already carries an overnight offer is night-only whatever its category', () => {
+ renderSheet({
+ stopDraft: draftOf('fuel', { overnight: { days: [{ id: 11, number: 1, date: '2026-05-01' }], defaultEndDayId: 12 } }),
+ })
+ expect(screen.getByText('mobileTrip.rtNightDesktopOnly')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'roadtrip.poi.add' })).not.toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTDRAFT-015: discarding clears the draft and writes nothing', () => {
+ const { planner } = renderSheet()
+ fireEvent.click(screen.getByRole('button', { name: 'common.discard' }))
+ expect(planner.setStopDraft).toHaveBeenCalledWith(null)
+ expect(planner.saveStopDraft).not.toHaveBeenCalled()
+ })
+})
diff --git a/client/tests/unit/mobile/trip/MRtInfoSheet.test.tsx b/client/tests/unit/mobile/trip/MRtInfoSheet.test.tsx
new file mode 100644
index 000000000..0c2b3c133
--- /dev/null
+++ b/client/tests/unit/mobile/trip/MRtInfoSheet.test.tsx
@@ -0,0 +1,168 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import MRtInfoSheet from '../../../../src/mobile/screens/trip/roadtrip/MRtInfoSheet'
+import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
+import { useAuthStore } from '../../../../src/store/authStore'
+import { useSettingsStore } from '../../../../src/store/settingsStore'
+import { useRoadtripPreferencesStore } from '../../../../src/store/roadtripPreferencesStore'
+import type { RoadtripPreferences } from '@trek/shared'
+import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
+import { resetAllStores, seedStore } from '../../../helpers/store'
+import { render, screen } from '../../../helpers/render'
+
+// FE-MOB-RTINFO-001 to FE-MOB-RTINFO-015
+//
+// The sheet renders inside the real TranslationProvider, so the copy is asserted
+// in English. Everything it shows comes out of the roadtrip preferences store,
+// which is keyed by ":".
+
+const USER_ID = 7
+const TRIP_ID = 4
+
+/** An electric car with every limit filled in, so each row has a value to show. */
+const FULL: RoadtripPreferences = {
+ roadtrip_vehicle: 'electric',
+ roadtrip_battery_kwh: 77,
+ roadtrip_kwh_per_100: 18,
+ roadtrip_battery_degradation: 8,
+ roadtrip_fill_percent: 80,
+ roadtrip_leg_minutes: 240,
+ roadtrip_day_minutes: 480,
+ roadtrip_day_start: '08:00',
+ roadtrip_day_end: '19:00',
+ roadtrip_avoid: 'toll,ferry',
+}
+
+function seedPreferences(preferences: RoadtripPreferences): void {
+ useRoadtripPreferencesStore.setState({ byTrip: { [`${USER_ID}:${TRIP_ID}`]: preferences } })
+}
+
+function renderSheet(preferences: RoadtripPreferences = FULL, shellOverrides: Record = {}) {
+ seedPreferences(preferences)
+ const planner = buildPlanner({ tripId: TRIP_ID } as unknown as Partial)
+ const shell = buildShell({ sheet: { id: 'rtinfo' }, ...shellOverrides } as unknown as Partial)
+ render( )
+ return { planner, shell }
+}
+
+/** The value cell of one limit row, found through its label. */
+function limitValue(label: string): string {
+ const row = screen.getByText(label).parentElement
+ return row?.lastElementChild?.textContent ?? ''
+}
+
+describe('MRtInfoSheet', () => {
+ beforeEach(() => {
+ resetAllStores()
+ useRoadtripPreferencesStore.setState({ byTrip: {} })
+ seedStore(useAuthStore, { user: { id: USER_ID } as never })
+ })
+
+ it('FE-MOB-RTINFO-001: stays closed while another sheet id is active', () => {
+ renderSheet(FULL, { sheet: { id: 'rtstop' } })
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTINFO-002: opens as the driving figures of the trip', () => {
+ renderSheet()
+ expect(screen.getByRole('dialog', { name: 'Driving figures' })).toBeInTheDocument()
+ expect(screen.getByText('Vehicle')).toBeInTheDocument()
+ expect(screen.getByText('Driving settings')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTINFO-003: shows the range the stage plans with, worked out from the battery', () => {
+ renderSheet()
+ // 77 kWh less 8 % wear, at 18 kWh/100 km.
+ expect(screen.getByText('394')).toBeInTheDocument()
+ expect(screen.getByText('km')).toBeInTheDocument()
+ expect(screen.getByText('per charge')).toBeInTheDocument()
+ expect(screen.getByRole('img', { name: '394 km per charge' })).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTINFO-004: explains the fill, the block size and the battery wear under the bar', () => {
+ renderSheet()
+ expect(screen.getByText('A 80 % stop gives 315.2 km · One block = 50 km · 8 % lost to age')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTINFO-005: reads the range in miles on an imperial account', () => {
+ seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, distance_unit: 'imperial' } })
+ renderSheet()
+ expect(screen.getByText('244.8')).toBeInTheDocument()
+ expect(screen.getByText('mi')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTINFO-006: prints every limit as its own value', () => {
+ renderSheet()
+ expect(limitValue('Longest drive at once')).toBe('4 h')
+ expect(limitValue('Driving per day')).toBe('8 h')
+ expect(limitValue('Daily travel times')).toBe('08:00 · 19:00')
+ expect(limitValue('Fill up to')).toBe('80 %')
+ expect(limitValue('Avoid where possible')).toBe('2 avoided')
+ })
+
+ it('FE-MOB-RTINFO-007: an unset limit reads "off" rather than zero', () => {
+ renderSheet({})
+ expect(limitValue('Longest drive at once')).toBe('off')
+ expect(limitValue('Driving per day')).toBe('off')
+ expect(limitValue('Daily travel times')).toBe('off')
+ expect(limitValue('Avoid where possible')).toBe('off')
+ expect(screen.queryByText('0')).not.toBeInTheDocument()
+ expect(screen.queryByText('0 min')).not.toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTINFO-008: an unset fill reads "full", because not saying means filling right up', () => {
+ renderSheet({})
+ expect(limitValue('Fill up to')).toBe('full')
+ expect(screen.queryByText('100 %')).not.toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTINFO-009: with no range the figure is a dash and the hint says where to set one', () => {
+ renderSheet({})
+ expect(screen.getByText('-')).toBeInTheDocument()
+ expect(screen.getByRole('img', { name: 'No range set' })).toBeInTheDocument()
+ expect(screen.getByText('Type a range, or fill in the figures below and TREK works it out.')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTINFO-010: a half-set day window counts as no window at all', () => {
+ renderSheet({ ...FULL, roadtrip_day_end: '' })
+ expect(limitValue('Daily travel times')).toBe('off')
+ })
+
+ it('FE-MOB-RTINFO-011: stored avoidance reads "off" when no second routing engine can honour it', () => {
+ // A self-hosted router and no Valhalla: the classes are stored but nothing applies them.
+ seedStore(useSettingsStore, {
+ settings: { ...useSettingsStore.getState().settings, routing_base_url: 'https://osrm.example', valhalla_base_url: '' },
+ })
+ renderSheet()
+ expect(limitValue('Avoid where possible')).toBe('off')
+ })
+
+ it('FE-MOB-RTINFO-012: carries no control of any kind, only the way out', () => {
+ renderSheet()
+ const dialog = screen.getByRole('dialog', { name: 'Driving figures' })
+ expect(screen.getAllByRole('button')).toHaveLength(1)
+ expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
+ expect(dialog.querySelectorAll('input, select, textarea')).toHaveLength(0)
+ for (const role of ['textbox', 'spinbutton', 'slider', 'switch', 'checkbox', 'combobox', 'radio'] as const) {
+ expect(screen.queryByRole(role)).not.toBeInTheDocument()
+ }
+ })
+
+ it('FE-MOB-RTINFO-013: closes with the footnote that names the desktop as the place to change them', () => {
+ renderSheet()
+ expect(screen.getByText(/These figures are set on the desktop/)).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTINFO-014: a combustion car reads per tank, out of tank size and consumption', () => {
+ renderSheet({ roadtrip_vehicle: 'combustion', roadtrip_tank_litres: 60, roadtrip_litres_per_100: 7 })
+ // 60 litres at 7 L/100 km, with no battery wear to take off it.
+ expect(screen.getByRole('img', { name: '857 km per tank' })).toBeInTheDocument()
+ expect(screen.getByText('One block = 50 km')).toBeInTheDocument()
+ expect(screen.queryByText(/lost to age/)).not.toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTINFO-015: a range too long to count in blocks gets a solid bar and no note', () => {
+ renderSheet({ roadtrip_range_km: 2000 })
+ expect(screen.getByRole('img', { name: '2000 km per tank' })).toBeInTheDocument()
+ expect(screen.queryByText(/One block/)).not.toBeInTheDocument()
+ })
+})
diff --git a/client/tests/unit/mobile/trip/MRtStaySheet.test.tsx b/client/tests/unit/mobile/trip/MRtStaySheet.test.tsx
new file mode 100644
index 000000000..0a715913a
--- /dev/null
+++ b/client/tests/unit/mobile/trip/MRtStaySheet.test.tsx
@@ -0,0 +1,214 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import MRtStaySheet from '../../../../src/mobile/screens/trip/roadtrip/MRtStaySheet'
+import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
+import { useSettingsStore } from '../../../../src/store/settingsStore'
+import type { RoadtripDay } from '@trek/shared/roadtrip'
+import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
+import { resetAllStores, seedStore } from '../../../helpers/store'
+import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
+
+// FE-MOB-RTSTAY-001 to FE-MOB-RTSTAY-018
+//
+// The sheet renders inside the real TranslationProvider, so the copy is asserted
+// in English.
+
+/** Arrives late in the evening, so a long stay has to carry into the next day. */
+const STAGE = {
+ dayId: 11,
+ dayNumber: 1,
+ date: '2026-05-01',
+ title: null,
+ stops: [
+ { assignmentId: 101, ownerDayId: 11, ownerIndex: 0, placeId: 201, name: 'Hamburg Hafen', lat: 53.54, lng: 9.98, time: null, dwellMinutes: 30, legMode: null, incomingLegMode: null, stopType: null },
+ { assignmentId: 102, ownerDayId: 11, ownerIndex: 1, placeId: 202, name: 'Bremen Marktplatz', lat: 53.07, lng: 8.8, time: null, dwellMinutes: 30, legMode: null, incomingLegMode: null, stopType: null },
+ ],
+ legs: [undefined, undefined],
+ legVias: [[], []],
+ geometry: [],
+ distance: 0,
+ duration: 0,
+ dayWarning: null,
+ schedule: {
+ entries: [
+ { arrival: '09:15', departure: '09:45', anchored: false, dayOffset: 0 },
+ { arrival: '22:30', departure: '23:00', anchored: false, dayOffset: 0 },
+ ],
+ warnings: [],
+ },
+ driveWarnings: [],
+} as unknown as RoadtripDay
+
+function makePlanner(overrides: Record = {}) {
+ return buildPlanner({
+ tripId: 4,
+ selectedDayId: 11,
+ roadtripRoutes: { days: [STAGE] },
+ setRoadtripStay: vi.fn(async () => undefined),
+ ...overrides,
+ } as unknown as Partial)
+}
+
+function makeShell(overrides: Record = {}) {
+ return buildShell({
+ sheet: { id: 'rtstay', payload: { placeId: 202, minutes: 30, name: 'Bremen Marktplatz' } },
+ ...overrides,
+ } as unknown as Partial)
+}
+
+function renderSheet(plannerOverrides: Record = {}, shellOverrides: Record = {}) {
+ const planner = makePlanner(plannerOverrides)
+ const shell = makeShell(shellOverrides)
+ render( )
+ return { planner, shell }
+}
+
+const minus = () => screen.getByRole('button', { name: '5 minutes less' })
+const plus = () => screen.getByRole('button', { name: '5 minutes more' })
+const value = () => screen.getByText('Minutes').previousElementSibling
+
+describe('MRtStaySheet', () => {
+ beforeEach(() => {
+ resetAllStores()
+ })
+
+ it('FE-MOB-RTSTAY-001: stays closed while another sheet id is active', () => {
+ renderSheet({}, { sheet: { id: 'rtstop', payload: { placeId: 202, minutes: 30 } } })
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTSTAY-002: opens on the stay the place carries, named and scoped', () => {
+ renderSheet()
+ expect(screen.getByRole('dialog', { name: 'Add a stay' })).toBeInTheDocument()
+ expect(screen.getByText('Bremen Marktplatz')).toBeInTheDocument()
+ expect(screen.getByText('The stay belongs to the place, so it counts on every day this stop is planned.')).toBeInTheDocument()
+ expect(value()).toHaveTextContent('30')
+ })
+
+ it('FE-MOB-RTSTAY-003: plus and minus move the value in five-minute steps', () => {
+ renderSheet()
+ fireEvent.click(plus())
+ expect(value()).toHaveTextContent('35')
+ fireEvent.click(plus())
+ expect(value()).toHaveTextContent('40')
+ fireEvent.click(minus())
+ expect(value()).toHaveTextContent('35')
+ })
+
+ it('FE-MOB-RTSTAY-004: minus stops at zero instead of going negative', () => {
+ renderSheet({}, { sheet: { id: 'rtstay', payload: { placeId: 202, minutes: 3, name: 'Bremen Marktplatz' } } })
+ fireEvent.click(minus())
+ expect(value()).toHaveTextContent('0')
+ expect(minus()).toBeDisabled()
+ })
+
+ it('FE-MOB-RTSTAY-005: plus stops at a full day', () => {
+ renderSheet({}, { sheet: { id: 'rtstay', payload: { placeId: 202, minutes: 1438, name: 'Bremen Marktplatz' } } })
+ fireEvent.click(plus())
+ expect(value()).toHaveTextContent('1440')
+ expect(plus()).toBeDisabled()
+ })
+
+ it('FE-MOB-RTSTAY-006: a preset sets the value outright rather than adding to it', () => {
+ renderSheet()
+ fireEvent.click(screen.getByRole('button', { name: '2 h' }))
+ expect(value()).toHaveTextContent('120')
+ fireEvent.click(screen.getByRole('button', { name: '45 min' }))
+ expect(value()).toHaveTextContent('45')
+ })
+
+ it('FE-MOB-RTSTAY-007: previews the new departure as arrival plus the stay', () => {
+ renderSheet({}, { sheet: { id: 'rtstay', payload: { placeId: 201, minutes: 30, name: 'Hamburg Hafen' } } })
+ expect(screen.getByText('09:15')).toBeInTheDocument()
+ expect(screen.getByText('09:45')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: '1 h' }))
+ expect(screen.getByText('10:15')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTSTAY-008: carries the preview into the next day when the stay runs past midnight', () => {
+ renderSheet()
+ // Arrives 22:30; two hours of it land at 00:30 the following day.
+ fireEvent.click(screen.getByRole('button', { name: '2 h' }))
+ expect(screen.getByText('00:30')).toBeInTheDocument()
+ expect(screen.getByText('+1')).toBeInTheDocument()
+ expect(screen.getByText('Next day')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTSTAY-009: no carry mark while the departure stays on the same day', () => {
+ renderSheet()
+ fireEvent.click(screen.getByRole('button', { name: '30 min' }))
+ expect(screen.getByText('23:00')).toBeInTheDocument()
+ expect(screen.queryByText('+1')).not.toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTSTAY-010: reads the preview clocks in the 12h setting', () => {
+ seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, time_format: '12h' } })
+ renderSheet({}, { sheet: { id: 'rtstay', payload: { placeId: 201, minutes: 30, name: 'Hamburg Hafen' } } })
+ expect(screen.getByText('9:15 AM')).toBeInTheDocument()
+ expect(screen.getByText('9:45 AM')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTSTAY-011: drops the preview when the stop is not on the stage', () => {
+ renderSheet({ selectedDayId: 99 })
+ expect(screen.queryByText('Arrive')).not.toBeInTheDocument()
+ expect(screen.queryByText('Leave')).not.toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTSTAY-012: saving writes the place and the minutes, then hands back to the stop', async () => {
+ const { planner, shell } = renderSheet()
+ fireEvent.click(plus())
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
+ await waitFor(() => expect(planner.setRoadtripStay).toHaveBeenCalledWith(202, 35))
+ expect(shell.openSheet).toHaveBeenCalledWith('rtstop', { placeId: 202, minutes: 35, name: 'Bremen Marktplatz' })
+ })
+
+ it('FE-MOB-RTSTAY-013: clearing the stay is a save of zero, not a second write path', async () => {
+ const { planner, shell } = renderSheet()
+ fireEvent.click(screen.getByRole('button', { name: 'No stay' }))
+ expect(planner.setRoadtripStay).not.toHaveBeenCalled()
+ expect(value()).toHaveTextContent('0')
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
+ await waitFor(() => expect(planner.setRoadtripStay).toHaveBeenCalledWith(202, 0))
+ expect(shell.openSheet).toHaveBeenCalledWith('rtstop', { placeId: 202, minutes: 0, name: 'Bremen Marktplatz' })
+ })
+
+ it('FE-MOB-RTSTAY-014: closing without saving writes nothing and does not reopen the stop', () => {
+ const { planner, shell } = renderSheet()
+ fireEvent.click(plus())
+ fireEvent.click(screen.getByRole('button', { name: 'Close' }))
+ expect(shell.closeSheet).toHaveBeenCalledTimes(1)
+ expect(planner.setRoadtripStay).not.toHaveBeenCalled()
+ expect(shell.openSheet).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-RTSTAY-015: a failed write puts the stored value back and says so', async () => {
+ const { planner, shell } = renderSheet({
+ setRoadtripStay: vi.fn(async () => { throw new Error('offline') }),
+ })
+ fireEvent.click(screen.getByRole('button', { name: '2 h' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
+ await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Unknown error'))
+ expect(value()).toHaveTextContent('30')
+ expect(shell.openSheet).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-RTSTAY-016: without place_edit there is nothing to set the stay with', () => {
+ renderSheet({ can: vi.fn(() => false) })
+ expect(minus()).toBeDisabled()
+ expect(plus()).toBeDisabled()
+ expect(screen.queryByRole('button', { name: '2 h' })).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: 'No stay' })).toBeDisabled()
+ })
+
+ it('FE-MOB-RTSTAY-017: a stop that carries no stay yet opens on zero', () => {
+ renderSheet({}, { sheet: { id: 'rtstay', payload: { placeId: 202, name: 'Bremen Marktplatz' } } })
+ expect(value()).toHaveTextContent('0')
+ expect(minus()).toBeDisabled()
+ })
+
+ it('FE-MOB-RTSTAY-018: a place the stage does not stop at gets no preview either', () => {
+ renderSheet({}, { sheet: { id: 'rtstay', payload: { placeId: 909, minutes: 30, name: 'Elsewhere' } } })
+ expect(screen.getByRole('dialog', { name: 'Add a stay' })).toBeInTheDocument()
+ expect(screen.queryByText('Arrive')).not.toBeInTheDocument()
+ })
+})
diff --git a/client/tests/unit/mobile/trip/MRtStopSheet.test.tsx b/client/tests/unit/mobile/trip/MRtStopSheet.test.tsx
new file mode 100644
index 000000000..f423d6807
--- /dev/null
+++ b/client/tests/unit/mobile/trip/MRtStopSheet.test.tsx
@@ -0,0 +1,361 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import MRtStopSheet from '../../../../src/mobile/screens/trip/roadtrip/MRtStopSheet'
+import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
+import type { Place } from '../../../../src/types'
+import type { RoadtripDay, RoadtripStop } from '@trek/shared/roadtrip'
+import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
+import { resetAllStores } from '../../../helpers/store'
+import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
+
+// FE-MOB-RTSTOP-001 to FE-MOB-RTSTOP-027
+//
+// The sheet renders inside the real TranslationProvider, so the copy is asserted
+// in English. `planner.t` from the fixture is never consulted here.
+
+// The charging panel polls a repo of its own every minute; stubbed so the sheet's
+// wiring can be asserted without a live availability lookup in the background.
+vi.mock('../../../../src/components/Roadtrip/ChargingInfo', () => ({
+ default: ({ placeId }: { placeId?: number }) => {placeId}
,
+}))
+
+function stop(over: Partial & Pick): RoadtripStop {
+ return {
+ ownerDayId: 11,
+ ownerIndex: 0,
+ lat: 53.55,
+ lng: 9.99,
+ time: null,
+ dwellMinutes: null,
+ legMode: 'driving',
+ incomingLegMode: 'driving',
+ stopType: null,
+ ...over,
+ } as RoadtripStop
+}
+
+/** Stop 1 is pinned, stop 2 is a fuel stop off the road, stop 3 collects three findings. */
+const HAMBURG = stop({ assignmentId: 101, placeId: 201, name: 'Hamburg Hafen', ownerIndex: 0, time: '09:00', dwellMinutes: 60 })
+const ARAL = stop({ assignmentId: 102, placeId: 202, name: 'Aral Dammtor', ownerIndex: 1, stopType: 'fuel', dwellMinutes: 10, offRoadMeters: 450 })
+const BREMEN = stop({ assignmentId: 103, placeId: 203, name: 'Bremen Marktplatz', ownerIndex: 2, dwellMinutes: 90 })
+/** Set off from on day 11, reached on day 12: stored on one card, drawn on the other. */
+const KASSEL = stop({ assignmentId: 104, placeId: 204, name: 'Kassel Rathaus', ownerDayId: 11, ownerIndex: 3 })
+
+const DAY_A = {
+ dayId: 11,
+ dayNumber: 1,
+ date: '2026-05-01',
+ title: null,
+ stops: [HAMBURG, ARAL, BREMEN],
+ legs: [undefined, undefined, undefined],
+ legVias: [[], [], []],
+ geometry: [],
+ distance: 0,
+ duration: 0,
+ dayWarning: null,
+ schedule: {
+ entries: [
+ { arrival: '09:00', departure: '10:00', anchored: true, dayOffset: 0 },
+ { arrival: '11:30', departure: '11:40', anchored: false, dayOffset: 0 },
+ { arrival: '13:05', departure: '14:35', anchored: false, dayOffset: 0 },
+ ],
+ warnings: [{ index: 2, code: 'late', minutes: 25 }],
+ },
+ driveWarnings: [
+ { index: 2, code: 'leg', overMinutes: 40 },
+ { index: 2, code: 'range', sinceKm: 520 },
+ ],
+} as unknown as RoadtripDay
+
+const DAY_B = {
+ dayId: 12,
+ dayNumber: 2,
+ date: '2026-05-02',
+ title: null,
+ stops: [KASSEL],
+ legs: [undefined],
+ legVias: [[]],
+ geometry: [],
+ distance: 0,
+ duration: 0,
+ dayWarning: null,
+ schedule: {
+ entries: [{ arrival: '08:11', departure: '08:41', anchored: false, dayOffset: 0 }],
+ warnings: [{ index: 0, code: 'late', minutes: 12 }],
+ },
+ driveWarnings: [],
+} as unknown as RoadtripDay
+
+const PLACES = [
+ { id: 201, name: 'Hamburg Hafen', lat: 53.54, lng: 9.98, address: 'Bei den St. Pauli-Landungsbrücken' },
+ { id: 203, name: 'Bremen Marktplatz', lat: 53.07, lng: 8.8, address: 'Am Markt' },
+] as unknown as Place[]
+
+function makePlanner(overrides: Record = {}) {
+ return buildPlanner({
+ tripId: 4,
+ places: PLACES,
+ dailyTimesActive: true,
+ setRoadtripEndDay: vi.fn(async () => undefined),
+ roadtripRoutes: { days: [DAY_A, DAY_B] },
+ ...overrides,
+ } as unknown as Partial)
+}
+
+function makeShell(overrides: Record = {}) {
+ return buildShell({
+ sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 103 } },
+ ...overrides,
+ } as unknown as Partial)
+}
+
+function renderSheet(plannerOverrides: Record = {}, shellOverrides: Record = {}) {
+ const planner = makePlanner(plannerOverrides)
+ const shell = makeShell(shellOverrides)
+ render( )
+ return { planner, shell }
+}
+
+describe('MRtStopSheet', () => {
+ beforeEach(() => {
+ resetAllStores()
+ })
+
+ it('FE-MOB-RTSTOP-001: stays closed while another sheet id is active', () => {
+ renderSheet({}, { sheet: { id: 'rtstay', payload: { dayId: 11, assignmentId: 103 } } })
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTSTOP-002: stays closed when no routed day carries the assignment', () => {
+ renderSheet({}, { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 999 } } })
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTSTOP-003: shows arrival and departure large, and says the time was set by hand', () => {
+ renderSheet({}, { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 101 } } })
+ expect(screen.getByRole('dialog', { name: 'Hamburg Hafen' })).toBeInTheDocument()
+ expect(screen.getByText('Arrive')).toBeInTheDocument()
+ expect(screen.getByText('09:00')).toBeInTheDocument()
+ expect(screen.getByText('Leave')).toBeInTheDocument()
+ expect(screen.getByText('10:00')).toBeInTheDocument()
+ expect(screen.getByText('Time you set')).toBeInTheDocument()
+ expect(screen.queryByText('Calculated from the drive')).not.toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTSTOP-004: says the clock was worked out when the stop is not anchored', () => {
+ renderSheet()
+ expect(screen.getByText('13:05')).toBeInTheDocument()
+ expect(screen.getByText('14:35')).toBeInTheDocument()
+ expect(screen.getByText('Calculated from the drive')).toBeInTheDocument()
+ expect(screen.queryByText('Time you set')).not.toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTSTOP-005: lists every finding of the stop, not just the one the chain has room for', () => {
+ renderSheet()
+ // The chain picks 'range' out of the drive warnings and shows nothing else.
+ expect(screen.getByText('Arrives 25 min after the time you set')).toBeInTheDocument()
+ expect(screen.getByText('40 min over your longest drive')).toBeInTheDocument()
+ expect(screen.getByText('520 km since the last fill-up')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTSTOP-006: spells out the stay and how far the stop sits off the road', () => {
+ renderSheet({}, { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 102 } } })
+ expect(screen.getByText('450 m from the road')).toBeInTheDocument()
+ expect(screen.getAllByText('10 min').length).toBeGreaterThan(0)
+ })
+
+ it('FE-MOB-RTSTOP-007: finds the stop on the card it is drawn on, not the day it is stored on', () => {
+ // ownerDayId 11 names day A; the stop only exists in day B's chain after a night drive.
+ renderSheet({}, { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 104 } } })
+ expect(screen.getByRole('dialog', { name: 'Kassel Rathaus' })).toBeInTheDocument()
+ // Day B's own schedule and day B's own warning, filed under day B's index.
+ expect(screen.getByText('08:11')).toBeInTheDocument()
+ expect(screen.getByText('Arrives 12 min after the time you set')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTSTOP-008: keeps the navigation tile without any write permission', () => {
+ renderSheet({ can: vi.fn(() => false) })
+ expect(screen.getByRole('button', { name: 'Navigation' })).toBeInTheDocument()
+ // The stay tile loses its handler and stays a plain box rather than going dead.
+ expect(screen.queryByRole('button', { name: /^Stay/ })).not.toBeInTheDocument()
+ const stayLabels = screen.getAllByText('Stay')
+ expect(stayLabels).toHaveLength(2)
+ expect(stayLabels[1].closest('button')).toBeNull()
+ })
+
+ it('FE-MOB-RTSTOP-009: offers the map apps built from the stop itself when no place row matches', () => {
+ renderSheet({ places: [] })
+ fireEvent.click(screen.getByRole('button', { name: 'Navigation' }))
+ expect(screen.getByRole('menu')).toBeInTheDocument()
+ expect(screen.getByRole('menuitem', { name: 'Google Maps' })).toBeInTheDocument()
+ expect(screen.getByRole('menuitem', { name: 'Waze' })).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTSTOP-010: has no delete, no reorder and no stop-kind control at all', () => {
+ renderSheet()
+ // Close, navigation, stay, show-on-map, plus the end-day switch, which is a switch.
+ expect(screen.getAllByRole('button')).toHaveLength(4)
+ expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Navigation' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /^Stay/ })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Show on map' })).toBeInTheDocument()
+ expect(screen.getAllByRole('switch')).toHaveLength(1)
+ // The desktop-only copy is absent rather than disabled.
+ expect(screen.queryByText('Remove stop')).not.toBeInTheDocument()
+ expect(screen.queryByText('Kind of stop')).not.toBeInTheDocument()
+ expect(screen.queryByText('Set how full this stop fills')).not.toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTSTOP-011: the stay tile hands the stop over to the stay sheet', () => {
+ const { shell } = renderSheet()
+ fireEvent.click(screen.getByRole('button', { name: /^Stay/ }))
+ expect(shell.openSheet).toHaveBeenCalledWith('rtstay', { placeId: 203, minutes: 90 })
+ })
+
+ it('FE-MOB-RTSTOP-012: ending the day here calls the stop-addressed writer with that very stop', async () => {
+ const { planner } = renderSheet()
+ const toggle = screen.getByRole('switch', { name: 'End the day here' })
+ expect(toggle).toHaveAttribute('aria-checked', 'false')
+ fireEvent.click(toggle)
+ expect(planner.setRoadtripEndDay).toHaveBeenCalledWith(BREMEN)
+ // Reads as switched the moment it is tapped, before the write lands.
+ await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'true'))
+ })
+
+ it('FE-MOB-RTSTOP-013: rolls the switch back and reports when the write fails', async () => {
+ const { planner } = renderSheet({
+ setRoadtripEndDay: vi.fn(async () => { throw new Error('Day boundary refused') }),
+ })
+ const toggle = screen.getByRole('switch', { name: 'End the day here' })
+ fireEvent.click(toggle)
+ await waitFor(() => expect(planner.toast.error).toHaveBeenCalledWith('Day boundary refused'))
+ expect(toggle).toHaveAttribute('aria-checked', 'false')
+ })
+
+ it('FE-MOB-RTSTOP-014: dropping the day window takes the end-day switch away entirely', () => {
+ renderSheet({ dailyTimesActive: false })
+ expect(screen.queryByRole('switch')).not.toBeInTheDocument()
+ expect(screen.queryByText('End the day here')).not.toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTSTOP-015: showing the stop on the map flips the stage over and selects it', () => {
+ const { planner, shell } = renderSheet({}, { rtView: 'list' })
+ fireEvent.click(screen.getByRole('button', { name: 'Show on map' }))
+ expect(shell.toggleRtView).toHaveBeenCalledTimes(1)
+ expect(planner.setSelectedPlaceId).toHaveBeenCalledWith(203)
+ expect(shell.closeSheet).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-MOB-RTSTOP-016: leaves the stage alone when the map is already the front layer', () => {
+ const { shell } = renderSheet({}, { rtView: 'map' })
+ fireEvent.click(screen.getByRole('button', { name: 'Show on map' }))
+ expect(shell.toggleRtView).not.toHaveBeenCalled()
+ expect(shell.closeSheet).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-MOB-RTSTOP-017: the disc counts destinations, so the third stop on the card is number two', () => {
+ // Hamburg is 1, the fuel stop between them takes no number, Bremen is 2.
+ renderSheet()
+ const heading = screen.getByRole('heading', { name: 'Bremen Marktplatz' })
+ expect(heading.previousElementSibling).toHaveTextContent('2')
+ })
+
+ it('FE-MOB-RTSTOP-018: a service stop is drawn by its kind, not by a position in the chain', () => {
+ renderSheet({}, { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 102 } } })
+ const disc = screen.getByRole('heading', { name: 'Aral Dammtor' }).previousElementSibling
+ // The kind's own colour, shared with the map marker, rather than a numbered disc.
+ expect(disc).toHaveStyle({ background: '#E8590C' })
+ expect(disc?.querySelector('svg')).not.toBeNull()
+ expect(disc?.textContent).toBe('')
+ })
+
+ it('FE-MOB-RTSTOP-019: a stop the routing round has no clock for drops the clock card entirely', () => {
+ const day = {
+ ...DAY_A,
+ stops: [BREMEN],
+ schedule: { entries: [], warnings: [] },
+ driveWarnings: [],
+ } as unknown as RoadtripDay
+ renderSheet({ roadtripRoutes: { days: [day] } }, { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 103 } } })
+ expect(screen.queryByText('Arrive')).not.toBeInTheDocument()
+ expect(screen.queryByText('Leave')).not.toBeInTheDocument()
+ expect(screen.queryByText('Calculated from the drive')).not.toBeInTheDocument()
+ // The stay is still filed, so the sheet is not empty.
+ expect(screen.getAllByText('1 h 30 min').length).toBeGreaterThan(0)
+ })
+
+ it('FE-MOB-RTSTOP-020: an arrival without a departure shows one clock, not an empty second one', () => {
+ const day = {
+ ...DAY_A,
+ stops: [HAMBURG],
+ schedule: { entries: [{ arrival: '09:00', departure: null, anchored: false, dayOffset: 0 }], warnings: [] },
+ driveWarnings: [],
+ } as unknown as RoadtripDay
+ renderSheet({ roadtripRoutes: { days: [day] } }, { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 101 } } })
+ expect(screen.getByText('Arrive')).toBeInTheDocument()
+ expect(screen.queryByText('Leave')).not.toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTSTOP-021: a stop with no stay says so on the tile and files no stay finding', () => {
+ const day = {
+ ...DAY_A,
+ stops: [{ ...BREMEN, dwellMinutes: null }],
+ schedule: { entries: [{ arrival: '13:05', departure: '13:05', anchored: false, dayOffset: 0 }], warnings: [] },
+ driveWarnings: [],
+ } as unknown as RoadtripDay
+ renderSheet({ roadtripRoutes: { days: [day] } }, { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 103 } } })
+ expect(screen.getByText('No stay')).toBeInTheDocument()
+ expect(screen.getAllByText('Stay')).toHaveLength(1)
+ })
+
+ it('FE-MOB-RTSTOP-022: one map app means no picker, the tap opens it and names it on the tile', () => {
+ const open = vi.spyOn(window, 'open').mockReturnValue(null)
+ // Name only: no coordinates and no address, so only the OSM search link survives.
+ renderSheet({ places: [{ id: 203, name: 'Bremen Marktplatz', lat: null, lng: null, address: null }] })
+ const tile = screen.getByRole('button', { name: /^Navigation/ })
+ expect(tile).toHaveTextContent('OpenStreetMap')
+ fireEvent.click(tile)
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument()
+ expect(open).toHaveBeenCalledWith(
+ 'https://www.openstreetmap.org/search?query=Bremen%20Marktplatz',
+ '_blank',
+ 'noopener,noreferrer',
+ )
+ open.mockRestore()
+ })
+
+ it('FE-MOB-RTSTOP-023: no reachable map app at all drops the tile instead of leaving a dead one', () => {
+ renderSheet({ places: [{ id: 203, name: '', lat: null, lng: null, address: null }] })
+ expect(screen.queryByRole('button', { name: /Navigation/ })).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /^Stay/ })).toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTSTOP-024: the map-app picker closes again on a tap outside it', () => {
+ renderSheet()
+ fireEvent.click(screen.getByRole('button', { name: 'Navigation' }))
+ expect(screen.getByRole('menu')).toBeInTheDocument()
+ fireEvent.mouseDown(document.body)
+ expect(screen.queryByRole('menu')).not.toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTSTOP-025: a payload without an assignment names no stop, so nothing opens', () => {
+ renderSheet({}, { sheet: { id: 'rtstop', payload: {} } })
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('FE-MOB-RTSTOP-026: a charger gets the live availability panel, a pump does not', () => {
+ const charger = stop({ assignmentId: 106, placeId: 206, name: 'Ionity Bremen', stopType: 'charging' })
+ const day = {
+ ...DAY_A,
+ stops: [charger],
+ schedule: { entries: [{ arrival: '12:00', departure: '12:30', anchored: false, dayOffset: 0 }], warnings: [] },
+ driveWarnings: [],
+ } as unknown as RoadtripDay
+ renderSheet({ roadtripRoutes: { days: [day] } }, { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 106 } } })
+ expect(screen.getByTestId('charging-info')).toHaveTextContent('206')
+ })
+
+ it('FE-MOB-RTSTOP-027: no availability panel on a stop that is not a charger', () => {
+ renderSheet({}, { sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 102 } } })
+ expect(screen.queryByTestId('charging-info')).not.toBeInTheDocument()
+ })
+})
diff --git a/client/tests/unit/mobile/trip/MTripSheets.test.tsx b/client/tests/unit/mobile/trip/MTripSheets.test.tsx
index a75b4bfdf..164cb638e 100644
--- a/client/tests/unit/mobile/trip/MTripSheets.test.tsx
+++ b/client/tests/unit/mobile/trip/MTripSheets.test.tsx
@@ -11,7 +11,7 @@ import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
-// FE-MOB-SHOST-001 to FE-MOB-SHOST-027
+// FE-MOB-SHOST-001 to FE-MOB-SHOST-028
//
// Every child sheet is stubbed: this file is about the host — which sheet is
// mounted for which shell.sheet id, and how the host's own callbacks wire the
@@ -34,6 +34,10 @@ vi.mock('../../../../src/mobile/screens/trip/sheets/MTransportSheet', () => ({ d
vi.mock('../../../../src/mobile/screens/trip/sheets/MBrowseActionsSheet', () => ({ default: selfRouted('stub-bract') }))
vi.mock('../../../../src/mobile/screens/trip/sheets/MMehrSheet', () => ({ default: selfRouted('stub-mehr') }))
vi.mock('../../../../src/mobile/screens/trip/sheets/MExportSheet', () => ({ default: selfRouted('stub-export') }))
+vi.mock('../../../../src/mobile/screens/trip/roadtrip/MRtCorridorSheet', () => ({ default: selfRouted('stub-rtsearch') }))
+vi.mock('../../../../src/mobile/screens/trip/roadtrip/MRtDraftSheet', () => ({
+ default: ({ planner }: { planner: TripPlanner }) =>
,
+}))
vi.mock('../../../../src/mobile/screens/trip/sheets/MNoteSheet', () => ({
default: ({ open, payload, onClose }: { open: boolean; payload?: { dayId?: number }; onClose: () => void }) => (
@@ -432,4 +436,17 @@ describe('MTripSheets', () => {
renderHost({ deletePlaceId: 101, showPlaceForm: true })
expect(screen.queryByTestId('stub-confirm')).not.toBeInTheDocument()
})
+
+ it('FE-MOB-SHOST-028: the search sheet is mounted before the draft it opens', () => {
+ renderHost({}, { sheet: null })
+
+ const order = [...document.querySelectorAll('[data-testid]')]
+ .map(el => el.getAttribute('data-testid'))
+ .filter(id => id === 'stub-rtsearch' || id === 'stub-rtdraft')
+
+ // Both sit at the same z. Portal order is what decides which paints on top, and
+ // taking a hit onto the trip has to open the draft OVER the search it came from.
+ expect(order).toEqual(['stub-rtsearch', 'stub-rtdraft'])
+ })
+
})
diff --git a/client/tests/unit/mobile/trip/MTripShell.test.tsx b/client/tests/unit/mobile/trip/MTripShell.test.tsx
index ff3437c12..83ae31af6 100644
--- a/client/tests/unit/mobile/trip/MTripShell.test.tsx
+++ b/client/tests/unit/mobile/trip/MTripShell.test.tsx
@@ -1,10 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { act, fireEvent, render, screen } from '../../../helpers/render'
+import { act, fireEvent, render, screen, within } from '../../../helpers/render'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
import type { Day, PackingItem, TodoItem } from '../../../../src/types'
-// FE-MOB-SHELL-001 to FE-MOB-SHELL-051
+// FE-MOB-SHELL-001 to FE-MOB-SHELL-064
const mocks = vi.hoisted(() => ({ planner: {} as TripPlanner }))
@@ -32,6 +32,16 @@ function slot(testId: string) {
}
}
+/**
+ * One identity per slot, created once. Built inside the wrapper's render body instead,
+ * every rerender hands the shell four brand-new component types and React remounts all
+ * of them, which would quietly mask the one thing FE-MOB-SHELL-054 is watching for.
+ */
+const PlanTimelineSlot = slot('plan-timeline')
+const MapAreaSlot = slot('map-area')
+const PlacesBrowserSlot = slot('places-browser')
+const SheetsSlot = slot('sheets')
+
function TabSlot({ shell, tab }: { planner: TripPlanner; shell: MTripShellApi; tab: string }) {
shellApi = shell
return {tab}
@@ -52,6 +62,33 @@ const DAYS = [
{ id: 12, trip_id: 1, day_number: 2, date: '2026-05-03', title: null },
] as unknown as Day[]
+/**
+ * The same sections with the road trip addon on, in the order the planner hook builds
+ * them, which is NOT the dock's order. The dock seats by its own priority list, so a
+ * fixture that mirrors the hook is the only one that can tell the two apart.
+ */
+const RT_TABS = [
+ { id: 'plan', label: 'Plan' },
+ { id: 'transports', label: 'Transport' },
+ { id: 'buchungen', label: 'Bookings' },
+ { id: 'roadtrip', label: 'Road trip' },
+ { id: 'listen', label: 'Lists' },
+ { id: 'finanzplan', label: 'Budget' },
+ { id: 'dateien', label: 'Files' },
+ { id: 'collab', label: 'Collaboration' },
+]
+
+/** One routed stage, as the addon reports it. */
+const stage = (over: Record = {}) => ({
+ dayId: 11, dayNumber: 1, date: '2026-05-02', title: null, distance: 123000, duration: 5400,
+ stops: [], legs: [], legVias: [], geometry: [], schedule: { entries: [] }, driveWarnings: [],
+ ...over,
+})
+
+/** The dock's seats, in order, plus the More button at the end. */
+const dockLabels = () =>
+ within(screen.getByRole('navigation')).getAllByRole('button').map(b => b.getAttribute('aria-label'))
+
function todayIso(): string {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
@@ -66,11 +103,11 @@ function renderShell(overrides: Partial = {}) {
} as Partial)
const Shell = () => (
)
const view = render( )
@@ -573,6 +610,168 @@ describe('MTripShell', () => {
expect(planner.handleSelectDay).not.toHaveBeenCalled()
})
})
+
+ // The road trip tab. The drive is a second reading of the same trip on the same map,
+ // so it does not get a map of its own: the shell's single instance now lies under
+ // BOTH tabs, and everything floating over it keys off `mapFront` rather than off
+ // `view`, which only ever meant the plan tab.
+ describe('the road trip tab', () => {
+ /** Commit a tab change the way the planner would, and redraw. */
+ function goToTab(planner: TripPlanner, rerenderShell: () => void, tabId: string) {
+ ;(planner as { activeTab: string }).activeTab = tabId
+ rerenderShell()
+ }
+
+ const rtSwitch = () => screen.getByRole('button', { name: 'mobileTrip.mapView' })
+ const backToList = () => screen.getByRole('button', { name: 'mobileTrip.listView' })
+
+ it('FE-MOB-SHELL-052: the drive takes the second dock seat and the packing list loses its own', () => {
+ renderShell({ TRIP_TABS: RT_TABS } as Partial)
+ // Second seat, not fourth: the dock orders by its own priority list, so the drive
+ // sits beside the plan however late the hook appends it.
+ expect(dockLabels()).toEqual(['Plan', 'Road trip', 'Transport', 'Bookings', 'Budget', 'mobileTrip.more'])
+ // Six sections would leave 3px between the circles, so the cap cuts the last one
+ // and the More sheet picks it up.
+ expect(screen.queryByRole('button', { name: 'Lists' })).not.toBeInTheDocument()
+ })
+
+ it('FE-MOB-SHELL-053: without the addon the dock is exactly what it was', () => {
+ renderShell()
+ expect(dockLabels()).toEqual(['Plan', 'Transport', 'Bookings', 'Budget', 'Lists', 'mobileTrip.more'])
+ })
+
+ it('FE-MOB-SHELL-054: the map survives the move between the plan tab and the drive', () => {
+ const { planner, rerenderShell } = renderShell({ TRIP_TABS: RT_TABS } as Partial)
+ const map = screen.getByTestId('map-area')
+
+ goToTab(planner, rerenderShell, 'roadtrip')
+ expect(screen.getByTestId('tab-panel')).toHaveTextContent('roadtrip')
+ // Identity, not presence. Written as two JSX positions the map remounts here,
+ // which tears down the WebGL context and reloads every tile, a flash on the
+ // device that a presence check would happily call a pass.
+ expect(screen.getByTestId('map-area')).toBe(map)
+
+ goToTab(planner, rerenderShell, 'plan')
+ expect(screen.getByTestId('map-area')).toBe(map)
+ expect(screen.getByTestId('plan-timeline')).toBeInTheDocument()
+ })
+
+ it('FE-MOB-SHELL-055: a tab with no map still tears it down', () => {
+ const { planner, rerenderShell } = renderShell({ TRIP_TABS: RT_TABS, activeTab: 'roadtrip' } as Partial)
+ expect(screen.getByTestId('map-area')).toBeInTheDocument()
+
+ goToTab(planner, rerenderShell, 'transports')
+
+ expect(screen.queryByTestId('map-area')).not.toBeInTheDocument()
+ expect(screen.getByTestId('tab-panel')).toHaveTextContent('transports')
+ })
+
+ it('FE-MOB-SHELL-056: the switch in the drive flips its own half, not the plan view', () => {
+ renderShell({ TRIP_TABS: RT_TABS, activeTab: 'roadtrip' } as Partial)
+ expect(shellApi.mapFront).toBe(false)
+
+ fireEvent.click(rtSwitch())
+ expect(shellApi.rtView).toBe('map')
+ expect(shellApi.mapFront).toBe(true)
+ expect(shellApi.view).toBe('plan')
+
+ fireEvent.click(backToList())
+ expect(shellApi.rtView).toBe('list')
+ expect(shellApi.mapFront).toBe(false)
+ })
+
+ it('FE-MOB-SHELL-057: the drive switch draws the route and otherwise leaves the camera alone', () => {
+ const { planner } = renderShell({
+ TRIP_TABS: RT_TABS, activeTab: 'roadtrip', selectedDayId: null,
+ } as Partial)
+
+ fireEvent.click(rtSwitch())
+ expect(planner.autoShowRoute).toHaveBeenCalledTimes(1)
+ // Deliberately quieter than the plan tab's toggle: the stage is always one day,
+ // both halves show the same one, and the camera belongs to whoever looked last.
+ expect(planner.handleSelectDay).not.toHaveBeenCalled()
+ expect(planner.setExpandedDayIds).not.toHaveBeenCalled()
+
+ fireEvent.click(backToList())
+ // And no day restore on the way out, which is what the plan tab does here.
+ expect(planner.handleSelectDay).not.toHaveBeenCalled()
+ expect(planner.setExpandedDayIds).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-SHELL-058: the two halves are separate states, so neither tab drags the other into its own', () => {
+ const { planner, rerenderShell } = renderShell({ TRIP_TABS: RT_TABS } as Partial)
+
+ fireEvent.click(rtSwitch())
+ expect(shellApi.view).toBe('map')
+ expect(shellApi.rtView).toBe('list')
+ expect(shellApi.mapFront).toBe(true)
+
+ goToTab(planner, rerenderShell, 'roadtrip')
+ // The plan tab is still on its map; the drive opens on its chain regardless.
+ expect(shellApi.view).toBe('map')
+ expect(shellApi.mapFront).toBe(false)
+ expect(screen.getByRole('button', { name: 'mobileTrip.mapView' })).toBeInTheDocument()
+ })
+
+ it('FE-MOB-SHELL-059: the all-days button follows the map into the drive', () => {
+ const { planner } = renderShell({ TRIP_TABS: RT_TABS, activeTab: 'roadtrip' } as Partial)
+ expect(screen.queryByRole('button', { name: 'mobileTrip.allDays' })).not.toBeInTheDocument()
+
+ fireEvent.click(rtSwitch())
+ vi.mocked(planner.setExpandedDayIds).mockClear()
+ fireEvent.click(screen.getByRole('button', { name: 'mobileTrip.allDays' }))
+
+ // The whole drive in its day colours, off the same handler the plan map uses.
+ expect(planner.handleSelectDay).toHaveBeenLastCalledWith(null, false)
+ expect(planner.setExpandedDayIds).toHaveBeenCalledWith(null)
+ })
+
+ it('FE-MOB-SHELL-060: the stage header opens the driving settings', () => {
+ renderShell({ TRIP_TABS: RT_TABS, activeTab: 'roadtrip' } as Partial)
+ fireEvent.click(screen.getByRole('button', { name: 'roadtrip.title' }))
+ expect(shellApi.sheet).toEqual({ id: 'rtinfo', payload: undefined })
+ })
+
+ it('FE-MOB-SHELL-061: the stage header names the day on screen and what it costs', () => {
+ renderShell({
+ TRIP_TABS: RT_TABS,
+ activeTab: 'roadtrip',
+ roadtripRoutes: { days: [stage()], totalDistance: 240000 },
+ } as unknown as Partial)
+ // The stage beats the total: 123 km is today's drive, 240 km is the whole trip.
+ expect(screen.getByRole('button', { name: 'Sat 2 · 123 km' })).toBeInTheDocument()
+ })
+
+ it('FE-MOB-SHELL-062: with the day filter off it reads the whole drive', () => {
+ renderShell({
+ TRIP_TABS: RT_TABS,
+ activeTab: 'roadtrip',
+ selectedDayId: null,
+ roadtripRoutes: { days: [stage()], totalDistance: 240000 },
+ } as unknown as Partial)
+ expect(screen.getByRole('button', { name: '240 km' })).toBeInTheDocument()
+ })
+
+ it('FE-MOB-SHELL-063: before anything has routed it falls back to the addon name, and the plan tab never shows it', () => {
+ const { planner, rerenderShell } = renderShell({ TRIP_TABS: RT_TABS, activeTab: 'roadtrip' } as Partial)
+ // Never an empty figure: the header is the only way into the driving settings.
+ expect(screen.getByRole('button', { name: 'roadtrip.title' })).toBeInTheDocument()
+
+ goToTab(planner, rerenderShell, 'plan')
+ expect(screen.queryByRole('button', { name: 'roadtrip.title' })).not.toBeInTheDocument()
+ })
+
+ it('FE-MOB-SHELL-064: a stage whose day the rail has lost still reads as a day number', () => {
+ // A remote day:deleted can retire the day while its legs are still on screen.
+ renderShell({
+ TRIP_TABS: RT_TABS,
+ activeTab: 'roadtrip',
+ selectedDayId: 99,
+ roadtripRoutes: { days: [stage({ dayId: 99, dayNumber: 4, distance: 50000 })], totalDistance: 240000 },
+ } as unknown as Partial)
+ expect(screen.getByRole('button', { name: 'planner.dayN:4 · 50 km' })).toBeInTheDocument()
+ })
+ })
})
describe('buildShell fixture', () => {
diff --git a/client/tests/unit/mobile/trip/useMRtCorridor.test.ts b/client/tests/unit/mobile/trip/useMRtCorridor.test.ts
new file mode 100644
index 000000000..aecf98fac
--- /dev/null
+++ b/client/tests/unit/mobile/trip/useMRtCorridor.test.ts
@@ -0,0 +1,218 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { act, renderHook } from '@testing-library/react'
+import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
+import { useMRtCorridor } from '../../../../src/mobile/screens/trip/roadtrip/useMRtCorridor'
+import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
+import type { RoadtripCorridor } from '../../../../src/components/Roadtrip/useRoadtripCorridor'
+import type { Day } from '../../../../src/types'
+import type { RoadtripDay, RoadtripRoutes } from '@trek/shared/roadtrip'
+
+// FE-MOB-RTCOR-001 to FE-MOB-RTCOR-012
+
+const mocks = vi.hoisted(() => ({ offline: false }))
+vi.mock('../../../../src/hooks/useNetworkMode', () => ({
+ useNetworkMode: () => ({ offline: mocks.offline, forced: false, setForced: vi.fn() }),
+}))
+
+const DAYS = [
+ { id: 1, trip_id: 7, day_number: 1, date: '2026-05-01', title: null },
+ { id: 2, trip_id: 7, day_number: 2, date: '2026-05-02', title: null },
+] as unknown as Day[]
+
+/** Four stops with clocks on them, so "which one is next" has an answer. */
+function stage(over: Partial = {}): RoadtripDay {
+ return {
+ dayId: 2,
+ dayNumber: 2,
+ date: '2026-05-02',
+ stops: [
+ { assignmentId: 501, ownerDayId: 2, ownerIndex: 0, placeId: 101, name: 'Start', lat: 53.55, lng: 9.99, time: null, dwellMinutes: 0, legMode: null, incomingLegMode: null, stopType: null },
+ { assignmentId: 502, ownerDayId: 2, ownerIndex: 1, placeId: 102, name: 'Middle', lat: 53.5, lng: 9.8, time: null, dwellMinutes: 0, legMode: null, incomingLegMode: null, stopType: null },
+ { assignmentId: 503, ownerDayId: 2, ownerIndex: 2, placeId: 103, name: 'End', lat: 53.45, lng: 9.6, time: null, dwellMinutes: 0, legMode: null, incomingLegMode: null, stopType: null },
+ ],
+ legs: [],
+ schedule: {
+ entries: [
+ { arrival: '08:00', departure: '08:00', anchored: true, dayOffset: 0 },
+ { arrival: '10:00', departure: '10:00', anchored: false, dayOffset: 0 },
+ { arrival: '12:00', departure: null, anchored: false, dayOffset: 0 },
+ ],
+ warnings: [],
+ },
+ legVias: [], geometry: [], distance: 300_000, duration: 14_400,
+ driveWarnings: [], dayWarning: null, spills: [], dryPoints: [],
+ ...over,
+ } as unknown as RoadtripDay
+}
+
+function routes(over: Partial = {}): RoadtripRoutes {
+ return {
+ days: [stage()],
+ quietDays: [], lines: [], lineDays: [], accessLines: [], vias: [], segments: [],
+ totalDistance: 300_000, totalDuration: 14_400, totalStops: 3, loading: false,
+ ...over,
+ } as unknown as RoadtripRoutes
+}
+
+function corridor(over: Partial = {}): RoadtripCorridor {
+ const base = buildPlanner().roadtripCorridor as RoadtripCorridor
+ return {
+ ...base,
+ // Two points is what `canSearch` reads as "there is a drive here".
+ search: { ...base.search, spine: [{ lat: 53.55, lng: 9.99 }, { lat: 53.45, lng: 9.6 }] },
+ stopsAlongKm: [0, 40, 95],
+ ...over,
+ }
+}
+
+function planner(over: Partial = {}): TripPlanner {
+ return buildPlanner({
+ tripId: 7,
+ days: DAYS,
+ selectedDayId: 2,
+ roadtripRoutes: routes(),
+ roadtripCorridor: corridor(),
+ ...over,
+ } as Partial)
+}
+
+function setup(p: TripPlanner = planner(), shell: MTripShellApi = buildShell()) {
+ const { result, rerender } = renderHook(() => useMRtCorridor(p, shell))
+ return { result, rerender, planner: p, shell }
+}
+
+/** Freezes the clock on the stage's own date, whatever zone the runner is in. */
+function freezeAt(hour: number, minute: number): TripPlanner {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date(2026, 4, 2, hour, minute))
+ const date = new Date().toISOString().slice(0, 10)
+ return planner({
+ days: DAYS.map(d => (d.id === 2 ? { ...d, date } : d)) as unknown as Day[],
+ roadtripRoutes: routes({ days: [stage({ date })] }),
+ })
+}
+
+beforeEach(() => { mocks.offline = false })
+afterEach(() => { vi.useRealTimers() })
+
+describe('useMRtCorridor', () => {
+ it('FE-MOB-RTCOR-001: searches the stage the chip rail is on', () => {
+ const p = planner()
+ setup(p)
+
+ expect(p.roadtripCorridor.setDayId).toHaveBeenCalledWith('2')
+ })
+
+ it('FE-MOB-RTCOR-002: a trip with nothing routed names no day at all', () => {
+ const p = planner({ roadtripRoutes: routes({ days: [] }) })
+ setup(p)
+
+ // Not '' and not the first day of the trip: there is no drive to search, and
+ // writing a day id anyway would make the desktop picker jump on the next render.
+ expect(p.roadtripCorridor.setDayId).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-RTCOR-003: asks about the stretch ahead, measured from the stop already passed', () => {
+ // 11:00 on the stage's own date: the 12:00 stop is next, so the 10:00 one is behind
+ // and the road still to drive starts at its 40 km.
+ const p = freezeAt(11, 0)
+ const { result } = setup(p)
+
+ expect(result.current.anchored).toBe(true)
+ act(() => { result.current.run() })
+ expect(p.roadtripCorridor.search.search).toHaveBeenCalledWith({ fromKm: 40, toKm: 90 })
+ })
+
+ it('FE-MOB-RTCOR-004: on any other day it starts at the beginning of the stage', () => {
+ // No clock reading to go on. A guess dressed up as a position would put the search
+ // in the wrong half of the drive, so it asks about the first fifty kilometres.
+ const p = planner()
+ const { result } = setup(p)
+
+ expect(result.current.anchored).toBe(false)
+ act(() => { result.current.run() })
+ expect(p.roadtripCorridor.search.search).toHaveBeenCalledWith({ fromKm: 0, toKm: 50 })
+ })
+
+ it('FE-MOB-RTCOR-005: before the first stop is behind, ahead is the whole stage from zero', () => {
+ const p = freezeAt(7, 30)
+ const { result } = setup(p)
+
+ expect(result.current.anchored).toBe(false)
+ act(() => { result.current.run() })
+ expect(p.roadtripCorridor.search.search).toHaveBeenCalledWith({ fromKm: 0, toKm: 50 })
+ })
+
+ it('FE-MOB-RTCOR-006: a stage search asks about the whole day, with no window at all', () => {
+ const p = freezeAt(11, 0)
+ const { result } = setup(p, buildShell({ rtReach: 'stage' }))
+
+ act(() => { result.current.run() })
+ expect(p.roadtripCorridor.search.search).toHaveBeenCalledWith(null)
+ })
+
+ it('FE-MOB-RTCOR-007: changing the reach throws the answer away', () => {
+ const p = planner()
+ const shell = buildShell()
+ const { result } = setup(p, shell)
+
+ act(() => { result.current.setReach('stage') })
+
+ expect(shell.setRtReach).toHaveBeenCalledWith('stage')
+ // Hits from the fifty kilometres ahead are not an answer to "the whole stage".
+ expect(p.roadtripCorridor.clear).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-MOB-RTCOR-008: picking the reach it is already on changes nothing', () => {
+ const p = planner()
+ const shell = buildShell()
+ const { result } = setup(p, shell)
+
+ act(() => { result.current.setReach('ahead') })
+
+ expect(shell.setRtReach).not.toHaveBeenCalled()
+ expect(p.roadtripCorridor.clear).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-RTCOR-009: offline it cannot search, and pressing it anyway does nothing', () => {
+ mocks.offline = true
+ const p = planner()
+ const { result } = setup(p)
+
+ expect(result.current.offline).toBe(true)
+ expect(result.current.canSearch).toBe(false)
+ act(() => { result.current.run() })
+ expect(p.roadtripCorridor.search.search).not.toHaveBeenCalled()
+ })
+
+ it('FE-MOB-RTCOR-010: without a category or a routed line there is nothing to ask', () => {
+ const noCategory = setup(planner({ roadtripCorridor: corridor({ categories: [] }) }))
+ expect(noCategory.result.current.canSearch).toBe(false)
+
+ const base = buildPlanner().roadtripCorridor as RoadtripCorridor
+ const noLine = setup(planner({ roadtripCorridor: corridor({ search: { ...base.search, spine: [] } }) }))
+ expect(noLine.result.current.canSearch).toBe(false)
+ })
+
+ it('FE-MOB-RTCOR-011: what the map draws is what the sheet lists', () => {
+ const visible = [{ osm_id: 'a' }] as unknown as RoadtripCorridor['visible']
+ const { result } = setup(planner({ roadtripCorridor: corridor({ visible }) }))
+
+ // One array, not two filtered views of one list: the pins and the rows have to be
+ // the same set or a row points at a pin that is not there.
+ expect(result.current.hits).toBe(visible)
+ })
+
+ it('FE-MOB-RTCOR-012: "answered" needs a search to have run, not just an empty list', () => {
+ const base = buildPlanner().roadtripCorridor as RoadtripCorridor
+ const idle = setup(planner())
+ expect(idle.result.current.answered).toBe(false)
+
+ const ran = setup(planner({
+ roadtripCorridor: corridor({ search: { ...base.search, progress: { done: 3, total: 3 } } }),
+ }))
+ // Nothing found is an answer. Reading it as "nothing asked yet" would hide the one
+ // sentence that says so.
+ expect(ran.result.current.answered).toBe(true)
+ })
+})
diff --git a/shared/src/i18n/ar/map.ts b/shared/src/i18n/ar/map.ts
index c2b148c7a..994a58b10 100644
--- a/shared/src/i18n/ar/map.ts
+++ b/shared/src/i18n/ar/map.ts
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': 'عرض الرحلة كاملة',
'map.overview.hide': 'إخفاء الرحلة كاملة',
'map.overview.total': 'المسافة الإجمالية',
+ 'map.attribution': 'مصادر الخريطة',
};
export default map;
diff --git a/shared/src/i18n/ar/mobileTrip.ts b/shared/src/i18n/ar/mobileTrip.ts
index f4d75a64c..dacab2ed4 100644
--- a/shared/src/i18n/ar/mobileTrip.ts
+++ b/shared/src/i18n/ar/mobileTrip.ts
@@ -47,5 +47,22 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.upNext': 'التالي',
'mobileTrip.viewDetails': 'عرض التفاصيل',
'mobileTrip.transportsEmpty': 'لا يوجد نقل بعد',
+ 'mobileTrip.rtInfoTitle': 'أرقام القيادة',
+ 'mobileTrip.rtDesktopNote':
+ 'تُضبط هذه الأرقام على سطح المكتب، حيث يمكنك أيضًا مقارنة المسارات البديلة، وجعل يوم يتبع مسارًا مستوردًا.',
+ 'mobileTrip.rtPlanOnDesktop': 'يتم التخطيط على سطح المكتب. يحسب TREK القيادة بمجرد أن يضم اليوم مكانين.',
+ 'mobileTrip.rtSearchOffline': 'يلزم اتصال بالإنترنت: يقرأ البحث المسار الذي أمامك.',
+ 'mobileTrip.rtBehind': '{time} تأخرًا عن الخطة',
+ 'mobileTrip.rtStayScope': 'مدة التوقف تخص المكان نفسه، لذا تُحتسب في كل يوم تكون فيه هذه المحطة ضمن الخطة.',
+ 'mobileTrip.rtStayLess': '{count} دقيقة أقل',
+ 'mobileTrip.rtStayMore': '{count} دقيقة أكثر',
+ 'mobileTrip.rtNightDesktopOnly': 'إضافة مبيت في هذا المكان تتم في مخطط سطح المكتب. هنا يمكنك إزالته فقط.',
+ 'mobileTrip.rtReach': 'إلى أي مدى',
+ 'mobileTrip.rtReachAhead': '{distance} أمامك',
+ 'mobileTrip.rtFromNext': 'من محطتك التالية',
+ 'mobileTrip.rtFromStart': 'من بداية المرحلة',
+ 'mobileTrip.rtNoneAhead': 'لا شيء على الطريق أمامك. جرّب المرحلة كاملة.',
+ 'mobileTrip.rtNoneOnStage': 'لا شيء من هذا النوع على طول هذه المرحلة.',
+ 'mobileTrip.rtTruncated': '{count} مقاطع فيها أكثر مما يتسع في إجابة واحدة. اختر أنواعًا أقل لرؤية الباقي.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/br/map.ts b/shared/src/i18n/br/map.ts
index 6fca93b95..d14722b7b 100644
--- a/shared/src/i18n/br/map.ts
+++ b/shared/src/i18n/br/map.ts
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': 'Mostrar a viagem inteira',
'map.overview.hide': 'Ocultar a viagem inteira',
'map.overview.total': 'Distância total',
+ 'map.attribution': 'Créditos do mapa',
};
export default map;
diff --git a/shared/src/i18n/br/mobileTrip.ts b/shared/src/i18n/br/mobileTrip.ts
index fdd948d38..a70053ab0 100644
--- a/shared/src/i18n/br/mobileTrip.ts
+++ b/shared/src/i18n/br/mobileTrip.ts
@@ -47,5 +47,26 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.upNext': 'A seguir',
'mobileTrip.viewDetails': 'Ver detalhes',
'mobileTrip.transportsEmpty': 'Nenhum transporte ainda',
+ 'mobileTrip.rtInfoTitle': 'Dados de condução',
+ 'mobileTrip.rtDesktopNote':
+ 'Esses dados são definidos no computador, onde você também pode comparar rotas alternativas e fazer um dia seguir uma trilha importada.',
+ 'mobileTrip.rtPlanOnDesktop':
+ 'O planejamento acontece no computador. O TREK calcula o trajeto assim que um dia tem dois locais.',
+ 'mobileTrip.rtSearchOffline': 'Precisa de conexão: a busca lê a rota à frente.',
+ 'mobileTrip.rtBehind': '{time} de atraso em relação ao plano',
+ 'mobileTrip.rtStayScope':
+ 'A estadia pertence ao local, então ela vale em todos os dias em que esta parada está planejada.',
+ 'mobileTrip.rtStayLess': '{count} minutos a menos',
+ 'mobileTrip.rtStayMore': '{count} minutos a mais',
+ 'mobileTrip.rtNightDesktopOnly':
+ 'Reservar um pernoite neste local funciona no planejador do computador. Aqui você só pode descartá-lo.',
+ 'mobileTrip.rtReach': 'Até onde',
+ 'mobileTrip.rtReachAhead': '{distance} à frente',
+ 'mobileTrip.rtFromNext': 'A partir da sua próxima parada',
+ 'mobileTrip.rtFromStart': 'A partir do início da etapa',
+ 'mobileTrip.rtNoneAhead': 'Nada na estrada à frente. Tente a etapa inteira.',
+ 'mobileTrip.rtNoneOnStage': 'Nada desse tipo ao longo desta etapa.',
+ 'mobileTrip.rtTruncated':
+ '{count} trechos tinham mais do que cabe em uma resposta. Escolha menos tipos para ver o resto.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/ca/map.ts b/shared/src/i18n/ca/map.ts
index e0bf1b992..a5ec3b491 100644
--- a/shared/src/i18n/ca/map.ts
+++ b/shared/src/i18n/ca/map.ts
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': 'Mostra tot el viatge',
'map.overview.hide': 'Amaga tot el viatge',
'map.overview.total': 'Distància total',
+ 'map.attribution': 'Crèdits del mapa',
};
export default map;
diff --git a/shared/src/i18n/ca/mobileTrip.ts b/shared/src/i18n/ca/mobileTrip.ts
index e2d94b94b..23eb96188 100644
--- a/shared/src/i18n/ca/mobileTrip.ts
+++ b/shared/src/i18n/ca/mobileTrip.ts
@@ -47,6 +47,27 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.upNext': 'A continuació',
'mobileTrip.viewDetails': 'Mostra els detalls',
'mobileTrip.transportsEmpty': 'Encara no hi ha transport',
+ 'mobileTrip.rtInfoTitle': 'Dades de conducció',
+ 'mobileTrip.rtDesktopNote':
+ 'Aquestes dades es configuren a l’escriptori, on també pots comparar rutes alternatives i fer que un dia segueixi una traça importada.',
+ 'mobileTrip.rtPlanOnDesktop':
+ 'La planificació es fa a l’escriptori. TREK calcula el trajecte quan un dia té dos llocs.',
+ 'mobileTrip.rtSearchOffline': 'Cal connexió: la cerca llegeix la ruta que tens per endavant.',
+ 'mobileTrip.rtBehind': '{time} de retard sobre el pla',
+ 'mobileTrip.rtStayScope':
+ 'L’estada pertany al lloc, així que compta tots els dies en què aquesta parada està planificada.',
+ 'mobileTrip.rtStayLess': '{count} minuts menys',
+ 'mobileTrip.rtStayMore': '{count} minuts més',
+ 'mobileTrip.rtNightDesktopOnly':
+ 'Reservar una pernoctació en aquest lloc es fa al planificador d’escriptori. Aquí només la pots descartar.',
+ 'mobileTrip.rtReach': 'Fins on',
+ 'mobileTrip.rtReachAhead': '{distance} per endavant',
+ 'mobileTrip.rtFromNext': 'Des de la teva propera parada',
+ 'mobileTrip.rtFromStart': 'Des de l’inici de l’etapa',
+ 'mobileTrip.rtNoneAhead': 'No hi ha res a la carretera que tens al davant. Prova tota l’etapa.',
+ 'mobileTrip.rtNoneOnStage': 'No hi ha res d’aquest tipus al llarg d’aquesta etapa.',
+ 'mobileTrip.rtTruncated':
+ '{count} trams tenien més del que cap en una resposta. Tria menys tipus per veure la resta.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/cs/map.ts b/shared/src/i18n/cs/map.ts
index c12aea4db..4d834319e 100644
--- a/shared/src/i18n/cs/map.ts
+++ b/shared/src/i18n/cs/map.ts
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': 'Zobrazit celou cestu',
'map.overview.hide': 'Skrýt celou cestu',
'map.overview.total': 'Celková vzdálenost',
+ 'map.attribution': 'Zdroje mapy',
};
export default map;
diff --git a/shared/src/i18n/cs/mobileTrip.ts b/shared/src/i18n/cs/mobileTrip.ts
index 5ca305d1b..78a878491 100644
--- a/shared/src/i18n/cs/mobileTrip.ts
+++ b/shared/src/i18n/cs/mobileTrip.ts
@@ -47,5 +47,25 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.upNext': 'Další na řadě',
'mobileTrip.viewDetails': 'Zobrazit podrobnosti',
'mobileTrip.transportsEmpty': 'Zatím žádná doprava',
+ 'mobileTrip.rtInfoTitle': 'Údaje o jízdě',
+ 'mobileTrip.rtDesktopNote':
+ 'Tyto údaje se nastavují na počítači, kde také můžete porovnávat jiné trasy a nechat den sledovat importovanou stopu.',
+ 'mobileTrip.rtPlanOnDesktop': 'Plánování probíhá na počítači. Jakmile má den dvě místa, TREK jízdu spočítá.',
+ 'mobileTrip.rtSearchOffline': 'Vyžaduje připojení: hledání čte trasu před vámi.',
+ 'mobileTrip.rtBehind': 'o {time} za plánem',
+ 'mobileTrip.rtStayScope':
+ 'Doba zastávky patří k místu, takže platí pro každý den, na který je tato zastávka naplánovaná.',
+ 'mobileTrip.rtStayLess': 'o {count} minut méně',
+ 'mobileTrip.rtStayMore': 'o {count} minut více',
+ 'mobileTrip.rtNightDesktopOnly':
+ 'Nocleh na tomto místě se zadává v plánovači na počítači. Tady jej můžete jen zrušit.',
+ 'mobileTrip.rtReach': 'Jak daleko',
+ 'mobileTrip.rtReachAhead': '{distance} před vámi',
+ 'mobileTrip.rtFromNext': 'Od vaší příští zastávky',
+ 'mobileTrip.rtFromStart': 'Od začátku etapy',
+ 'mobileTrip.rtNoneAhead': 'Na cestě před vámi nic není. Zkuste celou etapu.',
+ 'mobileTrip.rtNoneOnStage': 'Nic takového podél této etapy není.',
+ 'mobileTrip.rtTruncated':
+ '{count} úseků mělo víc, než se vejde do jedné odpovědi. Vyberte méně druhů, ať uvidíte zbytek.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/de/map.ts b/shared/src/i18n/de/map.ts
index a4b0e874b..baa750e0a 100644
--- a/shared/src/i18n/de/map.ts
+++ b/shared/src/i18n/de/map.ts
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': 'Ganze Reise anzeigen',
'map.overview.hide': 'Ganze Reise ausblenden',
'map.overview.total': 'Gesamtstrecke',
+ 'map.attribution': 'Kartenquellen',
};
export default map;
diff --git a/shared/src/i18n/de/mobileTrip.ts b/shared/src/i18n/de/mobileTrip.ts
index 2eaf8a02f..d1b046f8c 100644
--- a/shared/src/i18n/de/mobileTrip.ts
+++ b/shared/src/i18n/de/mobileTrip.ts
@@ -47,5 +47,25 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.upNext': 'Als Nächstes',
'mobileTrip.viewDetails': 'Details anzeigen',
'mobileTrip.transportsEmpty': 'Noch keine Transporte',
+ 'mobileTrip.rtInfoTitle': 'Fahrdaten',
+ 'mobileTrip.rtDesktopNote':
+ 'Diese Werte werden am Desktop gesetzt. Dort kannst du auch Routenvarianten vergleichen und einen Tag einer importierten Spur folgen lassen.',
+ 'mobileTrip.rtPlanOnDesktop': 'Geplant wird am Desktop. TREK berechnet die Fahrt, sobald ein Tag zwei Orte hat.',
+ 'mobileTrip.rtSearchOffline': 'Braucht eine Verbindung: Die Suche liest die Strecke, die vor dir liegt.',
+ 'mobileTrip.rtBehind': '{time} hinter dem Plan',
+ 'mobileTrip.rtStayScope':
+ 'Der Aufenthalt gehört zum Ort und zählt deshalb an jedem Tag, an dem dieser Stopp geplant ist.',
+ 'mobileTrip.rtStayLess': '{count} Minuten weniger',
+ 'mobileTrip.rtStayMore': '{count} Minuten mehr',
+ 'mobileTrip.rtNightDesktopOnly':
+ 'Eine Übernachtung an diesem Ort legst du im Desktop-Planer an. Hier kannst du sie nur verwerfen.',
+ 'mobileTrip.rtReach': 'Wie weit',
+ 'mobileTrip.rtReachAhead': '{distance} voraus',
+ 'mobileTrip.rtFromNext': 'Ab deinem nächsten Stopp',
+ 'mobileTrip.rtFromStart': 'Ab dem Anfang der Etappe',
+ 'mobileTrip.rtNoneAhead': 'Auf der Strecke voraus ist nichts. Versuch die ganze Etappe.',
+ 'mobileTrip.rtNoneOnStage': 'Auf dieser Etappe gibt es davon nichts.',
+ 'mobileTrip.rtTruncated':
+ '{count} Abschnitte hatten mehr, als in eine Antwort passt. Wähl weniger Arten, um den Rest zu sehen.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/en/map.ts b/shared/src/i18n/en/map.ts
index b14c9b528..26f01b3ad 100644
--- a/shared/src/i18n/en/map.ts
+++ b/shared/src/i18n/en/map.ts
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': 'Show whole trip',
'map.overview.hide': 'Hide whole trip',
'map.overview.total': 'Total distance',
+ 'map.attribution': 'Map credits',
};
export default map;
diff --git a/shared/src/i18n/en/mobileTrip.ts b/shared/src/i18n/en/mobileTrip.ts
index b393619f5..14edf03d5 100644
--- a/shared/src/i18n/en/mobileTrip.ts
+++ b/shared/src/i18n/en/mobileTrip.ts
@@ -47,5 +47,24 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.upNext': 'Up Next',
'mobileTrip.viewDetails': 'View details',
'mobileTrip.transportsEmpty': 'No transport yet',
+ 'mobileTrip.rtInfoTitle': 'Driving figures',
+ 'mobileTrip.rtDesktopNote':
+ 'These figures are set on the desktop, where you can also compare route alternatives and make a day follow an imported track.',
+ 'mobileTrip.rtPlanOnDesktop': 'Planning happens on the desktop. TREK works out the drive once a day has two places.',
+ 'mobileTrip.rtSearchOffline': 'Needs a connection: the search reads the route ahead.',
+ 'mobileTrip.rtBehind': '{time} behind plan',
+ 'mobileTrip.rtStayScope': 'The stay belongs to the place, so it counts on every day this stop is planned.',
+ 'mobileTrip.rtStayLess': '{count} minutes less',
+ 'mobileTrip.rtStayMore': '{count} minutes more',
+ 'mobileTrip.rtNightDesktopOnly':
+ 'Booking a night at this place works in the desktop planner. Here you can only discard it.',
+ 'mobileTrip.rtReach': 'How far',
+ 'mobileTrip.rtReachAhead': '{distance} ahead',
+ 'mobileTrip.rtFromNext': 'From your next stop',
+ 'mobileTrip.rtFromStart': 'From the start of the stage',
+ 'mobileTrip.rtNoneAhead': 'Nothing on the road ahead. Try the whole stage.',
+ 'mobileTrip.rtNoneOnStage': 'Nothing of that kind along this stage.',
+ 'mobileTrip.rtTruncated':
+ '{count} stretches had more than fits in one answer. Pick fewer kinds to see the rest.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/es/map.ts b/shared/src/i18n/es/map.ts
index 9478b1a0d..13dbf234d 100644
--- a/shared/src/i18n/es/map.ts
+++ b/shared/src/i18n/es/map.ts
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': 'Mostrar todo el viaje',
'map.overview.hide': 'Ocultar todo el viaje',
'map.overview.total': 'Distancia total',
+ 'map.attribution': 'Créditos del mapa',
};
export default map;
diff --git a/shared/src/i18n/es/mobileTrip.ts b/shared/src/i18n/es/mobileTrip.ts
index 376586bd0..0b5740eec 100644
--- a/shared/src/i18n/es/mobileTrip.ts
+++ b/shared/src/i18n/es/mobileTrip.ts
@@ -47,5 +47,26 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.upNext': 'A continuación',
'mobileTrip.viewDetails': 'Ver detalles',
'mobileTrip.transportsEmpty': 'Aún no hay transporte',
+ 'mobileTrip.rtInfoTitle': 'Cifras del trayecto',
+ 'mobileTrip.rtDesktopNote':
+ 'Estas cifras se ajustan en el escritorio, donde también puedes comparar rutas alternativas y hacer que un día siga una traza importada.',
+ 'mobileTrip.rtPlanOnDesktop':
+ 'La planificación se hace en el escritorio. TREK calcula el trayecto en cuanto un día tiene dos lugares.',
+ 'mobileTrip.rtSearchOffline': 'Hace falta conexión: la búsqueda lee la ruta que queda por delante.',
+ 'mobileTrip.rtBehind': '{time} de retraso sobre lo previsto',
+ 'mobileTrip.rtStayScope':
+ 'La duración de la parada pertenece al lugar, así que cuenta en cada día en el que esta parada está prevista.',
+ 'mobileTrip.rtStayLess': '{count} minutos menos',
+ 'mobileTrip.rtStayMore': '{count} minutos más',
+ 'mobileTrip.rtNightDesktopOnly':
+ 'Reservar una pernoctación en este lugar se hace en el planificador de escritorio. Aquí solo puedes descartarla.',
+ 'mobileTrip.rtReach': 'Hasta dónde',
+ 'mobileTrip.rtReachAhead': '{distance} por delante',
+ 'mobileTrip.rtFromNext': 'Desde tu próxima parada',
+ 'mobileTrip.rtFromStart': 'Desde el inicio de la etapa',
+ 'mobileTrip.rtNoneAhead': 'No hay nada en la carretera que tienes por delante. Prueba con toda la etapa.',
+ 'mobileTrip.rtNoneOnStage': 'No hay nada de eso a lo largo de esta etapa.',
+ 'mobileTrip.rtTruncated':
+ '{count} tramos tenían más de lo que cabe en una respuesta. Elige menos tipos para ver el resto.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/fr/map.ts b/shared/src/i18n/fr/map.ts
index 68854f27d..5b0093622 100644
--- a/shared/src/i18n/fr/map.ts
+++ b/shared/src/i18n/fr/map.ts
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': 'Afficher tout le voyage',
'map.overview.hide': 'Masquer tout le voyage',
'map.overview.total': 'Distance totale',
+ 'map.attribution': 'Crédits de la carte',
};
export default map;
diff --git a/shared/src/i18n/fr/mobileTrip.ts b/shared/src/i18n/fr/mobileTrip.ts
index 369bfb819..2a3c74adc 100644
--- a/shared/src/i18n/fr/mobileTrip.ts
+++ b/shared/src/i18n/fr/mobileTrip.ts
@@ -47,5 +47,26 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.upNext': 'À suivre',
'mobileTrip.viewDetails': 'Voir les détails',
'mobileTrip.transportsEmpty': 'Aucun transport',
+ 'mobileTrip.rtInfoTitle': 'Chiffres du trajet',
+ 'mobileTrip.rtDesktopNote':
+ 'Ces chiffres se règlent sur ordinateur, où vous pouvez aussi comparer d’autres itinéraires et faire suivre à un jour une trace importée.',
+ 'mobileTrip.rtPlanOnDesktop':
+ 'La planification se fait sur ordinateur. TREK calcule le trajet dès qu’un jour compte deux lieux.',
+ 'mobileTrip.rtSearchOffline': 'Connexion nécessaire : la recherche lit l’itinéraire à venir.',
+ 'mobileTrip.rtBehind': '{time} de retard sur le programme',
+ 'mobileTrip.rtStayScope':
+ 'La durée sur place appartient au lieu : elle compte donc pour chaque jour où cet arrêt est prévu.',
+ 'mobileTrip.rtStayLess': '{count} minutes de moins',
+ 'mobileTrip.rtStayMore': '{count} minutes de plus',
+ 'mobileTrip.rtNightDesktopOnly':
+ 'Réserver une nuitée à ce lieu se fait dans le planificateur sur ordinateur. Ici, vous pouvez seulement la supprimer.',
+ 'mobileTrip.rtReach': 'Jusqu’où',
+ 'mobileTrip.rtReachAhead': '{distance} devant',
+ 'mobileTrip.rtFromNext': 'À partir de votre prochain arrêt',
+ 'mobileTrip.rtFromStart': 'Depuis le début de l’étape',
+ 'mobileTrip.rtNoneAhead': 'Rien sur la route devant vous. Essayez toute l’étape.',
+ 'mobileTrip.rtNoneOnStage': 'Rien de ce genre le long de cette étape.',
+ 'mobileTrip.rtTruncated':
+ '{count} tronçons avaient plus que ce qui tient dans une réponse. Choisissez moins de types pour voir le reste.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/gr/map.ts b/shared/src/i18n/gr/map.ts
index 37ee38c72..463bbc831 100644
--- a/shared/src/i18n/gr/map.ts
+++ b/shared/src/i18n/gr/map.ts
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': 'Εμφάνιση όλου του ταξιδιού',
'map.overview.hide': 'Απόκρυψη όλου του ταξιδιού',
'map.overview.total': 'Συνολική απόσταση',
+ 'map.attribution': 'Πηγές χάρτη',
};
export default map;
diff --git a/shared/src/i18n/gr/mobileTrip.ts b/shared/src/i18n/gr/mobileTrip.ts
index c9a531006..08b417981 100644
--- a/shared/src/i18n/gr/mobileTrip.ts
+++ b/shared/src/i18n/gr/mobileTrip.ts
@@ -47,5 +47,26 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.upNext': 'Επόμενο',
'mobileTrip.viewDetails': 'Προβολή λεπτομερειών',
'mobileTrip.transportsEmpty': 'Δεν υπάρχει μεταφορά ακόμη',
+ 'mobileTrip.rtInfoTitle': 'Στοιχεία οδήγησης',
+ 'mobileTrip.rtDesktopNote':
+ 'Αυτά τα στοιχεία ορίζονται στον υπολογιστή, όπου μπορείτε επίσης να συγκρίνετε άλλες διαδρομές και να κάνετε μια ημέρα να ακολουθεί ένα εισαγόμενο ίχνος.',
+ 'mobileTrip.rtPlanOnDesktop':
+ 'Ο σχεδιασμός γίνεται στον υπολογιστή. Το TREK υπολογίζει τη διαδρομή μόλις μια ημέρα έχει δύο μέρη.',
+ 'mobileTrip.rtSearchOffline': 'Χρειάζεται σύνδεση: η αναζήτηση διαβάζει τη διαδρομή που ακολουθεί.',
+ 'mobileTrip.rtBehind': '{time} πίσω από το πρόγραμμα',
+ 'mobileTrip.rtStayScope':
+ 'Η διάρκεια της στάσης ανήκει στο μέρος, οπότε μετράει σε κάθε ημέρα που είναι προγραμματισμένη αυτή η στάση.',
+ 'mobileTrip.rtStayLess': '{count} λεπτά λιγότερα',
+ 'mobileTrip.rtStayMore': '{count} λεπτά περισσότερα',
+ 'mobileTrip.rtNightDesktopOnly':
+ 'Η κράτηση διανυκτέρευσης σε αυτό το μέρος γίνεται στον σχεδιαστή του υπολογιστή. Εδώ μπορείτε μόνο να την αφαιρέσετε.',
+ 'mobileTrip.rtReach': 'Πόσο μακριά',
+ 'mobileTrip.rtReachAhead': '{distance} μπροστά',
+ 'mobileTrip.rtFromNext': 'Από την επόμενη στάση σας',
+ 'mobileTrip.rtFromStart': 'Από την αρχή του σκέλους',
+ 'mobileTrip.rtNoneAhead': 'Τίποτα στον δρόμο μπροστά σας. Δοκιμάστε όλο το σκέλος.',
+ 'mobileTrip.rtNoneOnStage': 'Τίποτα τέτοιο κατά μήκος αυτού του σκέλους.',
+ 'mobileTrip.rtTruncated':
+ '{count} τμήματα είχαν περισσότερα από όσα χωρούν σε μία απάντηση. Διαλέξτε λιγότερα είδη για να δείτε τα υπόλοιπα.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/hu/map.ts b/shared/src/i18n/hu/map.ts
index 4ed177293..a44d5185b 100644
--- a/shared/src/i18n/hu/map.ts
+++ b/shared/src/i18n/hu/map.ts
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': 'Teljes utazás megjelenítése',
'map.overview.hide': 'Teljes utazás elrejtése',
'map.overview.total': 'Teljes távolság',
+ 'map.attribution': 'Térkép forrásai',
};
export default map;
diff --git a/shared/src/i18n/hu/mobileTrip.ts b/shared/src/i18n/hu/mobileTrip.ts
index 97ad3e7b4..19480e475 100644
--- a/shared/src/i18n/hu/mobileTrip.ts
+++ b/shared/src/i18n/hu/mobileTrip.ts
@@ -47,5 +47,26 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.upNext': 'Következő',
'mobileTrip.viewDetails': 'Részletek megtekintése',
'mobileTrip.transportsEmpty': 'Még nincs közlekedés',
+ 'mobileTrip.rtInfoTitle': 'Vezetési adatok',
+ 'mobileTrip.rtDesktopNote':
+ 'Ezeket az adatokat asztali gépen adod meg, ott hasonlíthatod össze az útvonalváltozatokat, és állíthatod be, hogy egy nap importált nyomvonalat kövessen.',
+ 'mobileTrip.rtPlanOnDesktop':
+ 'A tervezés asztali gépen történik. Amint egy napon két hely szerepel, a TREK kiszámolja az útvonalat.',
+ 'mobileTrip.rtSearchOffline': 'Internetkapcsolat kell hozzá: a keresés az előtted lévő útvonalat olvassa be.',
+ 'mobileTrip.rtBehind': '{time} késés a tervhez képest',
+ 'mobileTrip.rtStayScope':
+ 'Az itt töltött idő a helyhez tartozik, ezért minden olyan napon számít, amelyre ez a megálló be van tervezve.',
+ 'mobileTrip.rtStayLess': '{count} perccel kevesebb',
+ 'mobileTrip.rtStayMore': '{count} perccel több',
+ 'mobileTrip.rtNightDesktopOnly':
+ 'Az éjszakázást ezen a helyen az asztali tervezőben lehet megadni. Itt csak elvetni tudod.',
+ 'mobileTrip.rtReach': 'Meddig',
+ 'mobileTrip.rtReachAhead': '{distance} előre',
+ 'mobileTrip.rtFromNext': 'A következő megállódtól',
+ 'mobileTrip.rtFromStart': 'A szakasz elejétől',
+ 'mobileTrip.rtNoneAhead': 'Az előtted lévő úton nincs semmi. Próbáld az egész szakaszt.',
+ 'mobileTrip.rtNoneOnStage': 'Ezen a szakaszon nincs ilyesmi.',
+ 'mobileTrip.rtTruncated':
+ '{count} szakaszon több volt, mint ami egy válaszba fér. Válassz kevesebb fajtát, hogy lásd a többit.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/id/map.ts b/shared/src/i18n/id/map.ts
index 0b41df148..885b8decb 100644
--- a/shared/src/i18n/id/map.ts
+++ b/shared/src/i18n/id/map.ts
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': 'Tampilkan seluruh perjalanan',
'map.overview.hide': 'Sembunyikan seluruh perjalanan',
'map.overview.total': 'Jarak total',
+ 'map.attribution': 'Kredit peta',
};
export default map;
diff --git a/shared/src/i18n/id/mobileTrip.ts b/shared/src/i18n/id/mobileTrip.ts
index 8b8501528..3cdb1873d 100644
--- a/shared/src/i18n/id/mobileTrip.ts
+++ b/shared/src/i18n/id/mobileTrip.ts
@@ -47,5 +47,26 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.upNext': 'Berikutnya',
'mobileTrip.viewDetails': 'Lihat detail',
'mobileTrip.transportsEmpty': 'Belum ada transportasi',
+ 'mobileTrip.rtInfoTitle': 'Data berkendara',
+ 'mobileTrip.rtDesktopNote':
+ 'Data ini diatur di desktop, di sana kamu juga bisa membandingkan rute alternatif dan membuat satu hari mengikuti jalur yang diimpor.',
+ 'mobileTrip.rtPlanOnDesktop':
+ 'Perencanaan dilakukan di desktop. TREK menghitung perjalanannya begitu satu hari punya dua tempat.',
+ 'mobileTrip.rtSearchOffline': 'Perlu koneksi: pencarian membaca rute di depan.',
+ 'mobileTrip.rtBehind': 'terlambat {time} dari rencana',
+ 'mobileTrip.rtStayScope':
+ 'Lama singgah melekat pada tempatnya, jadi ia terhitung di setiap hari perhentian ini direncanakan.',
+ 'mobileTrip.rtStayLess': '{count} menit lebih singkat',
+ 'mobileTrip.rtStayMore': '{count} menit lebih lama',
+ 'mobileTrip.rtNightDesktopOnly':
+ 'Memesan menginap di tempat ini dilakukan di perencana desktop. Di sini kamu hanya bisa menghapusnya.',
+ 'mobileTrip.rtReach': 'Sejauh apa',
+ 'mobileTrip.rtReachAhead': '{distance} ke depan',
+ 'mobileTrip.rtFromNext': 'Dari perhentian berikutnya',
+ 'mobileTrip.rtFromStart': 'Dari awal etape',
+ 'mobileTrip.rtNoneAhead': 'Tidak ada apa-apa di jalan di depan. Coba seluruh etape.',
+ 'mobileTrip.rtNoneOnStage': 'Tidak ada yang seperti itu di sepanjang etape ini.',
+ 'mobileTrip.rtTruncated':
+ '{count} ruas punya lebih banyak daripada yang muat dalam satu jawaban. Pilih lebih sedikit jenis untuk melihat sisanya.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/it/map.ts b/shared/src/i18n/it/map.ts
index 0ada52a73..eed483926 100644
--- a/shared/src/i18n/it/map.ts
+++ b/shared/src/i18n/it/map.ts
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': 'Mostra tutto il viaggio',
'map.overview.hide': 'Nascondi tutto il viaggio',
'map.overview.total': 'Distanza totale',
+ 'map.attribution': 'Crediti della mappa',
};
export default map;
diff --git a/shared/src/i18n/it/mobileTrip.ts b/shared/src/i18n/it/mobileTrip.ts
index 797070110..effc2ef93 100644
--- a/shared/src/i18n/it/mobileTrip.ts
+++ b/shared/src/i18n/it/mobileTrip.ts
@@ -47,5 +47,26 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.upNext': 'Prossimo',
'mobileTrip.viewDetails': 'Vedi dettagli',
'mobileTrip.transportsEmpty': 'Ancora nessun trasporto',
+ 'mobileTrip.rtInfoTitle': 'Dati del tragitto',
+ 'mobileTrip.rtDesktopNote':
+ 'Questi dati si impostano da computer, dove puoi anche confrontare percorsi alternativi e far seguire a un giorno una traccia importata.',
+ 'mobileTrip.rtPlanOnDesktop':
+ 'La pianificazione si fa da computer. TREK calcola il tragitto appena un giorno ha due luoghi.',
+ 'mobileTrip.rtSearchOffline': 'Serve una connessione: la ricerca legge il percorso che hai davanti.',
+ 'mobileTrip.rtBehind': '{time} di ritardo sul programma',
+ 'mobileTrip.rtStayScope':
+ 'La durata della sosta appartiene al luogo, quindi vale in ogni giorno in cui questa tappa è prevista.',
+ 'mobileTrip.rtStayLess': '{count} minuti in meno',
+ 'mobileTrip.rtStayMore': '{count} minuti in più',
+ 'mobileTrip.rtNightDesktopOnly':
+ 'Prenotare un pernottamento in questo luogo si fa nel pianificatore da computer. Qui puoi solo scartarlo.',
+ 'mobileTrip.rtReach': 'Fin dove',
+ 'mobileTrip.rtReachAhead': '{distance} davanti',
+ 'mobileTrip.rtFromNext': 'Dalla tua prossima sosta',
+ 'mobileTrip.rtFromStart': 'Dall’inizio della tappa',
+ 'mobileTrip.rtNoneAhead': 'Niente sulla strada davanti a te. Prova con tutta la tappa.',
+ 'mobileTrip.rtNoneOnStage': 'Niente del genere lungo questa tappa.',
+ 'mobileTrip.rtTruncated':
+ '{count} tratti avevano più di quanto stia in una risposta. Scegli meno tipi per vedere il resto.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/ja/map.ts b/shared/src/i18n/ja/map.ts
index a5d7d3ca9..a4ee235d6 100644
--- a/shared/src/i18n/ja/map.ts
+++ b/shared/src/i18n/ja/map.ts
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': '旅程全体を表示',
'map.overview.hide': '旅程全体を非表示',
'map.overview.total': '合計距離',
+ 'map.attribution': '地図のクレジット',
};
export default map;
diff --git a/shared/src/i18n/ja/mobileTrip.ts b/shared/src/i18n/ja/mobileTrip.ts
index dc6932a53..e10c345b3 100644
--- a/shared/src/i18n/ja/mobileTrip.ts
+++ b/shared/src/i18n/ja/mobileTrip.ts
@@ -47,5 +47,22 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.upNext': '次の予定',
'mobileTrip.viewDetails': '詳細を表示',
'mobileTrip.transportsEmpty': '移動手段はまだありません',
+ 'mobileTrip.rtInfoTitle': '走行データ',
+ 'mobileTrip.rtDesktopNote': 'これらの数値はデスクトップで設定します。デスクトップでは、別のルートを比べたり、読み込んだトラックにその日を沿わせたりもできます。',
+ 'mobileTrip.rtPlanOnDesktop': '計画はデスクトップで行います。1日に場所が2か所そろうと、TREKが走行を計算します。',
+ 'mobileTrip.rtSearchOffline': '接続が必要です:この検索は先のルートを読み取ります。',
+ 'mobileTrip.rtBehind': '予定より {time} 遅れ',
+ 'mobileTrip.rtStayScope': '滞在時間は場所に紐づくため、この立ち寄り先を予定しているすべての日に適用されます。',
+ 'mobileTrip.rtStayLess': '{count} 分減らす',
+ 'mobileTrip.rtStayMore': '{count} 分増やす',
+ 'mobileTrip.rtNightDesktopOnly':
+ 'この場所に宿泊を設定できるのはデスクトップのプランナーです。ここでは取り消すことしかできません。',
+ 'mobileTrip.rtReach': 'どこまで',
+ 'mobileTrip.rtReachAhead': 'この先 {distance}',
+ 'mobileTrip.rtFromNext': '次の立ち寄り先から',
+ 'mobileTrip.rtFromStart': '区間の始まりから',
+ 'mobileTrip.rtNoneAhead': 'この先の道沿いには何もありません。区間全体で試してください。',
+ 'mobileTrip.rtNoneOnStage': 'この区間沿いにはそうした場所はありません。',
+ 'mobileTrip.rtTruncated': '{count} 区間は1回の答えに収まりきりませんでした。種類を減らすと残りも見られます。',
};
export default mobileTrip;
diff --git a/shared/src/i18n/ko/map.ts b/shared/src/i18n/ko/map.ts
index c20e69a9e..585b5d433 100644
--- a/shared/src/i18n/ko/map.ts
+++ b/shared/src/i18n/ko/map.ts
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': '전체 여행 보기',
'map.overview.hide': '전체 여행 숨기기',
'map.overview.total': '총 거리',
+ 'map.attribution': '지도 출처',
};
export default map;
diff --git a/shared/src/i18n/ko/mobileTrip.ts b/shared/src/i18n/ko/mobileTrip.ts
index b863b18ad..b333d8821 100644
--- a/shared/src/i18n/ko/mobileTrip.ts
+++ b/shared/src/i18n/ko/mobileTrip.ts
@@ -47,5 +47,22 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.upNext': '다음 일정',
'mobileTrip.viewDetails': '세부정보 보기',
'mobileTrip.transportsEmpty': '아직 교통편이 없습니다',
+ 'mobileTrip.rtInfoTitle': '주행 수치',
+ 'mobileTrip.rtDesktopNote': '이 수치는 데스크톱에서 설정합니다. 데스크톱에서는 다른 경로를 비교하고, 가져온 트랙을 따라가도록 하루를 맞출 수도 있습니다.',
+ 'mobileTrip.rtPlanOnDesktop': '계획은 데스크톱에서 세웁니다. 하루에 장소가 두 곳 생기면 TREK이 주행을 계산합니다.',
+ 'mobileTrip.rtSearchOffline': '연결이 필요합니다: 이 검색은 앞으로 갈 경로를 읽습니다.',
+ 'mobileTrip.rtBehind': '계획보다 {time} 늦음',
+ 'mobileTrip.rtStayScope': '체류 시간은 장소에 속하므로, 이 경유지가 들어간 모든 날짜에 적용됩니다.',
+ 'mobileTrip.rtStayLess': '{count}분 줄이기',
+ 'mobileTrip.rtStayMore': '{count}분 늘리기',
+ 'mobileTrip.rtNightDesktopOnly':
+ '이 장소에 숙박을 잡는 것은 데스크톱 플래너에서만 됩니다. 여기에서는 취소만 할 수 있습니다.',
+ 'mobileTrip.rtReach': '어디까지',
+ 'mobileTrip.rtReachAhead': '앞으로 {distance}',
+ 'mobileTrip.rtFromNext': '다음 경유지부터',
+ 'mobileTrip.rtFromStart': '구간 시작부터',
+ 'mobileTrip.rtNoneAhead': '앞쪽 도로에는 아무것도 없습니다. 구간 전체로 찾아보세요.',
+ 'mobileTrip.rtNoneOnStage': '이 구간에는 그런 곳이 없습니다.',
+ 'mobileTrip.rtTruncated': '{count}개 구간은 한 번의 답에 담기지 않았습니다. 종류를 줄이면 나머지도 볼 수 있습니다.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/nl/map.ts b/shared/src/i18n/nl/map.ts
index 5083b1925..021eb524d 100644
--- a/shared/src/i18n/nl/map.ts
+++ b/shared/src/i18n/nl/map.ts
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': 'Hele reis tonen',
'map.overview.hide': 'Hele reis verbergen',
'map.overview.total': 'Totale afstand',
+ 'map.attribution': 'Kaartvermeldingen',
};
export default map;
diff --git a/shared/src/i18n/nl/mobileTrip.ts b/shared/src/i18n/nl/mobileTrip.ts
index 56a225b66..2d609b376 100644
--- a/shared/src/i18n/nl/mobileTrip.ts
+++ b/shared/src/i18n/nl/mobileTrip.ts
@@ -47,5 +47,24 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.upNext': 'Volgende',
'mobileTrip.viewDetails': 'Details bekijken',
'mobileTrip.transportsEmpty': 'Nog geen transport',
+ 'mobileTrip.rtInfoTitle': 'Rijgegevens',
+ 'mobileTrip.rtDesktopNote':
+ 'Deze waarden stel je in op de desktop. Daar kun je ook routevarianten vergelijken en een dag een geïmporteerde track laten volgen.',
+ 'mobileTrip.rtPlanOnDesktop': 'Plannen doe je op de desktop. TREK berekent de rit zodra een dag twee plaatsen heeft.',
+ 'mobileTrip.rtSearchOffline': 'Vereist een verbinding: de zoekopdracht leest de route die voor je ligt.',
+ 'mobileTrip.rtBehind': '{time} achter op schema',
+ 'mobileTrip.rtStayScope': 'Het verblijf hoort bij de plaats en telt dus op elke dag waarop deze stop gepland staat.',
+ 'mobileTrip.rtStayLess': '{count} minuten minder',
+ 'mobileTrip.rtStayMore': '{count} minuten meer',
+ 'mobileTrip.rtNightDesktopOnly':
+ 'Een overnachting op deze plaats voeg je toe in de desktopplanner. Hier kun je die alleen verwijderen.',
+ 'mobileTrip.rtReach': 'Hoe ver',
+ 'mobileTrip.rtReachAhead': '{distance} vooruit',
+ 'mobileTrip.rtFromNext': 'Vanaf je volgende stop',
+ 'mobileTrip.rtFromStart': 'Vanaf het begin van de etappe',
+ 'mobileTrip.rtNoneAhead': 'Niets op de weg die voor je ligt. Probeer de hele etappe.',
+ 'mobileTrip.rtNoneOnStage': 'Niets van dat soort langs deze etappe.',
+ 'mobileTrip.rtTruncated':
+ '{count} stukken hadden meer dan in één antwoord past. Kies minder soorten om de rest te zien.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/pl/map.ts b/shared/src/i18n/pl/map.ts
index 59c40db28..4f1619a46 100644
--- a/shared/src/i18n/pl/map.ts
+++ b/shared/src/i18n/pl/map.ts
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': 'Pokaż całą podróż',
'map.overview.hide': 'Ukryj całą podróż',
'map.overview.total': 'Łączny dystans',
+ 'map.attribution': 'Źródła mapy',
};
export default map;
diff --git a/shared/src/i18n/pl/mobileTrip.ts b/shared/src/i18n/pl/mobileTrip.ts
index bbe860f9e..992fd3568 100644
--- a/shared/src/i18n/pl/mobileTrip.ts
+++ b/shared/src/i18n/pl/mobileTrip.ts
@@ -47,5 +47,25 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.upNext': 'Następne',
'mobileTrip.viewDetails': 'Zobacz szczegóły',
'mobileTrip.transportsEmpty': 'Brak transportu',
+ 'mobileTrip.rtInfoTitle': 'Dane jazdy',
+ 'mobileTrip.rtDesktopNote':
+ 'Te dane ustawia się na komputerze, gdzie możesz też porównywać warianty trasy i sprawić, by dzień podążał za zaimportowanym śladem.',
+ 'mobileTrip.rtPlanOnDesktop': 'Planowanie odbywa się na komputerze. TREK wylicza przejazd, gdy dzień ma dwa miejsca.',
+ 'mobileTrip.rtSearchOffline': 'Wymaga połączenia: wyszukiwanie czyta trasę przed tobą.',
+ 'mobileTrip.rtBehind': '{time} opóźnienia względem planu',
+ 'mobileTrip.rtStayScope':
+ 'Pobyt jest przypisany do miejsca, więc liczy się w każdym dniu, w którym zaplanowano ten przystanek.',
+ 'mobileTrip.rtStayLess': '{count} minut mniej',
+ 'mobileTrip.rtStayMore': '{count} minut więcej',
+ 'mobileTrip.rtNightDesktopOnly':
+ 'Nocleg w tym miejscu rezerwuje się w planerze na komputerze. Tutaj możesz go tylko odrzucić.',
+ 'mobileTrip.rtReach': 'Jak daleko',
+ 'mobileTrip.rtReachAhead': '{distance} przed tobą',
+ 'mobileTrip.rtFromNext': 'Od następnego przystanku',
+ 'mobileTrip.rtFromStart': 'Od początku etapu',
+ 'mobileTrip.rtNoneAhead': 'Na drodze przed tobą nic nie ma. Spróbuj całego etapu.',
+ 'mobileTrip.rtNoneOnStage': 'Na tym etapie nie ma nic takiego.',
+ 'mobileTrip.rtTruncated':
+ '{count} odcinków miało więcej, niż mieści się w jednej odpowiedzi. Wybierz mniej rodzajów, by zobaczyć resztę.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/ru/map.ts b/shared/src/i18n/ru/map.ts
index a40c752a1..732143b2d 100644
--- a/shared/src/i18n/ru/map.ts
+++ b/shared/src/i18n/ru/map.ts
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': 'Показать всю поездку',
'map.overview.hide': 'Скрыть всю поездку',
'map.overview.total': 'Общее расстояние',
+ 'map.attribution': 'Источники карты',
};
export default map;
diff --git a/shared/src/i18n/ru/mobileTrip.ts b/shared/src/i18n/ru/mobileTrip.ts
index 03981b805..082e08dbd 100644
--- a/shared/src/i18n/ru/mobileTrip.ts
+++ b/shared/src/i18n/ru/mobileTrip.ts
@@ -47,5 +47,26 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.upNext': 'Далее',
'mobileTrip.viewDetails': 'Просмотреть детали',
'mobileTrip.transportsEmpty': 'Пока нет транспорта',
+ 'mobileTrip.rtInfoTitle': 'Данные о вождении',
+ 'mobileTrip.rtDesktopNote':
+ 'Эти данные задаются на компьютере: там же можно сравнивать другие маршруты и провести день по импортированному треку.',
+ 'mobileTrip.rtPlanOnDesktop':
+ 'Планирование происходит на компьютере. TREK рассчитает маршрут, как только в дне окажется два места.',
+ 'mobileTrip.rtSearchOffline': 'Нужна сеть: поиск считывает маршрут впереди.',
+ 'mobileTrip.rtBehind': 'на {time} позже плана',
+ 'mobileTrip.rtStayScope':
+ 'Стоянка привязана к месту, поэтому она учитывается в каждый день, на который запланирована эта остановка.',
+ 'mobileTrip.rtStayLess': 'на {count} минут меньше',
+ 'mobileTrip.rtStayMore': 'на {count} минут больше',
+ 'mobileTrip.rtNightDesktopOnly':
+ 'Забронировать ночёвку в этом месте можно в планировщике на компьютере. Здесь её можно только убрать.',
+ 'mobileTrip.rtReach': 'Насколько далеко',
+ 'mobileTrip.rtReachAhead': '{distance} впереди',
+ 'mobileTrip.rtFromNext': 'От следующей остановки',
+ 'mobileTrip.rtFromStart': 'От начала этапа',
+ 'mobileTrip.rtNoneAhead': 'На дороге впереди ничего нет. Попробуйте весь этап.',
+ 'mobileTrip.rtNoneOnStage': 'На этом этапе ничего такого нет.',
+ 'mobileTrip.rtTruncated':
+ 'На {count} участках нашлось больше, чем помещается в один ответ. Выберите меньше видов, чтобы увидеть остальное.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/sv/map.ts b/shared/src/i18n/sv/map.ts
index 37130e560..6392be254 100644
--- a/shared/src/i18n/sv/map.ts
+++ b/shared/src/i18n/sv/map.ts
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': 'Visa hela resan',
'map.overview.hide': 'Dölj hela resan',
'map.overview.total': 'Total sträcka',
+ 'map.attribution': 'Kartkällor',
};
export default map;
diff --git a/shared/src/i18n/sv/mobileTrip.ts b/shared/src/i18n/sv/mobileTrip.ts
index 0914b8545..d33415766 100644
--- a/shared/src/i18n/sv/mobileTrip.ts
+++ b/shared/src/i18n/sv/mobileTrip.ts
@@ -47,5 +47,24 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.upNext': 'Härnäst',
'mobileTrip.viewDetails': 'Visa detaljer',
'mobileTrip.transportsEmpty': 'Ingen transport än',
+ 'mobileTrip.rtInfoTitle': 'Kördata',
+ 'mobileTrip.rtDesktopNote':
+ 'De här värdena ställer du in på datorn. Där kan du också jämföra alternativa vägar och låta en dag följa ett importerat spår.',
+ 'mobileTrip.rtPlanOnDesktop': 'Planeringen sker på datorn. TREK räknar ut körningen så snart en dag har två platser.',
+ 'mobileTrip.rtSearchOffline': 'Kräver anslutning: sökningen läser rutten som ligger framför dig.',
+ 'mobileTrip.rtBehind': '{time} efter planen',
+ 'mobileTrip.rtStayScope': 'Vistelsen hör till platsen och räknas därför varje dag som det här stoppet är inplanerat.',
+ 'mobileTrip.rtStayLess': '{count} minuter mindre',
+ 'mobileTrip.rtStayMore': '{count} minuter mer',
+ 'mobileTrip.rtNightDesktopOnly':
+ 'En övernattning på den här platsen lägger du till i planeraren på datorn. Här kan du bara ta bort den.',
+ 'mobileTrip.rtReach': 'Hur långt',
+ 'mobileTrip.rtReachAhead': '{distance} framåt',
+ 'mobileTrip.rtFromNext': 'Från ditt nästa stopp',
+ 'mobileTrip.rtFromStart': 'Från etappens början',
+ 'mobileTrip.rtNoneAhead': 'Inget på vägen framför dig. Prova hela etappen.',
+ 'mobileTrip.rtNoneOnStage': 'Inget sådant längs den här etappen.',
+ 'mobileTrip.rtTruncated':
+ '{count} sträckor hade mer än vad som får plats i ett svar. Välj färre sorter för att se resten.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/tr/map.ts b/shared/src/i18n/tr/map.ts
index d317d6f94..74b7c22d2 100644
--- a/shared/src/i18n/tr/map.ts
+++ b/shared/src/i18n/tr/map.ts
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': 'Tüm geziyi göster',
'map.overview.hide': 'Tüm geziyi gizle',
'map.overview.total': 'Toplam mesafe',
+ 'map.attribution': 'Harita kaynakları',
};
export default map;
diff --git a/shared/src/i18n/tr/mobileTrip.ts b/shared/src/i18n/tr/mobileTrip.ts
index 22e3d99a4..373bb6851 100644
--- a/shared/src/i18n/tr/mobileTrip.ts
+++ b/shared/src/i18n/tr/mobileTrip.ts
@@ -47,5 +47,24 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.upNext': 'Sırada',
'mobileTrip.viewDetails': 'Ayrıntıları görüntüle',
'mobileTrip.transportsEmpty': 'Henüz ulaşım yok',
+ 'mobileTrip.rtInfoTitle': 'Sürüş değerleri',
+ 'mobileTrip.rtDesktopNote':
+ 'Bu değerler masaüstünde ayarlanır; orada başka yolları karşılaştırabilir ve bir günü içe aktarılmış bir izi takip ettirebilirsiniz.',
+ 'mobileTrip.rtPlanOnDesktop': 'Planlama masaüstünde yapılır. Bir günde iki yer olduğunda TREK sürüşü hesaplar.',
+ 'mobileTrip.rtSearchOffline': 'Bağlantı gerekir: arama, ileride kalan rotayı okur.',
+ 'mobileTrip.rtBehind': 'plandan {time} geride',
+ 'mobileTrip.rtStayScope': 'Bu süre yere aittir, bu yüzden bu durağın planlandığı her gün için geçerlidir.',
+ 'mobileTrip.rtStayLess': '{count} dakika daha az',
+ 'mobileTrip.rtStayMore': '{count} dakika daha fazla',
+ 'mobileTrip.rtNightDesktopOnly':
+ 'Bu yerde konaklama masaüstü planlayıcıda ayarlanır. Burada yalnızca kaldırabilirsiniz.',
+ 'mobileTrip.rtReach': 'Ne kadar uzağa',
+ 'mobileTrip.rtReachAhead': '{distance} ileride',
+ 'mobileTrip.rtFromNext': 'Bir sonraki durağınızdan itibaren',
+ 'mobileTrip.rtFromStart': 'Etabın başından itibaren',
+ 'mobileTrip.rtNoneAhead': 'İleride yol üzerinde bir şey yok. Tüm etabı deneyin.',
+ 'mobileTrip.rtNoneOnStage': 'Bu etap boyunca bu türden bir şey yok.',
+ 'mobileTrip.rtTruncated':
+ '{count} kesimde tek bir yanıta sığandan fazlası vardı. Geri kalanı görmek için daha az tür seçin.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/uk/map.ts b/shared/src/i18n/uk/map.ts
index 6dc038eb1..fed8d2728 100644
--- a/shared/src/i18n/uk/map.ts
+++ b/shared/src/i18n/uk/map.ts
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': 'Показати всю подорож',
'map.overview.hide': 'Сховати всю подорож',
'map.overview.total': 'Загальна відстань',
+ 'map.attribution': 'Джерела карти',
};
export default map;
diff --git a/shared/src/i18n/uk/mobileTrip.ts b/shared/src/i18n/uk/mobileTrip.ts
index de3a3491f..05571811d 100644
--- a/shared/src/i18n/uk/mobileTrip.ts
+++ b/shared/src/i18n/uk/mobileTrip.ts
@@ -47,5 +47,26 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.upNext': 'Далі',
'mobileTrip.viewDetails': 'Переглянути деталі',
'mobileTrip.transportsEmpty': 'Поки немає транспорту',
+ 'mobileTrip.rtInfoTitle': 'Дані про водіння',
+ 'mobileTrip.rtDesktopNote':
+ 'Ці дані задаються на комп’ютері: там само можна порівнювати інші маршрути та провести день за імпортованим треком.',
+ 'mobileTrip.rtPlanOnDesktop':
+ 'Планування відбувається на комп’ютері. TREK розрахує маршрут, щойно в дні буде два місця.',
+ 'mobileTrip.rtSearchOffline': 'Потрібне з’єднання: пошук зчитує маршрут попереду.',
+ 'mobileTrip.rtBehind': 'на {time} пізніше плану',
+ 'mobileTrip.rtStayScope':
+ 'Стоянка належить місцю, тому вона враховується в кожен день, на який заплановано цю зупинку.',
+ 'mobileTrip.rtStayLess': 'на {count} хвилин менше',
+ 'mobileTrip.rtStayMore': 'на {count} хвилин більше',
+ 'mobileTrip.rtNightDesktopOnly':
+ 'Забронювати ночівлю в цьому місці можна в планувальнику на комп’ютері. Тут її можна лише прибрати.',
+ 'mobileTrip.rtReach': 'Наскільки далеко',
+ 'mobileTrip.rtReachAhead': '{distance} попереду',
+ 'mobileTrip.rtFromNext': 'Від наступної зупинки',
+ 'mobileTrip.rtFromStart': 'Від початку етапу',
+ 'mobileTrip.rtNoneAhead': 'На дорозі попереду нічого немає. Спробуйте весь етап.',
+ 'mobileTrip.rtNoneOnStage': 'На цьому етапі нічого такого немає.',
+ 'mobileTrip.rtTruncated':
+ 'На {count} ділянках знайшлося більше, ніж уміщується в одну відповідь. Виберіть менше видів, щоб побачити решту.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/vi/map.ts b/shared/src/i18n/vi/map.ts
index 081fe6c22..6c7bf8dea 100644
--- a/shared/src/i18n/vi/map.ts
+++ b/shared/src/i18n/vi/map.ts
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': 'Hiện toàn bộ chuyến đi',
'map.overview.hide': 'Ẩn toàn bộ chuyến đi',
'map.overview.total': 'Tổng quãng đường',
+ 'map.attribution': 'Nguồn bản đồ',
};
export default map;
diff --git a/shared/src/i18n/vi/mobileTrip.ts b/shared/src/i18n/vi/mobileTrip.ts
index 12588df6b..5285627fe 100644
--- a/shared/src/i18n/vi/mobileTrip.ts
+++ b/shared/src/i18n/vi/mobileTrip.ts
@@ -47,5 +47,26 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.upNext': 'Tiếp theo',
'mobileTrip.viewDetails': 'Xem chi tiết',
'mobileTrip.transportsEmpty': 'Chưa có phương tiện di chuyển',
+ 'mobileTrip.rtInfoTitle': 'Số liệu lái xe',
+ 'mobileTrip.rtDesktopNote':
+ 'Các số liệu này được đặt trên máy tính, nơi bạn cũng có thể so sánh các lộ trình khác và cho một ngày đi theo tuyến đã nhập.',
+ 'mobileTrip.rtPlanOnDesktop':
+ 'Việc lên kế hoạch diễn ra trên máy tính. TREK tính chặng lái xe ngay khi một ngày có hai địa điểm.',
+ 'mobileTrip.rtSearchOffline': 'Cần có kết nối: việc tìm kiếm đọc lộ trình phía trước.',
+ 'mobileTrip.rtBehind': 'chậm {time} so với kế hoạch',
+ 'mobileTrip.rtStayScope':
+ 'Thời gian dừng thuộc về địa điểm, nên nó được tính vào mọi ngày có điểm dừng này trong kế hoạch.',
+ 'mobileTrip.rtStayLess': 'ít hơn {count} phút',
+ 'mobileTrip.rtStayMore': 'nhiều hơn {count} phút',
+ 'mobileTrip.rtNightDesktopOnly':
+ 'Việc đặt nghỉ đêm tại địa điểm này được thực hiện trong trình lập kế hoạch trên máy tính. Ở đây bạn chỉ có thể bỏ nó đi.',
+ 'mobileTrip.rtReach': 'Xa đến đâu',
+ 'mobileTrip.rtReachAhead': '{distance} phía trước',
+ 'mobileTrip.rtFromNext': 'Từ điểm dừng kế tiếp',
+ 'mobileTrip.rtFromStart': 'Từ đầu chặng',
+ 'mobileTrip.rtNoneAhead': 'Không có gì trên đường phía trước. Thử cả chặng xem sao.',
+ 'mobileTrip.rtNoneOnStage': 'Không có gì thuộc loại đó dọc chặng này.',
+ 'mobileTrip.rtTruncated':
+ '{count} đoạn có nhiều hơn mức vừa trong một câu trả lời. Chọn ít loại hơn để xem phần còn lại.',
};
export default mobileTrip;
diff --git a/shared/src/i18n/zh-TW/map.ts b/shared/src/i18n/zh-TW/map.ts
index d5446d5f6..e92a8ea71 100644
--- a/shared/src/i18n/zh-TW/map.ts
+++ b/shared/src/i18n/zh-TW/map.ts
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': '顯示整趟行程',
'map.overview.hide': '隱藏整趟行程',
'map.overview.total': '總距離',
+ 'map.attribution': '地圖來源',
};
export default map;
diff --git a/shared/src/i18n/zh-TW/mobileTrip.ts b/shared/src/i18n/zh-TW/mobileTrip.ts
index 5b02fdef6..4cde2c97c 100644
--- a/shared/src/i18n/zh-TW/mobileTrip.ts
+++ b/shared/src/i18n/zh-TW/mobileTrip.ts
@@ -47,5 +47,21 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.upNext': '接下來',
'mobileTrip.viewDetails': '查看詳情',
'mobileTrip.transportsEmpty': '暫無交通安排',
+ 'mobileTrip.rtInfoTitle': '駕駛數據',
+ 'mobileTrip.rtDesktopNote': '這些數據在電腦上設定,在那裡也可以比較其他路線,以及讓某一天沿匯入的軌跡行駛。',
+ 'mobileTrip.rtPlanOnDesktop': '規劃在電腦上進行。當一天有兩個地點時,TREK 就會算出車程。',
+ 'mobileTrip.rtSearchOffline': '需要連線:搜尋會讀取前方的路線。',
+ 'mobileTrip.rtBehind': '比計畫晚 {time}',
+ 'mobileTrip.rtStayScope': '停留時間屬於該地點,因此這個停靠點排在哪一天,就會計入那一天。',
+ 'mobileTrip.rtStayLess': '少 {count} 分鐘',
+ 'mobileTrip.rtStayMore': '多 {count} 分鐘',
+ 'mobileTrip.rtNightDesktopOnly': '在這個地點安排過夜住宿要在電腦版的行程規劃中進行。這裡只能把它移除。',
+ 'mobileTrip.rtReach': '找多遠',
+ 'mobileTrip.rtReachAhead': '前方 {distance}',
+ 'mobileTrip.rtFromNext': '從下一個停靠點起',
+ 'mobileTrip.rtFromStart': '從本段起點起',
+ 'mobileTrip.rtNoneAhead': '前方路上沒有找到。試試整段。',
+ 'mobileTrip.rtNoneOnStage': '本段沿途沒有這類地點。',
+ 'mobileTrip.rtTruncated': '{count} 段路的結果超出一次回答能容納的量。少選幾類就能看到其餘的。',
};
export default mobileTrip;
diff --git a/shared/src/i18n/zh/map.ts b/shared/src/i18n/zh/map.ts
index 32dbf24e4..f20075b67 100644
--- a/shared/src/i18n/zh/map.ts
+++ b/shared/src/i18n/zh/map.ts
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': '显示整个行程',
'map.overview.hide': '隐藏整个行程',
'map.overview.total': '总距离',
+ 'map.attribution': '地图来源',
};
export default map;
diff --git a/shared/src/i18n/zh/mobileTrip.ts b/shared/src/i18n/zh/mobileTrip.ts
index b22513a79..95b6cd566 100644
--- a/shared/src/i18n/zh/mobileTrip.ts
+++ b/shared/src/i18n/zh/mobileTrip.ts
@@ -47,5 +47,21 @@ const mobileTrip: TranslationStrings = {
'mobileTrip.upNext': '接下来',
'mobileTrip.viewDetails': '查看详情',
'mobileTrip.transportsEmpty': '暂无交通安排',
+ 'mobileTrip.rtInfoTitle': '驾驶数据',
+ 'mobileTrip.rtDesktopNote': '这些数据在桌面端设置。在桌面端还可以比较其他路线走法,并让某一天沿导入的轨迹行驶。',
+ 'mobileTrip.rtPlanOnDesktop': '规划在桌面端进行。一天里有了两个地点,TREK 就会算出这段车程。',
+ 'mobileTrip.rtSearchOffline': '需要联网:搜索会读取前方的路线。',
+ 'mobileTrip.rtBehind': '比计划晚 {time}',
+ 'mobileTrip.rtStayScope': '停留时间属于这个地点,因此在安排了该停靠点的每一天都会生效。',
+ 'mobileTrip.rtStayLess': '减少 {count} 分钟',
+ 'mobileTrip.rtStayMore': '增加 {count} 分钟',
+ 'mobileTrip.rtNightDesktopOnly': '在这个地点安排过夜需要在桌面端的规划器中进行,这里只能放弃。',
+ 'mobileTrip.rtReach': '找多远',
+ 'mobileTrip.rtReachAhead': '前方 {distance}',
+ 'mobileTrip.rtFromNext': '从下一个停靠点起',
+ 'mobileTrip.rtFromStart': '从本段起点起',
+ 'mobileTrip.rtNoneAhead': '前方路上没有找到。试试整段。',
+ 'mobileTrip.rtNoneOnStage': '本段沿途没有这类地点。',
+ 'mobileTrip.rtTruncated': '{count} 段路的结果超出一次回答能装下的量。少选几类就能看到其余的。',
};
export default mobileTrip;