feat(roadtrip): bring the road trip addon to the phone

A road trip tab on the mobile trip screen: the stage as a chain, the same
map underneath it, the sheets a passenger needs, and a search along the
route.

The addon was desktop only because road trip MODE is a data switch rather
than a view switch. It rewrites `assignments` and `places` for the whole
planner hook, and every mobile sheet is permanently mounted and reads those
same two lists, so turning it on there brought back documented regressions.
The mode flag is therefore untouched. A second flag, `roadtripFeedActive`,
feeds only the routing round and does it off the STORED assignments, so the
phone gets the drive without what the mode drags along with it.

The map is one instance shared by the plan tab and the road trip tab,
hoisted into a single JSX expression so React keeps it in the same position
and the WebGL context is never torn down on a tab switch.

The corridor search is scoped to what a phone can afford: a window of the
day rather than all of it, a smaller tile ceiling, a capped retry pass and a
deadline. The standard search is three requests where the desk makes
fifteen. The categories are deliberately not narrowed, because they all ride
one query per box, so a second one costs nothing.

Two gaps found while wiring it up are closed here as well. The fuel search
drew its offers nowhere on the phone, so the button did nothing visible;
they are in the dry band now, with the detour, the spare range and an accept
button. And the map credit opened as a two line ribbon across a screen the
map fills: both GL engines now start it collapsed, and Leaflet, which has no
collapsed mode of its own, is given one.
This commit is contained in:
Maurice
2026-09-16 21:55:18 +02:00
parent bd44197974
commit aaff8f9db4
96 changed files with 7544 additions and 97 deletions
@@ -0,0 +1,57 @@
import { Info } from 'lucide-react'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
import { useTranslation } from '../../i18n'
interface MapAttributionToggleProps {
open: boolean
onToggle: () => void
/** Same band as the locate button, so the two sit on one line. */
bottomOffset: string
}
/**
* The little (i) that stands in for the credit on a phone.
*
* Both GL engines ship one of these: below 640px they collapse their attribution to a
* button and open it on a tap. Leaflet has no such mode, so on a screen the map fills,
* its credit is a white two-line block parked in the middle of the picture. This is the
* missing half of that pattern, and it is a React button rather than surgery on
* Leaflet's own container because Leaflet rewrites that container's markup from scratch
* whenever a layer with an attribution is added or removed.
*
* Nothing is hidden that cannot be read: one tap shows the full credit, every link in
* it included, which is the same bargain the GL engines strike.
*/
export default function MapAttributionToggle({ open, onToggle, bottomOffset }: MapAttributionToggleProps) {
const { t } = useTranslation()
const label = t('map.attribution')
return (
<button
type="button"
onClick={onToggle}
title={label}
aria-label={label}
aria-expanded={open}
style={{
position: 'absolute',
bottom: bottomOffset,
// Clear of the locate button at right: 12 plus its 42, with a gap.
right: 62,
zIndex: 1000,
width: 30,
height: 30,
borderRadius: '50%',
border: 'none',
cursor: 'pointer',
background: 'var(--bg-card, white)',
color: 'var(--text-muted, #6b7280)',
boxShadow: MAP_CONTROL_SHADOW,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Info size={15} strokeWidth={2.2} aria-hidden="true" />
</button>
)
}
+21 -1
View File
@@ -34,6 +34,7 @@ function alternativeLabelIcon(label: string, note: string, background: string, a
})
}
import L from 'leaflet'
import MapAttributionToggle from './MapAttributionToggle'
import 'leaflet.markercluster/dist/MarkerCluster.css'
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
import { mapsApi } from '../../api/client'
@@ -978,6 +979,16 @@ export const MapView = memo(function MapView({
? 'calc(var(--bottom-nav-h, 84px) + 20px + var(--day-panel-h, 0px) + 12px)'
: 'calc(var(--bottom-nav-h, 84px) + 12px)'
/**
* Whether the credit is showing, on a phone where it starts as the (i).
*
* Held here rather than pushed into Leaflet's own container: Leaflet rewrites that
* container's markup from scratch whenever a layer with an attribution is added or
* removed, so anything injected into it dies on the next basemap switch. A class on
* the wrapper survives that, and the CSS lives with the rest of the mobile chrome.
*/
const [attribOpen, setAttribOpen] = useState(false)
const baseLayer = useSettingsStore(s => s.settings.map_base_layer) || 'default'
const updateSetting = useSettingsStore(s => s.updateSetting)
const isSatellite = baseLayer === 'satellite'
@@ -996,7 +1007,7 @@ export const MapView = memo(function MapView({
return (
<>
<div className="w-full h-full relative">
<div className={`w-full h-full relative${isMobile && attribOpen ? ' m-attrib-open' : ''}`}>
<MapContainer
// The datum is in the element's identity, because react-leaflet builds the
// map once and Leaflet cannot change a CRS afterwards. Settings can arrive
@@ -1215,6 +1226,15 @@ export const MapView = memo(function MapView({
onClick={cycleTrackingMode}
bottomOffset={locationButtonBottom as unknown as number}
/>}
{/* The credit as the little (i), which is what both GL engines do on a narrow map
and Leaflet has no mode for. */}
{isMobile && (
<MapAttributionToggle
open={attribOpen}
onToggle={() => setAttribOpen(v => !v)}
bottomOffset={locationButtonBottom}
/>
)}
{/* 20px off the sidebar, not 12: the pill is round and frosted, so at the
smaller gap its shadow ran into the sidebar edge and the two read as one
surface. */}
@@ -2066,3 +2066,48 @@ describe('MapViewGL', () => {
expect(glMap.setLayoutProperty).toHaveBeenCalledWith('trip-satellite-raster', 'visibility', 'none')
})
})
describe('MapViewGL attribution', () => {
const flushFrames = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 40)) })
/** The compact control as maplibre leaves it on a narrow map: collapsed class, open. */
function compactAttribution(container: HTMLElement): HTMLElement {
const el = document.createElement('details')
el.className = 'maplibregl-ctrl maplibregl-ctrl-attrib maplibregl-compact maplibregl-compact-show'
el.setAttribute('open', '')
// Inside the map container itself, where maplibre puts its controls.
container.querySelectorAll('div')[1]?.appendChild(el)
return el
}
it('FE-COMP-MAPVIEWGL-077: the credit starts as the little button, not as a ribbon', async () => {
const { container } = render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
const attrib = compactAttribution(container)
const idle = glMap.once.mock.calls.find(c => c[0] === 'idle')?.[1] as (() => void) | undefined
expect(idle).toBeDefined()
act(() => { idle!() })
// Both engines open theirs on a phone and only tuck it away on the first drag, so
// the first thing on a screen the map fills is a two-line band across the bottom.
expect(attrib.classList.contains('maplibregl-compact-show')).toBe(false)
expect(attrib.hasAttribute('open')).toBe(false)
// Still there, still one tap away: nothing about the credit is removed.
expect(attrib.classList.contains('maplibregl-ctrl-attrib')).toBe(true)
})
it('FE-COMP-MAPVIEWGL-078: a map wide enough to show the credit in full is left alone', async () => {
const { container } = render(<MapViewGL places={[]} fitKey={1} />)
await flushFrames()
const el = document.createElement('details')
// No `compact-show`, which is what a map over 640px reports.
el.className = 'maplibregl-ctrl maplibregl-ctrl-attrib'
el.setAttribute('open', '')
container.querySelectorAll('div')[1]?.appendChild(el)
const idle = glMap.once.mock.calls.find(c => c[0] === 'idle')?.[1] as (() => void) | undefined
act(() => { idle!() })
expect(el.hasAttribute('open')).toBe(true)
})
})
+17
View File
@@ -1170,6 +1170,23 @@ export function MapViewGL({
maxWidth: '240px',
className: 'trek-map-popup',
})
/*
* The credit starts as the little (i), not as a ribbon across the map.
*
* Both engines collapse their attribution below 640px, and both then open it
* anyway and wait for a drag before tucking it away. On a phone, where the map IS
* the screen, that means the first thing anybody sees is a two-line grey band
* over the bottom of it. Dragging is exactly what maplibre does on its own
* `drag` handler; this only starts where that would have ended up, so the credit
* is one tap away and nothing about it is removed.
*/
map.once('idle', () => {
const attrib = containerRef.current?.querySelector('.maplibregl-ctrl-attrib.maplibregl-compact-show')
?? containerRef.current?.querySelector('.mapboxgl-ctrl-attrib.mapboxgl-compact-show')
attrib?.classList.remove('maplibregl-compact-show', 'mapboxgl-compact-show')
attrib?.removeAttribute('open')
})
// Hand the map out so the trip planner can render its own compass pill next to
// the POI pill (a custom round control instead of Mapbox's default top-right one).
onMapReadyRef.current?.(map)
@@ -12,6 +12,7 @@ import { formatDistance } from '../../utils/units'
import CustomSelect from '../shared/CustomSelect'
import RoadtripCategoryPicker from './RoadtripCategoryPicker'
import { serviceColor } from './roadtripModel'
import { alongLabel, offRouteLabel } from './corridorSearchModel'
import { CORRIDOR_CATEGORY_BY_KEY } from './stopKinds'
import { FS } from './typeScale'
import { CORRIDOR_CATEGORY_KEYS, CORRIDOR_SECTION_KM, CORRIDOR_WIDTHS_KM, type RoadtripCorridor } from './useRoadtripCorridor'
@@ -154,14 +155,11 @@ function ResultRow({ poi, onAdd, onFocus }: { poi: CorridorPoi; onAdd?: () => vo
row still wraps cleanly at a narrow width because nothing has to break around
a separator. */}
<div className="flex flex-wrap items-center gap-1" style={{ fontSize: FS.meta }}>
<span className={POI_CHIP}>
{t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })}
</span>
<span className={POI_CHIP}>
{poi.alongKm < 0.5
? t('roadtrip.poi.atStart')
: t('roadtrip.poi.alongRoute', { distance: formatDistance(poi.alongKm, distanceUnit) })}
</span>
{/* Written by the shared model rather than here: the phone says the same two
things in one line, and the wording of a hit should not depend on which
shell is reading it. */}
<span className={POI_CHIP}>{offRouteLabel(poi.offRouteKm, distanceUnit, t)}</span>
<span className={POI_CHIP}>{alongLabel(poi.alongKm, distanceUnit, t)}</span>
{/* What the charger offers, where OSM says. Socket names are proper nouns and
stay as they are; the numbers around them are what decides whether a car can
use it at all. A station that says nothing shows nothing rather than a row
@@ -455,7 +453,9 @@ export default function RoadtripCorridorPanel({
<div className="flex gap-2">
<button
type="button"
onClick={search.search}
// Wrapped, not handed over: `search` takes an optional stretch of the drive,
// and a click handler passed straight through would hand it the MouseEvent.
onClick={() => search.search()}
disabled={!canSearch}
aria-label={t('roadtrip.poi.search')}
title={narrow ? t('roadtrip.poi.search') : undefined}
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest'
import {
alongLabel,
anchorKmFor,
corridorHitLabel,
corridorWindow,
DESKTOP_CORRIDOR_BUDGET,
offRouteLabel,
PHONE_CORRIDOR_BUDGET,
PHONE_CORRIDOR_OPTIONS,
PHONE_REACH_KM,
} from './corridorSearchModel'
import type { TranslationFn } from '../../types'
// FE-ROADTRIP-CORWIN-001 to FE-ROADTRIP-CORWIN-012
/** Echoes the key and its parameters, so assertions stay off the copy. */
const t: TranslationFn = (key, params) =>
params ? `${key}:${Object.values(params).join(',')}` : key
describe('corridorWindow', () => {
it('FE-ROADTRIP-CORWIN-001: a stage search has no window, which is the whole day', () => {
expect(corridorWindow(120, 'stage')).toBeNull()
})
it('FE-ROADTRIP-CORWIN-002: an ahead search starts at the anchor and runs one reach', () => {
expect(corridorWindow(120, 'ahead')).toEqual({ fromKm: 120, toKm: 120 + PHONE_REACH_KM })
})
it('FE-ROADTRIP-CORWIN-003: never starts behind the beginning of the drive', () => {
// A negative anchor cannot come from `anchorKmFor`, but a window that started at
// -20 would ask the slicer for road that does not exist.
expect(corridorWindow(-20, 'ahead')).toEqual({ fromKm: 0, toKm: PHONE_REACH_KM })
})
it('FE-ROADTRIP-CORWIN-004: takes a reach of its own, so the figure is not baked in', () => {
expect(corridorWindow(10, 'ahead', 25)).toEqual({ fromKm: 10, toKm: 35 })
})
})
describe('anchorKmFor', () => {
const stops = [0, 40, 95, 180]
it('FE-ROADTRIP-CORWIN-005: without a next stop it is the start of the stage', () => {
// No position source and no clock reading: the honest answer is the beginning of
// the day, not a guess dressed up as a location.
expect(anchorKmFor(stops, null)).toBe(0)
})
it('FE-ROADTRIP-CORWIN-006: the first stop is also the start, not a step back', () => {
expect(anchorKmFor(stops, 0)).toBe(0)
})
it('FE-ROADTRIP-CORWIN-007: otherwise it is the stop already behind the traveller', () => {
// Heading for the third stop means the second is behind: that is where the road
// still to drive begins.
expect(anchorKmFor(stops, 2)).toBe(40)
})
it('FE-ROADTRIP-CORWIN-008: an index the stop list cannot answer falls back to the start', () => {
expect(anchorKmFor(stops, 99)).toBe(0)
expect(anchorKmFor([0, Number.NaN, 95], 2)).toBe(0)
})
})
describe('the labels a hit carries', () => {
it('FE-ROADTRIP-CORWIN-009: anything under half a kilometre in is "at the start"', () => {
expect(alongLabel(0.2, 'metric', t)).toBe('roadtrip.poi.atStart')
expect(alongLabel(0.5, 'metric', t)).toBe('roadtrip.poi.alongRoute:500 m')
})
it('FE-ROADTRIP-CORWIN-010: the detour is stated in the trip’s own unit', () => {
expect(offRouteLabel(1.5, 'metric', t)).toBe('roadtrip.poi.offRoute:1.5 km')
expect(offRouteLabel(1.5, 'imperial', t)).toBe('roadtrip.poi.offRoute:0.9 mi')
})
it('FE-ROADTRIP-CORWIN-011: the phone joins both into the one line a row has room for', () => {
expect(corridorHitLabel({ alongKm: 82, offRouteKm: 0.4 }, 'metric', t))
.toBe('roadtrip.poi.alongRoute:82 km · roadtrip.poi.offRoute:400 m')
})
})
describe('the budgets', () => {
it('FE-ROADTRIP-CORWIN-012: the desk keeps its old behaviour, the phone is capped', () => {
// The desktop budget IS today's behaviour written down: no deadline, every failed
// box retried. Loosening the phone's numbers is a product decision, not a tidy-up,
// so they are pinned.
expect(DESKTOP_CORRIDOR_BUDGET).toEqual({ maxTiles: 16, maxRetries: null, deadlineMs: null })
expect(PHONE_CORRIDOR_BUDGET).toEqual({ maxTiles: 10, maxRetries: 2, deadlineMs: 25_000 })
// Handed down by identity so the search callback underneath is not rebuilt.
expect(PHONE_CORRIDOR_OPTIONS.budget).toBe(PHONE_CORRIDOR_BUDGET)
})
})
@@ -0,0 +1,117 @@
import { formatDistance } from '../../utils/units'
import type { CorridorPoi, CorridorWindow } from './useCorridorPois'
import type { DistanceUnit } from '@trek/shared/roadtrip'
import type { TranslationFn } from '../../types'
/**
* What a corridor search is allowed to cost, and how far along the drive it looks.
*
* The search is already scoped to one day. What it is NOT scoped to is a stretch of
* that day, and that is where the money is: at roughly one request per 20km of driving,
* a 300km day is 15 boxes, while the 50km ahead of the driver is three. A phone in a
* cell is not a desk, so it asks for the stretch it is about to drive and offers the
* whole stage as the second tap.
*
* Deliberately NOT narrowed: the categories. They all travel in one comma-separated
* query per box, so a second category costs zero extra requests. Restricting the phone
* to one at a time would have made three searches out of one, which is three times the
* traffic for less choice.
*/
export interface CorridorBudget {
/** Boxes one search may ask for. Past this it stops and says so. */
maxTiles: number
/** Boxes the follow-up pass may retry, or null for all of them (the desktop's way). */
maxRetries: number | null
/** No new box is started after this, or null for no limit. */
deadlineMs: number | null
}
/** Unchanged behaviour: this is what the panel has always done. */
export const DESKTOP_CORRIDOR_BUDGET: CorridorBudget = {
maxTiles: 16,
maxRetries: null,
deadlineMs: null,
}
/**
* The phone's ceiling.
*
* `deadlineMs` exists only here, and it answers a conflict the desktop lives with: the
* client gives a box 20 seconds, the server's Overpass fallback allows itself 25. A slow
* box therefore always times out on the client first and gets retried while the first
* attempt is still running. On a phone that is the difference between a search that
* ends and one that keeps the radio warm.
*/
export const PHONE_CORRIDOR_BUDGET: CorridorBudget = {
maxTiles: 10,
maxRetries: 2,
deadlineMs: 25_000,
}
/** Passed by identity, so the search callback below it is not rebuilt every render. */
export const PHONE_CORRIDOR_OPTIONS = { budget: PHONE_CORRIDOR_BUDGET }
/** How far ahead "ahead" means, in kilometres of driving. */
export const PHONE_REACH_KM = 50
export type CorridorReach = 'ahead' | 'stage'
/**
* The stretch of the drive a search covers, in kilometres along the day's line.
*
* Null means the whole stage, which is what the caller passes straight through to the
* existing behaviour. The window is applied to the tile line only: the spine keeps its
* full length, so `alongKm`, `offRouteKm` and the insert position of a hit stay measured
* against the day rather than against the window.
*/
export function corridorWindow(
anchorKm: number,
reach: CorridorReach,
reachKm: number = PHONE_REACH_KM,
): CorridorWindow | null {
if (reach === 'stage') return null
const from = Math.max(0, anchorKm)
return { fromKm: from, toKm: from + reachKm }
}
/**
* Where "ahead" starts.
*
* The stop the traveller is heading for, measured in kilometres along the day. Without
* one (a stage that is not today, or a day already driven) it is the start of the stage,
* which is the honest answer: there is no position source, and a guess dressed up as a
* location is worse than the beginning of the day.
*/
export function anchorKmFor(stopsAlongKm: readonly number[], upNextIndex: number | null): number {
if (upNextIndex == null || upNextIndex <= 0) return 0
const previous = stopsAlongKm[upNextIndex - 1]
return Number.isFinite(previous) ? Math.max(0, previous as number) : 0
}
/**
* How far into the drive a hit sits.
*
* Shared with the desktop panel rather than written twice. The two shells already mirror
* each other closely and the duplication budget is three percent of new code, so the
* phrases that describe a hit are written once and read in both: the desktop puts them
* in two chips, the phone joins them into one line, and neither owns the wording.
*/
export function alongLabel(alongKm: number, unit: DistanceUnit, t: TranslationFn): string {
return alongKm < 0.5
? t('roadtrip.poi.atStart')
: t('roadtrip.poi.alongRoute', { distance: formatDistance(alongKm, unit) })
}
/** How far off the road it sits. */
export function offRouteLabel(offRouteKm: number, unit: DistanceUnit, t: TranslationFn): string {
return t('roadtrip.poi.offRoute', { distance: formatDistance(offRouteKm, unit) })
}
/** Both of them, as the one line a phone row has room for. */
export function corridorHitLabel(
poi: Pick<CorridorPoi, 'alongKm' | 'offRouteKm'>,
unit: DistanceUnit,
t: TranslationFn,
): string {
return `${alongLabel(poi.alongKm, unit, t)} · ${offRouteLabel(poi.offRouteKm, unit, t)}`
}
@@ -0,0 +1,331 @@
import { describe, expect, it } from 'vitest'
import {
destinationCount,
pickWarning,
roadtripRows,
stageOf,
upNextStop,
type RoadtripRow,
type StopRow,
} from './roadtripRowModel'
import type { ScheduleEntry, ScheduleWarning } from './roadtripModel'
import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
// FE-RTROW-001 to FE-RTROW-028
function stop(name: string, over: Partial<RoadtripStop> = {}): RoadtripStop {
return {
assignmentId: name.length,
ownerDayId: 7,
ownerIndex: 0,
placeId: 100,
name,
lat: 48.1,
lng: 11.2,
time: null,
dwellMinutes: null,
legMode: null,
incomingLegMode: null,
stopType: null,
...over,
}
}
function entry(arrival: string | null, over: Partial<ScheduleEntry> = {}): ScheduleEntry {
return { arrival, departure: arrival, anchored: false, dayOffset: 0, ...over }
}
function seg(index: number): RouteSegment {
return {
mid: [48, 11],
from: [48, 11],
to: [49, 12],
distance: 1000 * (index + 1),
duration: 600,
walkingText: '2 h',
drivingText: '10 min',
distanceText: `${index + 1} km`,
}
}
function day(stops: RoadtripStop[], over: Partial<RoadtripDay> = {}): RoadtripDay {
return {
dayId: 7,
dayNumber: 1,
date: '2026-06-01',
title: null,
stops,
legs: stops.slice(0, -1).map((_, i) => seg(i)),
schedule: { entries: stops.map(() => entry(null)), warnings: [] },
legVias: [],
geometry: [],
distance: 0,
duration: 0,
driveWarnings: [],
dayWarning: null,
...over,
}
}
const night = (phase: 'start' | 'end' = 'end') =>
stop('Nacht', { automaticNight: { phase, fromDayNumber: 1 }, dwellMinutes: 480 })
const stopRows = (rows: RoadtripRow[]) => rows.filter((r): r is StopRow => r.kind === 'stop')
/** Ziel, Tankstelle, Ziel, automatische Nacht, Ziel. */
const mixedDay = () =>
day([stop('Bremen'), stop('Aral', { stopType: 'fuel' }), stop('Kassel'), night(), stop('Fulda')])
describe('roadtripRows numbering', () => {
it('FE-RTROW-001: only destinations take a number, a service stop and a night do not advance it', () => {
// Nummerierung ueber den Array-Index machte aus Kassel die drei und aus Fulda die fuenf.
const numbers = stopRows(roadtripRows(mixedDay())).map((r) => [r.stop.name, r.number])
expect(numbers).toEqual([
['Bremen', 1],
['Aral', null],
['Kassel', 2],
['Fulda', 3],
])
})
it('FE-RTROW-002: the service stop is a stop row, the automatic night is not', () => {
const rows = roadtripRows(mixedDay())
expect(rows.map((r) => r.kind)).toEqual(['stop', 'leg', 'stop', 'leg', 'stop', 'leg', 'auto', 'leg', 'stop'])
expect(stopRows(rows).map((r) => r.stop.name)).not.toContain('Nacht')
})
it('FE-RTROW-003: the automatic night carries no dwell and takes its time from the schedule', () => {
// Die Nacht traegt dwellMinutes 480, aber keine Zeile, die eine Aufenthaltsdauer zeigen koennte.
const rows = roadtripRows(
day([stop('Kassel'), night(), stop('Fulda')], {
schedule: { entries: [entry('18:00'), entry('22:30'), entry('09:15')], warnings: [] },
}),
)
expect(rows.find((r) => r.kind === 'auto')).toEqual({ kind: 'auto', phase: 'end', time: '22:30' })
expect(JSON.stringify(rows)).not.toContain('480')
})
it('FE-RTROW-004: a night that starts a day resumes it, anything else ends it', () => {
expect(roadtripRows(day([night('start'), stop('Fulda')])).find((r) => r.kind === 'auto')).toMatchObject({
phase: 'resume',
})
expect(roadtripRows(day([night('end'), stop('Fulda')])).find((r) => r.kind === 'auto')).toMatchObject({
phase: 'end',
})
})
it('FE-RTROW-005: exactly one leg between two stops and none after the last', () => {
const rows = roadtripRows(day([stop('A'), stop('B'), stop('C')]))
expect(rows.filter((r) => r.kind === 'leg')).toEqual([
{ kind: 'leg', index: 0, seg: seg(0), mode: null },
{ kind: 'leg', index: 1, seg: seg(1), mode: null },
])
expect(rows[rows.length - 1].kind).toBe('stop')
})
it('FE-RTROW-006: a leg is driven in the next stop incoming mode, else in this one own', () => {
const rows = roadtripRows(
day([
stop('A', { legMode: 'car' }),
stop('B', { incomingLegMode: 'ferry', legMode: 'train' }),
stop('C'),
stop('D'),
]),
)
const modes = rows.flatMap((r) => (r.kind === 'leg' ? [r.mode] : []))
expect(modes).toEqual(['ferry', 'train', null])
})
})
describe('roadtripRows dry points and spills', () => {
it('FE-RTROW-007: a dry point makes one row, directly after the leg it falls on', () => {
const rows = roadtripRows(
day([stop('A'), stop('B'), stop('C')], {
dryPoints: [{ legIndex: 1, intoLegKm: 42, sinceKm: 610, drivenMeters: 42000, lat: 48, lng: 11 }],
}),
)
expect(rows.map((r) => r.kind)).toEqual(['stop', 'leg', 'stop', 'leg', 'dry', 'stop'])
expect(rows[4]).toEqual({ kind: 'dry', legIndex: 1, intoLegKm: 42, sinceKm: 610 })
})
it('FE-RTROW-008: a dry point past the last leg is not drawn', () => {
// Nach dem letzten Stopp gibt es kein Bein, an dem die Warnung haengen koennte.
const rows = roadtripRows(
day([stop('A'), stop('B')], {
dryPoints: [{ legIndex: 1, intoLegKm: 5, sinceKm: 600, drivenMeters: 5000, lat: 48, lng: 11 }],
}),
)
expect(rows.some((r) => r.kind === 'dry')).toBe(false)
})
it('FE-RTROW-009: a spill sits before the stop it hangs on, with the day it came from', () => {
const rows = roadtripRows(
day([stop('A'), stop('B')], {
spills: [
{ at: 1, count: 1, fromDayNumber: 3, departure: '23:40', leg: undefined, fromStop: undefined, line: [] },
],
}),
)
expect(rows.map((r) => r.kind)).toEqual(['stop', 'leg', 'spill', 'stop'])
expect(rows[2]).toEqual({ kind: 'spill', fromDayNumber: 3, departs: '23:40', stops: [] })
})
})
describe('roadtripRows stop detail', () => {
it('FE-RTROW-010: the clock and the pin come from the schedule entry', () => {
const rows = stopRows(
roadtripRows(
day([stop('A'), stop('B'), stop('C')], {
schedule: {
entries: [entry('08:00'), entry('09:40', { departure: '10:10', anchored: true }), entry(null)],
warnings: [],
},
}),
),
)
expect(rows.map((r) => [r.time, r.pinned])).toEqual([
['08:00', false],
['09:40', true],
[null, false],
])
expect(rows[1].entry?.departure).toBe('10:10')
})
it('FE-RTROW-011: a stop shows only its own findings, and only the strongest of them', () => {
const rows = stopRows(
roadtripRows(
day([stop('A'), stop('B')], {
driveWarnings: [
{ index: 0, code: 'overnight' },
{ index: 1, code: 'leg', overMinutes: 20 },
{ index: 1, code: 'late', minutes: 15 },
],
}),
),
)
expect(rows[0].warning).toEqual({ index: 0, code: 'overnight' })
expect(rows[1].warning).toEqual({ index: 1, code: 'late', minutes: 15 })
})
it('FE-RTROW-012: dwell and the walk in from the road pass through, absent means null', () => {
const rows = stopRows(roadtripRows(day([stop('A', { dwellMinutes: 90, offRoadMeters: 240 }), stop('B')])))
expect([rows[0].dwellMinutes, rows[0].offRoadMeters]).toEqual([90, 240])
expect([rows[1].dwellMinutes, rows[1].offRoadMeters]).toEqual([null, null])
})
})
describe('pickWarning', () => {
it('FE-RTROW-013: late beats range beats leg beats overnight', () => {
const all: ScheduleWarning[] = [
{ index: 0, code: 'overnight' },
{ index: 0, code: 'leg', overMinutes: 30 },
{ index: 0, code: 'range', sinceKm: 700 },
{ index: 0, code: 'late', minutes: 20 },
]
expect(pickWarning(all)?.code).toBe('late')
expect(pickWarning(all.slice(0, 3))?.code).toBe('range')
expect(pickWarning(all.slice(0, 2))?.code).toBe('leg')
expect(pickWarning(all.slice(0, 1))?.code).toBe('overnight')
})
it('FE-RTROW-028: the order the findings arrive in does not decide', () => {
// Sonst haengt die Marke davon ab, in welcher Reihenfolge deriveDriveWarnings sie anhaengt.
const all: ScheduleWarning[] = [
{ index: 0, code: 'late', minutes: 20 },
{ index: 0, code: 'range', sinceKm: 700 },
{ index: 0, code: 'overnight' },
]
expect(pickWarning(all)?.code).toBe('late')
expect(pickWarning([...all].reverse())?.code).toBe('late')
})
it('FE-RTROW-014: nothing to report is null, not a placeholder', () => {
expect(pickWarning([])).toBeNull()
})
})
describe('stageOf', () => {
const days = [day([stop('A')], { dayId: 11, dayNumber: 1 }), day([stop('B')], { dayId: 22, dayNumber: 2 })]
it('FE-RTROW-015: finds the day by its id, not by its position', () => {
expect(stageOf(days, 22)?.dayNumber).toBe(2)
})
it('FE-RTROW-016: no selection and an id that is gone both give null', () => {
expect(stageOf(days, null)).toBeNull()
expect(stageOf(days, 999)).toBeNull()
expect(stageOf([], 11)).toBeNull()
})
})
describe('destinationCount', () => {
it('FE-RTROW-017: agrees with the highest number the rows handed out', () => {
const d = mixedDay()
const highest = stopRows(roadtripRows(d)).reduce((max, r) => Math.max(max, r.number ?? 0), 0)
expect(destinationCount(d)).toBe(3)
expect(destinationCount(d)).toBe(highest)
})
it('FE-RTROW-018: a day of nothing but services and nights counts none', () => {
expect(destinationCount(day([stop('Aral', { stopType: 'fuel' }), night()]))).toBe(0)
})
})
const timedDay = (arrivals: (string | null)[], stops: RoadtripStop[]) =>
day(stops, { schedule: { entries: arrivals.map((a) => entry(a)), warnings: [] } })
describe('upNextStop', () => {
const threeStops = () =>
timedDay(['09:00', '11:00', '13:00'], [stop('A'), stop('Aral', { stopType: 'fuel' }), stop('C')])
it('FE-RTROW-019: says nothing on a day that is not today, and nothing without a day', () => {
expect(upNextStop(threeStops(), 10 * 60, false)).toBeNull()
expect(upNextStop(null, 10 * 60, true)).toBeNull()
})
it('FE-RTROW-020: passes over the petrol stop and names the next destination', () => {
// Die Tankstelle um 11:00 liegt vor dem Ziel um 13:00 und ist trotzdem nie das naechste.
const next = upNextStop(threeStops(), 10 * 60, true)
expect(next?.row.stop.name).toBe('C')
expect(next?.minutesUntil).toBe(180)
})
it('FE-RTROW-021: a stop without a readable time is passed over', () => {
const d = timedDay([null, 'irgendwann', '13:00'], [stop('A'), stop('B'), stop('C')])
expect(upNextStop(d, 8 * 60, true)?.row.stop.name).toBe('C')
})
it('FE-RTROW-022: a stop due this very minute is still ahead', () => {
expect(upNextStop(threeStops(), 13 * 60, true)?.minutesUntil).toBe(0)
})
it('FE-RTROW-023: a stop whose time has just passed comes back as a delay', () => {
// Es gibt keine Positionsquelle, also ist "der Plan sagte 13:00, es ist 13:20"
// die staerkste ehrliche Aussage. Sie kommt als negatives minutesUntil.
expect(upNextStop(threeStops(), 13 * 60 + 20, true)?.minutesUntil).toBe(-20)
})
it('FE-RTROW-024: an hour past the last stop the day is over and nothing is next', () => {
expect(upNextStop(threeStops(), 14 * 60 + 1, true)).toBeNull()
})
it('FE-RTROW-025: a delay is only ever reported for the last stop, never a passed one', () => {
// 09:00 ist lange vorbei, 13:00 auch, gemeldet wird der Verzug auf den letzten.
const late = upNextStop(threeStops(), 13 * 60 + 30, true)
expect(late?.row.stop.name).toBe('C')
expect(late?.minutesUntil).toBe(-30)
})
it('FE-RTROW-026: another day, no day and a day without any clock name nothing', () => {
expect(upNextStop(timedDay(['09:00'], [stop('A')]), 12 * 60, false)).toBeNull()
expect(upNextStop(null, 12 * 60, true)).toBeNull()
expect(upNextStop(timedDay([null], [stop('A')]), 12 * 60, true)).toBeNull()
expect(upNextStop(timedDay(['nachmittags'], [stop('A')]), 12 * 60, true)).toBeNull()
})
it('FE-RTROW-027: a service stop is never the one named, even when it is next in line', () => {
// Die Tankstelle um 10:00 laege vorne, genannt wird trotzdem das Ziel um 11:00.
const d = timedDay(['09:00', '10:00', '11:00'], [stop('A'), stop('Aral', { stopType: 'fuel' }), stop('B')])
expect(upNextStop(d, 9 * 60 + 30, true)?.row.stop.name).toBe('B')
})
})
@@ -0,0 +1,199 @@
import { isServiceStopType, type ScheduleEntry, type ScheduleWarning } from './roadtripModel'
import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
/**
* A road trip day, flattened into the rows a screen draws, with no React in sight.
*
* The rail builds its rows inline while it renders, which is fine for a panel that only
* ever exists once. The phone draws the same chain in a second shell, and the two would
* drift the first time a rule changed, and the duplication budget does not stretch to
* the same forty lines of branching twice. So the branching lives here: what is a row,
* in which order, and which of a stop's findings survives the two marks a 375px line can
* carry.
*
* Deliberately free of React, translations and formatting. It answers "what is on this
* line", never "how does it read", so it can be unit tested on its own and so the same
* answer can be drawn twice in two different design languages.
*/
export type RoadtripRow =
| { kind: 'spill'; fromDayNumber: number; departs: string | null; stops: StopRow[] }
| StopRow
| { kind: 'leg'; index: number; seg: RouteSegment | undefined; mode: string | null }
| { kind: 'dry'; legIndex: number; intoLegKm: number; sinceKm: number }
| { kind: 'auto'; phase: 'end' | 'resume'; time: string | null }
export interface StopRow {
kind: 'stop'
stop: RoadtripStop
/** Position in the day's numbering, or null for a service stop and an automatic night. */
number: number | null
service: boolean
entry: ScheduleEntry | undefined
/** The clock this row shows on the right: when the traveller gets there. */
time: string | null
/** When they leave again, which is arrival plus the stay. Null without a schedule. */
departure: string | null
/** True when a person pinned that time, which is what the pin in front of it means. */
pinned: boolean
/** At most two marks fit a 375px row. This is the one warning that earned the second. */
warning: ScheduleWarning | null
dwellMinutes: number | null
offRoadMeters: number | null
}
/**
* Which finding wins when a stop has several.
*
* The desktop stacks all of them, which is honest on a 420px rail and unreadable on a
* phone. The order is by what changes the next decision soonest: being late moves every
* arrival after it, running dry strands the car, and an over-long leg is a planning
* remark you can act on tonight.
*/
const WARNING_RANK: Record<ScheduleWarning['code'], number> = {
late: 0,
range: 1,
leg: 2,
overnight: 3,
}
export function pickWarning(warnings: readonly ScheduleWarning[]): ScheduleWarning | null {
let best: ScheduleWarning | null = null
for (const w of warnings) {
if (!best || WARNING_RANK[w.code] < WARNING_RANK[best.code]) best = w
}
return best
}
/** The one day a phone screen shows, picked from what the routing round produced. */
export function stageOf(days: readonly RoadtripDay[], dayId: number | null): RoadtripDay | null {
if (dayId == null) return null
return days.find(d => d.dayId === dayId) ?? null
}
function stopRow(
stop: RoadtripStop,
index: number,
number: number | null,
schedule: RoadtripDay['schedule'],
driveWarnings: readonly ScheduleWarning[],
): StopRow {
// Only ever called for a real stop: an automatic night is the shell's own marker
// for "the day ended here" and `roadtripRows` turns it into an 'auto' row instead.
const entry = schedule.entries[index]
const mine = driveWarnings.filter(w => w.index === index)
return {
kind: 'stop',
stop,
number,
service: isServiceStopType(stop.stopType),
entry,
time: entry?.arrival ?? null,
departure: entry?.departure ?? null,
pinned: !!entry?.anchored,
warning: pickWarning(mine),
dwellMinutes: stop.dwellMinutes,
offRoadMeters: stop.offRoadMeters ?? null,
}
}
/**
* The rows of one day, in the order they are drawn.
*
* Two things here are easy to get wrong and both have a test:
*
* 1. Numbering counts destinations only. A petrol station between stop two and stop
* three does not make the next one four, and neither does the marker that ends the
* day. Numbering off the array index gets both wrong.
* 2. `ownerDayId` is the day a stop is STORED on, which after a night drive is not the
* card it is drawn on. The rows follow the card, because that is what the traveller
* reads, and anything writing back has to use `ownerDayId` instead.
*/
export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
const rows: RoadtripRow[] = []
const spills = day.spills ?? []
let number = 0
day.stops.forEach((stop, i) => {
const spill = spills.find(s => s.at === i)
if (spill) {
rows.push({
kind: 'spill',
fromDayNumber: spill.fromDayNumber,
departs: spill.departure,
stops: [],
})
}
const automatic = !!stop.automaticNight
if (automatic) {
rows.push({
kind: 'auto',
phase: stop.automaticNight?.phase === 'start' ? 'resume' : 'end',
time: day.schedule.entries[i]?.arrival ?? null,
})
} else {
if (!isServiceStopType(stop.stopType)) number += 1
rows.push(stopRow(stop, i, isServiceStopType(stop.stopType) ? null : number, day.schedule, day.driveWarnings))
}
// The leg AFTER this stop, plus the dry point that falls on it. Both belong
// between two stops, so they are emitted here rather than in their own pass.
const seg = day.legs[i]
if (i < day.stops.length - 1) {
rows.push({ kind: 'leg', index: i, seg, mode: day.stops[i + 1]?.incomingLegMode ?? stop.legMode ?? null })
const dry = (day.dryPoints ?? []).find(p => p.legIndex === i)
if (dry) {
rows.push({ kind: 'dry', legIndex: i, intoLegKm: dry.intoLegKm, sinceKm: dry.sinceKm })
}
}
})
return rows
}
/** Stops that carry a number, for a count that agrees with the numbering above. */
export function destinationCount(day: RoadtripDay): number {
return day.stops.filter(s => !s.automaticNight && !isServiceStopType(s.stopType)).length
}
/**
* The next destination the plan still owes you, measured against the clock.
*
* Only for today: a countdown on a day in March is noise. `minutesUntil` goes
* NEGATIVE once that stop's planned time has passed, and that negative number is
* the only honest statement about running late this screen can make. There is no
* position source, so the app cannot know whether the stop was reached; what it
* can say is that the plan said 10:45 and it is now 11:21.
*
* Null once the last stop's time is past: a screen still saying "next" after the
* driving is done is worse than one saying nothing.
*/
export function upNextStop(
day: RoadtripDay | null,
nowMinutes: number,
isToday: boolean,
): { row: StopRow; minutesUntil: number } | null {
if (!day || !isToday) return null
const timed = roadtripRows(day)
.filter((r): r is StopRow => r.kind === 'stop' && !r.service)
.map(row => ({ row, at: clockMinutes(row.time) }))
.filter((x): x is { row: StopRow; at: number } => x.at != null)
if (!timed.length) return null
const ahead = timed.find(x => x.at >= nowMinutes)
if (ahead) return { row: ahead.row, minutesUntil: ahead.at - nowMinutes }
// Everything is past. The last one is only still "next" while the day has not
// run out entirely, which we take as its planned time plus the hour after it.
const last = timed[timed.length - 1]
const overdue = nowMinutes - last.at
return overdue <= 60 ? { row: last.row, minutesUntil: -overdue } : null
}
function clockMinutes(time: string | null): number | null {
if (!time) return null
const [h, m] = time.split(':').map(Number)
if (!Number.isFinite(h) || !Number.isFinite(m)) return null
return h * 60 + m
}
@@ -0,0 +1,209 @@
import { describe, expect, it } from 'vitest'
import { dayColor } from './dayColors'
import { stageMapData, stagePlaceIds, stagePoints } from './stageMap'
import type { AccessSpur, RoadtripDay, RoadtripRoutes, RoadtripStop } from '@trek/shared/roadtrip'
// FE-RTSTAGE-001 to FE-RTSTAGE-015
function stop(name: string, over: Partial<RoadtripStop> = {}): RoadtripStop {
return {
assignmentId: 1,
ownerDayId: 7,
ownerIndex: 0,
placeId: 100,
name,
lat: 48.1,
lng: 11.2,
time: null,
dwellMinutes: null,
legMode: null,
incomingLegMode: null,
stopType: null,
...over,
}
}
function day(stops: RoadtripStop[], over: Partial<RoadtripDay> = {}): RoadtripDay {
return {
dayId: 7,
dayNumber: 2,
date: '2026-06-02',
title: null,
stops,
legs: [],
schedule: { entries: [], warnings: [] },
legVias: [],
geometry: [],
distance: 0,
duration: 0,
driveWarnings: [],
dayWarning: null,
...over,
}
}
const spur = (stopKey: string): AccessSpur => ({
line: [
[48, 11],
[48.001, 11.001],
],
meters: 120,
stopKey,
})
/** Der Schluessel, den assemble baut: Position auf fuenf Stellen plus beide Fahrtarten. */
const keyFor = (s: RoadtripStop) =>
`${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}`
const LINES: [number, number][][] = [
[
[48, 11],
[48.1, 11.1],
],
[
[48.1, 11.1],
[48.2, 11.2],
],
[
[48.2, 11.2],
[48.3, 11.3],
],
[
[48.3, 11.3],
[48.4, 11.4],
],
]
function routes(over: Partial<RoadtripRoutes> = {}): RoadtripRoutes {
return {
days: [],
quietDays: [],
lines: LINES,
lineDays: [1, 2, 2, 3],
accessLines: [],
vias: [],
segments: [],
totalDistance: 0,
totalDuration: 0,
totalStops: 0,
loading: false,
...over,
}
}
describe('stageMapData without a stage', () => {
it('FE-RTSTAGE-001: draws every line of the drive, none filtered away', () => {
expect(stageMapData(routes(), null, true).lines).toEqual(LINES)
})
it('FE-RTSTAGE-002: gives each line the colour of the day it belongs to', () => {
const colors = stageMapData(routes(), null, true).lineColors
expect(colors).toEqual([dayColor(1), dayColor(2), dayColor(2), dayColor(3)])
expect(colors).toHaveLength(LINES.length)
})
it('FE-RTSTAGE-003: keeps every spur and filters no markers', () => {
// Ohne Auswahl gibt es nichts zu verstecken: leere placeIds heisst "kein Filter".
const data = stageMapData(routes({ accessLines: [spur('a'), spur('b')] }), null, true)
expect(data.accessLines).toHaveLength(2)
expect(data.placeIds.size).toBe(0)
expect(data.focusPoints).toEqual([])
})
it('FE-RTSTAGE-004: day colours off leaves the paint to the map', () => {
expect(stageMapData(routes(), null, false).lineColors).toBeUndefined()
})
})
describe('stageMapData with a stage', () => {
const stage = day([stop('Kassel', { placeId: 11 }), stop('Fulda', { placeId: 22, lat: 50.55, lng: 9.68 })], {
dayNumber: 2,
})
it('FE-RTSTAGE-005: keeps only the lines of that day', () => {
expect(stageMapData(routes(), stage, true).lines).toEqual([LINES[1], LINES[2]])
})
it('FE-RTSTAGE-006: the colours stay lined up with the lines that are left', () => {
const data = stageMapData(routes(), stage, true)
expect(data.lineColors).toHaveLength(data.lines.length)
expect(data.lineColors).toEqual([dayColor(2), dayColor(2)])
})
it('FE-RTSTAGE-007: a day the routing round drew nothing for keeps no lines', () => {
expect(stageMapData(routes(), day([stop('Kassel')], { dayNumber: 9 }), true).lines).toEqual([])
})
it('FE-RTSTAGE-008: day colours off leaves the paint to the map here too', () => {
expect(stageMapData(routes(), stage, false).lineColors).toBeUndefined()
})
it('FE-RTSTAGE-009: the stage names its places and its fit points', () => {
const data = stageMapData(routes(), stage, true)
expect([...data.placeIds]).toEqual([11, 22])
expect(data.focusPoints).toEqual([
[48.1, 11.2],
[50.55, 9.68],
])
})
it('FE-RTSTAGE-010: before the routing round has any line, the stage still gets places and fit', () => {
// Sonst springt die Karte beim ersten Oeffnen ins Nichts, bis die Route da ist.
const data = stageMapData(routes({ lines: [], lineDays: [] }), stage, true)
expect(data.lines).toEqual([])
expect(data.lineColors).toBeUndefined()
expect(data.accessLines).toEqual([])
expect(data.placeIds.size).toBe(2)
expect(data.focusPoints).toHaveLength(2)
})
})
describe('stageMapData access spurs', () => {
const onStage = stop('Kassel', { lat: 51.31627, lng: 9.49797, legMode: 'car', incomingLegMode: null })
const stage = day([onStage])
it('FE-RTSTAGE-011: a spur comes along exactly when its stop does', () => {
const mine = spur(keyFor(onStage))
const elsewhere = spur(keyFor(stop('Bremen', { lat: 53.07, lng: 8.8 })))
expect(stageMapData(routes({ accessLines: [mine, elsewhere] }), stage, true).accessLines).toEqual([mine])
})
it('FE-RTSTAGE-012: a stop with no mode on one side still finds its spur', () => {
// Der letzte Stopp des Tages faehrt nirgends mehr hin, der erste kommt von nirgends her.
const last = stop('Fulda', { lat: 50.55221, lng: 9.6752, legMode: null, incomingLegMode: 'car' })
const spurs = [spur(keyFor(onStage)), spur(keyFor(last))]
expect(stageMapData(routes({ accessLines: spurs }), day([onStage, last]), true).accessLines).toEqual(spurs)
})
it('FE-RTSTAGE-013: the key carries both leg modes, so the same spot in another mode is a different stop', () => {
// Zwei Stopps auf demselben Parkplatz unterscheiden sich nur hier.
const ferry = spur(`${onStage.lat.toFixed(5)},${onStage.lng.toFixed(5)},ferry,`)
expect(stageMapData(routes({ accessLines: [ferry] }), stage, true).accessLines).toEqual([])
})
})
describe('stagePlaceIds', () => {
it('FE-RTSTAGE-014: leaves the automatic night out, it has no place behind it', () => {
const stage = day([
stop('Kassel', { placeId: 11 }),
stop('Nacht', { placeId: 0, automaticNight: { phase: 'end', fromDayNumber: 1 } }),
stop('Fulda', { placeId: 22 }),
])
expect([...stagePlaceIds(stage)]).toEqual([11, 22])
})
})
describe('stagePoints', () => {
it('FE-RTSTAGE-015: keeps order and drops what cannot be put on a map', () => {
const stage = day([
stop('Kassel', { lat: 51.31, lng: 9.49 }),
stop('Ohne', { lat: Number.NaN, lng: 9.5 }),
stop('Auch ohne', { lat: 50.5, lng: Number.NaN }),
stop('Fulda', { lat: 50.55, lng: 9.68 }),
])
expect(stagePoints(stage)).toEqual([
[51.31, 9.49],
[50.55, 9.68],
])
})
})
@@ -0,0 +1,93 @@
import { dayColor } from './dayColors'
import type { AccessSpur, RoadtripDay, RoadtripRoutes } from '@trek/shared/roadtrip'
/**
* What the map draws for ONE stage of a drive.
*
* The rail hides a folded day by filtering the whole trip's lines; a phone shows one
* day and hides the rest, which is the same filter read from the other end. Rather
* than a second `collapsedRoadtripDays` set built backwards from the selection (a
* set that would then have to be kept out of the desktop's own folding), the
* selection picks its lines here, and the untouched whole-trip arrays stay available
* for the "all days" view.
*
* `lineDays` runs parallel to `lines`, so both are filtered in one pass and the
* colours stay lined up with what is left. Same rule the desktop follows.
*/
export interface StageMapData {
lines: [number, number][][]
lineColors: { line: string; casing: string }[] | undefined
accessLines: AccessSpur[]
/** Place ids on this stage, for the marker filter. Empty means "no filter". */
placeIds: Set<number>
focusPoints: [number, number][]
}
const EMPTY: StageMapData = {
lines: [],
lineColors: undefined,
accessLines: [],
placeIds: new Set(),
focusPoints: [],
}
export function stageMapData(
routes: RoadtripRoutes,
stage: RoadtripDay | null,
dayColorsOn: boolean,
): StageMapData {
// No stage selected is the "whole drive" view, which is every line in its own
// colour, the best overview the addon has on a phone, and it costs nothing
// because the routing round has already produced all of it.
if (!stage) {
return {
lines: routes.lines,
lineColors: dayColorsOn ? routes.lineDays.map(n => dayColor(n)) : undefined,
accessLines: routes.accessLines,
placeIds: new Set(),
focusPoints: [],
}
}
if (!routes.lines.length) return { ...EMPTY, placeIds: stagePlaceIds(stage), focusPoints: stagePoints(stage) }
const keep: number[] = []
routes.lineDays.forEach((n, i) => { if (n === stage.dayNumber) keep.push(i) })
const placeIds = stagePlaceIds(stage)
return {
lines: keep.map(i => routes.lines[i]),
lineColors: dayColorsOn ? keep.map(() => dayColor(stage.dayNumber)) : undefined,
// A spur belongs to a stop, so it comes along exactly when its stop does.
accessLines: routes.accessLines.filter(spur => spurOnStage(spur, stage)),
placeIds,
focusPoints: stagePoints(stage),
}
}
/**
* The places drawn on this stage.
*
* Automatic nights are left out: they are the shell's own marker for where a day
* ended, not somewhere anybody stops, and they have no place row behind them.
*/
export function stagePlaceIds(stage: RoadtripDay): Set<number> {
return new Set(stage.stops.filter(s => !s.automaticNight).map(s => s.placeId))
}
/** The stage's stops as coordinates, for the fit. */
export function stagePoints(stage: RoadtripDay): [number, number][] {
return stage.stops
.filter(s => Number.isFinite(s.lat) && Number.isFinite(s.lng))
.map(s => [s.lat, s.lng] as [number, number])
}
/**
* A spur's key is the one `assemble` builds: position to five decimals plus both
* leg modes. Rebuilt here rather than imported because it is assemble's private
* detail, and the five decimals are what make it exact enough to tell two stops in
* the same car park apart.
*/
function spurOnStage(spur: AccessSpur, stage: RoadtripDay): boolean {
return stage.stops.some(s =>
`${s.lat.toFixed(5)},${s.lng.toFixed(5)},${s.legMode ?? ''},${s.incomingLegMode ?? ''}` === spur.stopKey)
}
@@ -30,6 +30,9 @@ const hit = (id: string, lat: number, lng: number, category = 'fuel') => ({
source: 'openstreetmap',
})
/** Long enough to tile into a handful of boxes, so a cap and a window can bite. */
const LONG: LatLng[] = Array.from({ length: 12 }, (_, i) => ({ lat: 53.55 - i * 0.05, lng: 9.99 - i * 0.2 }))
const answer = (...items: ReturnType<typeof hit>[]) => ({ pois: items, sources: ['openstreetmap'], failedSources: [], truncated: false, clamped: false })
beforeEach(() => {
@@ -192,4 +195,70 @@ describe('useCorridorPois', () => {
unmount()
expect(seen!.aborted).toBe(true)
})
it('FE-ROADTRIP-CORRIDOR-013: a window cuts the boxes, not the line the hits are measured on', async () => {
const full = renderHook(() => useCorridorPois(LONG, ['fuel'], 10))
act(() => { full.result.current.search() })
await waitFor(() => expect(full.result.current.loading).toBe(false))
const whole = pois.mock.calls.length
const spine = full.result.current.spine.length
pois.mockClear()
const part = renderHook(() => useCorridorPois(LONG, ['fuel'], 10))
act(() => { part.result.current.search({ fromKm: 0, toKm: 20 }) })
await waitFor(() => expect(part.result.current.loading).toBe(false))
// Fewer requests, which is the whole point of asking about a stretch...
expect(pois.mock.calls.length).toBeLessThan(whole)
// ...and the same spine, which is what keeps `alongKm` meaning the same thing. A
// cut spine would renumber every hit and every stop the moment the reach moved.
expect(part.result.current.spine.length).toBe(spine)
})
it('FE-ROADTRIP-CORRIDOR-014: the window belongs to the search, so the last answer survives the next question', async () => {
pois.mockResolvedValue(answer(hit('a', 53.5, 9.81)))
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 10))
act(() => { result.current.search({ fromKm: 0, toKm: 20 }) })
await waitFor(() => expect(result.current.results.length).toBeGreaterThan(0))
// Re-rendering with a different reach in mind must not empty the list: only
// asking again does. The window is an argument, never a dependency.
expect(result.current.results.map(r => r.osm_id)).toEqual(['a'])
})
it('FE-ROADTRIP-CORRIDOR-015: a budget caps the boxes and says the tail was left out', async () => {
const { result } = renderHook(() =>
useCorridorPois(LONG, ['fuel'], 10, { budget: { maxTiles: 2, maxRetries: null, deadlineMs: null } }))
act(() => { result.current.search() })
await waitFor(() => expect(result.current.loading).toBe(false))
expect(pois.mock.calls.length).toBe(2)
expect(result.current.capped).toBe(true)
})
it('FE-ROADTRIP-CORRIDOR-016: a capped retry pass reports the boxes it did not ask again', async () => {
pois.mockRejectedValue(new Error('mirror down'))
const { result } = renderHook(() =>
useCorridorPois(LONG, ['fuel'], 10, { budget: { maxTiles: 4, maxRetries: 1, deadlineMs: null } }))
act(() => { result.current.search() })
await waitFor(() => expect(result.current.loading).toBe(false))
// Four boxes, four failures, one retried: the three never asked again are still
// failures, because "we did not look" is the honest reading either way.
expect(pois.mock.calls.length).toBe(5)
expect(result.current.failedAreas).toBe(4)
expect(result.current.error).toBe(true)
})
it('FE-ROADTRIP-CORRIDOR-017: a deadline stops starting boxes and files the rest as unsearched', async () => {
// Every box answers, slowly. The deadline is what ends the run, not the answers.
pois.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(answer()), 40)))
const { result } = renderHook(() =>
useCorridorPois(LONG, ['fuel'], 10, { budget: { maxTiles: 16, maxRetries: 0, deadlineMs: 1 } }))
act(() => { result.current.search() })
await waitFor(() => expect(result.current.loading).toBe(false), { timeout: 4000 })
expect(result.current.failedAreas).toBeGreaterThan(0)
expect(pois.mock.calls.length).toBeLessThan(result.current.progress.total + 1)
})
})
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { roadtripSearchRepo } from '../../repo/roadtripSearchRepo'
import { useTranslation } from '../../i18n'
import { corridorTiles, projectOntoRoute, simplifyLine, type Bbox, type LatLng } from './corridor'
import { corridorTiles, projectOntoRoute, simplifyLine, sliceAtMeters, type Bbox, type LatLng } from './corridor'
import { DESKTOP_CORRIDOR_BUDGET, type CorridorBudget } from './corridorSearchModel'
import type { Poi } from '../Map/poiCategories'
/** A POI that is actually on the way, with the two numbers that make it one. */
@@ -30,16 +31,24 @@ export interface CorridorSearch {
error: boolean
/** The thinned line every `alongKm` above is measured along. */
spine: LatLng[]
search: () => void
/**
* Asks, optionally only about a stretch of the line.
*
* The window is an argument rather than hook state because it belongs to the question:
* one traveller asks about the fifty kilometres ahead and the next about the whole day,
* off the same route and the same categories, and neither should make the other's
* answer disappear before it was read.
*/
search: (window?: CorridorWindow | null) => void
clear: () => void
}
/**
* How many bounding boxes one search may ask Overpass for. A German-to-Italian drive
* would otherwise be dozens of requests against a shared public mirror; past this the
* search stops and says so rather than quietly covering half the route.
*/
const MAX_TILES = 16
/** A stretch of the drive, in kilometres along the spine. */
export interface CorridorWindow {
fromKm: number
toKm: number
}
/** Gap after a request before the same worker starts the next — the mirrors are shared. */
const REQUEST_SPACING_MS = 250
/** How many boxes are in flight at once. */
@@ -64,7 +73,21 @@ const sleep = (ms: number, signal: AbortSignal): Promise<void> =>
* every route change, because each run is several Overpass requests and the mirrors are
* shared. Results stay until the categories or the route change.
*/
export function useCorridorPois(line: LatLng[], categories: string[], widthKm: number): CorridorSearch {
export function useCorridorPois(
line: LatLng[],
categories: string[],
widthKm: number,
/**
* What one search may cost.
*
* Absent means the desktop's behaviour, unchanged: every box of the whole day, every
* failed box retried, no deadline. The phone passes a smaller ceiling, because a cell
* is not a desk and a search that keeps the radio warm for a minute is a search that
* costs battery somebody is navigating on.
*/
options?: { budget?: CorridorBudget },
): CorridorSearch {
const budget = options?.budget ?? DESKTOP_CORRIDOR_BUDGET
const { locale } = useTranslation()
const [results, setResults] = useState<CorridorPoi[]>([])
const [progress, setProgress] = useState({ done: 0, total: 0 })
@@ -104,15 +127,22 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
const catKey = categories.join(',')
useEffect(() => { clear() }, [routeKey, catKey, widthKm, clear])
const search = useCallback(() => {
const search = useCallback((window?: CorridorWindow | null) => {
abortRef.current?.abort()
if (spine.length < 2 || categories.length === 0) return
const controller = new AbortController()
abortRef.current = controller
const runId = ++runIdRef.current
const allTiles = corridorTiles(spine, widthKm)
const tiles = allTiles.slice(0, MAX_TILES)
// Only the TILE line is cut, never the spine: every `alongKm` in a result is a
// distance along the spine, and so is every entry in `stopsAlongKm`. Cutting the
// spine would renumber both the moment the window moved, and a hit would land at the
// wrong position in the day's chain.
const tileLine = window ? sliceAtMeters(spine, window.fromKm * 1000, window.toKm * 1000) : spine
const allTiles = corridorTiles(tileLine, widthKm)
const tiles = allTiles.slice(0, budget.maxTiles)
const startedAt = Date.now()
const outOfTime = (): boolean => budget.deadlineMs != null && Date.now() - startedAt > budget.deadlineMs
setCapped(allTiles.length > tiles.length)
setFailedSources([])
@@ -180,6 +210,9 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
const worker = async (): Promise<void> => {
while (next < jobs.length) {
if (controller.signal.aborted || runId !== runIdRef.current) return
// Out of time stops STARTING boxes; whatever answered stays on screen and the
// rest reports itself as unsearched, the same way a failed box does.
if (outOfTime()) { failures += jobs.length - next; setFailedAreas(failures); return }
const job = jobs[next++]
if (!(await collect(job))) retryable.push(job)
done++
@@ -195,8 +228,11 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
// One more pass over what did not answer. Overpass mirrors time out under load far
// more often than they are truly empty, and reporting "4 stretches went unsearched"
// when a second ask would have answered them is the wrong end of the trade.
for (const job of retryable) {
const retries = budget.maxRetries == null ? retryable : retryable.slice(0, budget.maxRetries)
failures += retryable.length - retries.length
for (const job of retries) {
if (controller.signal.aborted || runId !== runIdRef.current) return
if (outOfTime()) { failures += retries.length - retries.indexOf(job); break }
if (await collect(job)) continue
failures++
setFailedAreas(failures)
@@ -205,7 +241,7 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
setError(failures === jobs.length && failures > 0)
setLoading(false)
})()
}, [spine, categories, widthKm, locale])
}, [spine, categories, widthKm, locale, budget.maxTiles, budget.maxRetries, budget.deadlineMs])
useEffect(() => () => abortRef.current?.abort(), [])
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCorridorPois, type CorridorPoi, type CorridorSearch } from './useCorridorPois'
import type { CorridorBudget } from './corridorSearchModel'
import { sectionAnchors, insertIndexForAlong } from './roadtripModel'
import type { SectionAnchor } from './roadtripModel'
import { projectOntoRoute, type LatLng } from './corridor'
@@ -74,7 +75,16 @@ export interface RoadtripCorridor {
* answer if you cannot see which one is on your side of the road — so the results have to
* live above both the panel and the map rather than inside the panel.
*/
export function useRoadtripCorridor(routes: RoadtripRoutes, tripId?: number | string | null): RoadtripCorridor {
export function useRoadtripCorridor(
routes: RoadtripRoutes,
tripId?: number | string | null,
/**
* What one search may cost. Absent is the desktop's ceiling; the phone hands down a
* smaller one. Not a window: which stretch of the day to ask about is decided per
* search, by whoever presses the button, and travels as an argument to `search`.
*/
options?: { budget?: CorridorBudget },
): RoadtripCorridor {
const [dayId, setDayId] = useState<string>('')
const [categories, setCategories] = useState<string[]>(['fuel'])
const [widthKm, setWidthKm] = useState<number>(5)
@@ -109,7 +119,7 @@ export function useRoadtripCorridor(routes: RoadtripRoutes, tripId?: number | st
return day.stops.map(s => ({ lat: s.lat, lng: s.lng }))
}, [day])
const search = useCorridorPois(line, categories, widthKm)
const search = useCorridorPois(line, categories, widthKm, options)
/**
* Whether what the panel looks for has been decided yet.
+2
View File
@@ -12,6 +12,7 @@
*/
export const TRIP_TAB_IDS = [
'plan',
'roadtrip',
'transports',
'buchungen',
'listen',
@@ -30,6 +31,7 @@ export type TripTabId = (typeof TRIP_TAB_IDS)[number]
*/
export const TRIP_TAB_LABEL_KEYS: Record<TripTabId, string> = {
plan: 'trip.tabs.plan',
roadtrip: 'roadtrip.title',
transports: 'trip.tabs.transports',
buchungen: 'trip.tabs.reservations',
listen: 'trip.tabs.lists',
+11 -2
View File
@@ -3,17 +3,26 @@ import { ReactNode } from 'react'
interface MChipProps {
active?: boolean
onClick?: () => void
/**
* `sm` is the 30px chip a form sheet packs six of into a row; `tap` is 38px, for a
* chip that IS the control rather than a setting beside one. Apple's own floor is
* 44px and a chip cannot be that and still fit a row of categories, so `tap` buys
* back what it can: the chips that start a search are the ones worth the height.
*/
size?: 'sm' | 'tap'
className?: string
children: ReactNode
}
/** Small pill chip: --m-act when active, neutral --m-ic surface otherwise. */
export default function MChip({ active = false, onClick, className = '', children }: MChipProps) {
export default function MChip({ active = false, onClick, size = 'sm', className = '', children }: MChipProps) {
return (
<button
type="button"
onClick={onClick}
className={`inline-flex flex-none items-center gap-[6px] rounded-full px-3 py-[7px] text-[0.75rem] font-semibold ${
className={`inline-flex flex-none items-center gap-[6px] rounded-full font-semibold ${
size === 'tap' ? 'px-[13px] py-[10px] text-[0.8125rem]' : 'px-3 py-[7px] text-[0.75rem]'
} ${
active
? 'bg-m-act text-m-actfg'
: 'border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-ink'
+45 -1
View File
@@ -343,10 +343,54 @@
* MapLibre both, because a phone meets either depending on the screen. */
.m-root .leaflet-control-attribution,
.m-root .maplibregl-ctrl-bottom-left,
.m-root .maplibregl-ctrl-bottom-right {
.m-root .mapboxgl-ctrl-bottom-left,
.m-root .maplibregl-ctrl-bottom-right,
.m-root .mapboxgl-ctrl-bottom-right {
bottom: var(--bottom-nav-h);
}
/* ...and it is a round (i) rather than a band across the picture.
*
* Both GL engines collapse their attribution below 640px and then open it anyway,
* tucking it away only on the first drag. On a phone, where the map IS the screen,
* the first thing anybody sees is then a two-line grey ribbon over the bottom of
* it. MapViewGL now starts it collapsed; what is left here is where the little
* button sits, which is beside the locate control rather than underneath it.
*
* Nothing is dropped: one tap opens the full credit, and it is still a link. */
.m-root .maplibregl-ctrl-bottom-right,
.m-root .mapboxgl-ctrl-bottom-right {
bottom: calc(var(--bottom-nav-h) + 2px);
right: 46px;
}
/* Leaflet has no collapsed mode at all, so it is given one: hidden until the (i) beside
* the locate button is tapped (`MapAttributionToggle`, which puts `m-attrib-open` on the
* map's wrapper). Nothing is lost: one tap shows the whole credit, every link in it
* included, which is the same bargain the GL engines strike on a narrow map. */
.m-root .leaflet-control-attribution { display: none; }
/* The control column, not just the block inside it: Leaflet sizes that column to the
credit on one line, so capping only the child leaves the text overflowing its own
box instead of wrapping. */
.m-root .m-attrib-open .leaflet-bottom.leaflet-right { max-width: 78vw; }
.m-root .m-attrib-open .leaflet-control-attribution {
display: block;
/* Above the (i) rather than beside it, so the block never lands under a thumb
reaching for the locate button. */
right: 12px;
bottom: calc(var(--bottom-nav-h) + 52px);
padding: 4px 9px;
border-radius: 11px;
font-size: 10px;
line-height: 1.4;
background: color-mix(in srgb, var(--m-card) 92%, transparent);
color: var(--m-muted);
box-shadow: 0 10px 28px -12px rgba(0, 0, 0, .45);
}
.m-root .m-attrib-open .leaflet-control-attribution a { color: var(--m-ink); }
/* Text fields carry the row's background, not their own.
*
* iOS paints its own background inside a text control, and under
+126 -25
View File
@@ -1,11 +1,11 @@
import { useEffect, useRef, useState, type ComponentType, type ReactNode } from 'react'
import { findFocusDayId } from '../../../components/Planner/today'
import {
CalendarDays, ChevronDown, ChevronLeft, Download, FileDown, List, Map as MapIcon, MoreHorizontal, PackageCheck,
Plane, Plus, Rows3, Ticket, TrainFront, Trash2, Upload, Wallet,
CalendarDays, ChevronDown, ChevronLeft, Download, FileDown, List, Map as MapIcon, MoreHorizontal,
Plane, Plus, Rows3, Route, SlidersHorizontal, Trash2, Upload,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useTripPlanner } from '../../../pages/tripPlanner/useTripPlanner'
import { pickDockTabs } from './dockTabs'
import MIconBtn from '../../components/MIconBtn'
import MPlanTimeline from './plan/MPlanTimeline'
import MMapArea from './map/MMapArea'
@@ -14,6 +14,10 @@ import MTripTabPanel from './tabs/MTripTabPanel'
import MTripSheets from './sheets/MTripSheets'
import MTripLoadingSplash from './MTripLoadingSplash'
import { usePluginDayTints, dayTintBackground } from '../../../components/Plugins/PluginDaySchedule'
import { stageOf } from '../../../components/Roadtrip/roadtripRowModel'
import type { CorridorReach } from '../../../components/Roadtrip/corridorSearchModel'
import { useSettingsStore } from '../../../store/settingsStore'
import { formatDistance } from '../../../utils/units'
import type { Day } from '../../../types'
/**
@@ -34,6 +38,8 @@ export type TripPlanner = ReturnType<typeof useTripPlanner>
export type MTripView = 'plan' | 'map'
export type MTripMode = 'go' | 'edit' | 'browse'
/** The road trip tab's own two halves: the chain, or the same map showing the stage. */
export type MRtView = 'list' | 'map'
export type MTripListsTab = 'packing' | 'todo'
export type MTripCollabTab = 'chat' | 'notes' | 'links' | 'polls'
@@ -60,6 +66,27 @@ export interface MTripSheetState {
export interface MTripShellApi {
/** 'plan' = list chrome, 'map' = fullscreen map. Plan tab only. */
view: MTripView
/** The road trip tab's own list ⇄ map switch, independent of `view`. */
rtView: MRtView
/**
* True whenever the map is the front layer, in either tab.
*
* The map is one instance shared by the plan tab and the road trip tab, so
* everything floating over it (POI pill, compass, pills) keys off this rather
* than off `view`, which only ever meant the plan tab.
*/
mapFront: boolean
/** Road trip tab: chain ⇄ map, on the same instance, without moving the camera. */
toggleRtView: () => void
/**
* How much of the stage the corridor search asks about.
*
* Shell state rather than sheet state, because two surfaces ask the same question:
* the bar over the stage says what the next search will cover, and the sheet is where
* it is chosen. Kept here they cannot disagree, and it survives the sheet closing.
*/
rtReach: CorridorReach
setRtReach: (value: CorridorReach) => void
/** Travel/Plan/Places segment: go | edit | browse. */
mode: MTripMode
/** Legacy tab ids: plan · transports · buchungen · listen · finanzplan · dateien · collab · plugin:* */
@@ -150,14 +177,8 @@ interface MTripShellProps {
Sheets?: ComponentType<MTripSheetsProps>
}
/** The 5 dock tabs in demo order; files/collab/plugins live in the Mehr sheet. */
const DOCK_TABS: { id: string; icon: LucideIcon }[] = [
{ id: 'plan', icon: MapIcon },
{ id: 'transports', icon: TrainFront },
{ id: 'buchungen', icon: Ticket },
{ id: 'finanzplan', icon: Wallet },
{ id: 'listen', icon: PackageCheck },
]
/** The two tabs that share the map instance. Everything else is a full overlay. */
const MAP_TABS = new Set(['plan', 'roadtrip'])
function dayChipLabel(day: Day, language: string, fallback: string): string {
if (day.date) {
@@ -182,8 +203,14 @@ export default function MTripShell({
// Per-day colours from the dayTintProvider plugin hook — the mobile counterpart
// of the desktop day-card wash, carried on the day chips. Empty without a plugin.
const dayTints = usePluginDayTints(tripId)
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const [view, setView] = useState<MTripView>('plan')
// Its own state rather than a share of `view`: the two tabs carry different
// things on the same map, and forcing one into the other's half is the kind of
// coupling nobody can predict from the outside.
const [rtView, setRtView] = useState<MRtView>('list')
const [rtReach, setRtReach] = useState<CorridorReach>('ahead')
const [mode, setMode] = useState<MTripMode>('go')
const [browseFromEdit, setBrowseFromEdit] = useState(false)
const [sheet, setSheet] = useState<MTripSheetState | null>(null)
@@ -318,6 +345,24 @@ export default function MTripShell({
}
}
/**
* The road trip tab's own list ⇄ map switch.
*
* Deliberately quieter than `toggleView`: no reframe on the way in and no day
* restore on the way out. The stage is always one day, the chain and the map show
* the same one, and the camera belongs to whoever looked at it last. What does
* reframe is a stage change, through the day chips, which both halves share.
*/
const toggleRtView = () => {
setRtView(prev => {
const next = prev === 'list' ? 'map' : 'list'
if (next === 'map') planner.autoShowRoute()
return next
})
}
const mapFront = (trTab === 'plan' && view === 'map') || (trTab === 'roadtrip' && rtView === 'map')
const setListsTab = (tab: MTripListsTab) => {
setListsTabState(tab)
sessionStorage.setItem(`trip-lists-subtab-${tripId}`, tab)
@@ -327,7 +372,7 @@ export default function MTripShell({
const closeSheet = () => setSheet(null)
const shell: MTripShellApi = {
view, mode, trTab, setTrTab, setTravelMode, toggleView, browseFromEdit,
view, rtView, mapFront, toggleRtView, rtReach, setRtReach, mode, trTab, setTrTab, setTravelMode, toggleView, browseFromEdit,
sheet, openSheet, closeSheet,
listsTab, setListsTab, collabTab, setCollabTab,
transportsCompact, bookingsCompact,
@@ -341,7 +386,7 @@ export default function MTripShell({
if (!trip) return null
const enabledTabIds = new Set(planner.TRIP_TABS.map(tab => tab.id))
const dockTabs = DOCK_TABS.filter(d => enabledTabIds.has(d.id))
const dockTabs = pickDockTabs(enabledTabIds)
const tabLabel = (id: string) => planner.TRIP_TABS.find(tab => tab.id === id)?.label ?? id
const onDayChipTap = (dayId: number) => {
@@ -355,25 +400,59 @@ export default function MTripShell({
const packedCount = packingItems.filter(i => i.checked).length
const todoOpenCount = todoItems.filter(i => !i.checked).length
// The stage header: the day on screen and what it costs, or the whole drive while
// the day filter is off. Falls back to the addon's own name before anything has
// routed, so the pill never reads as an empty figure.
const rtStage = stageOf(planner.roadtripRoutes.days, planner.selectedDayId)
const rtStageDay = rtStage ? days.find(d => d.id === rtStage.dayId) : undefined
const rtHeaderLabel = rtStage
? [
rtStageDay ? dayChipLabel(rtStageDay, language, t('planner.dayN', { n: rtStage.dayNumber })) : t('planner.dayN', { n: rtStage.dayNumber }),
formatDistance(rtStage.distance / 1000, distanceUnit),
].join(' · ')
: planner.roadtripRoutes.totalDistance > 0
? formatDistance(planner.roadtripRoutes.totalDistance / 1000, distanceUnit)
: t('roadtrip.title')
return (
<div className="fixed inset-0 z-50 overflow-hidden bg-[color:var(--m-bg)] bg-[image:var(--m-scr)] text-m-ink">
{/* ── Content layers ─────────────────────────────────────────────── */}
{trTab === 'plan' && (
{/*
One expression for both map tabs, and it has to stay one: written as two
(`{trTab === 'plan' && <MapArea/>}{trTab === 'roadtrip' && <MapArea/>}`)
React sees two positions and remounts the map on every tab switch, which
tears down the WebGL context and reloads every tile. The symptom is a flash
on the device, not a failing test.
*/}
{MAP_TABS.has(trTab) && (
<div className="absolute inset-0">
<MapArea planner={planner} shell={shell} />
{view === 'plan' && mode !== 'browse' && (
{trTab === 'plan' && view === 'plan' && mode !== 'browse' && (
<div className="absolute inset-0 z-10 bg-[color:var(--m-bg)] bg-[image:var(--m-scr)]">
<PlanTimeline planner={planner} shell={shell} />
</div>
)}
{mode === 'browse' && (
{trTab === 'plan' && mode === 'browse' && (
<div className="absolute inset-0 z-30 bg-[color:var(--m-bg)] bg-[image:var(--m-scr)]">
<PlacesBrowser planner={planner} shell={shell} />
</div>
)}
{/*
The stage lives at z-20, below the day chips at z-25, which is the whole
of the stage picker: the chips belong to the shell and were only ever
hidden because non-plan panels cover them at z-30.
*/}
{trTab === 'roadtrip' && rtView === 'list' && (
<div className="absolute inset-0 z-20 bg-[color:var(--m-bg)] bg-[image:var(--m-scr)]">
<TabPanel planner={planner} shell={shell} tab={trTab} />
</div>
)}
{trTab === 'roadtrip' && rtView === 'map' && (
<TabPanel planner={planner} shell={shell} tab={trTab} />
)}
</div>
)}
{trTab !== 'plan' && (
{!MAP_TABS.has(trTab) && (
<div className="absolute inset-0 z-30 bg-[color:var(--m-bg)] bg-[image:var(--m-scr)]">
<TabPanel planner={planner} shell={shell} tab={trTab} />
</div>
@@ -411,10 +490,11 @@ export default function MTripShell({
})}
</div>
{/* Drop the day filter and show the whole trip (#2257). Map only: the
plan timeline is single-day, so there is nothing to widen there.
Stays mounted while a day is active rather than appearing on
selection, which would shove the rail sideways under the thumb. */}
{view === 'map' && (
plan timeline and the road trip chain are both single-day, so there is
nothing to widen there. Stays mounted while a day is active rather than
appearing on selection, which would shove the rail sideways under the
thumb. On the road trip map it means the whole drive in its day colours. */}
{mapFront && (
<button
type="button"
onClick={toggleAllDays}
@@ -554,6 +634,24 @@ export default function MTripShell({
</GlassSegment>
)}
{/* The stage's own header: the day it shows, and a way into the figures
behind it. A button rather than the plugin tabs' inert pill, because it
is the only entry to the driving settings, and the right slot belongs to
the list ⇄ map switch, same as in the plan tab. */}
{trTab === 'roadtrip' && (
<button
type="button"
onClick={() => openSheet('rtinfo')}
className="absolute left-[52px] right-[52px] top-1/2 mx-auto flex h-[34px] w-fit max-w-full -translate-y-1/2 items-center gap-[6px] rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-[13px] backdrop-blur-[24px] backdrop-saturate-[1.7]"
>
<Route size={14} strokeWidth={2} className="flex-none text-m-muted" aria-hidden="true" />
<span className="min-w-0 truncate text-[0.8125rem] font-semibold tabular-nums text-m-ink">
{rtHeaderLabel}
</span>
<SlidersHorizontal size={13} strokeWidth={2} className="flex-none text-m-faint" aria-hidden="true" />
</button>
)}
{trTab === 'dateien' && (
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center gap-[7px]">
<PrimaryPill icon={<Upload size={13} strokeWidth={2.2} />} 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) ? (
<MIconBtn
ariaLabel={view === 'plan' ? t('mobileTrip.mapView') : t('mobileTrip.listView')}
onClick={toggleView}
ariaLabel={mapFront ? t('mobileTrip.listView') : t('mobileTrip.mapView')}
onClick={trTab === 'roadtrip' ? toggleRtView : toggleView}
className="backdrop-blur-[24px] backdrop-saturate-[1.7]"
>
{view === 'plan' ? <MapIcon size={18} strokeWidth={2} /> : <List size={18} strokeWidth={2} />}
{mapFront ? <List size={18} strokeWidth={2} /> : <MapIcon size={18} strokeWidth={2} />}
</MIconBtn>
) : (
<span className="w-[38px] flex-none" />
@@ -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)
})
})
@@ -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<string>): { 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<string>): Set<string> {
return new Set(pickDockTabs(enabledTabIds).map(tab => tab.id))
}
+83 -14
View File
@@ -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.
<div className="absolute inset-0 isolate overflow-hidden bg-[color:var(--m-mapb)] [--bottom-nav-h:calc(env(safe-area-inset-bottom,0px)+74px)]">
// 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.
<div
className="absolute inset-0 isolate overflow-hidden bg-[color:var(--m-mapb)] [--bottom-nav-h:calc(env(safe-area-inset-bottom,0px)+74px+var(--m-stage-lift,0px))]"
// 76px is the stage bar's own height plus the gap it keeps on both sides, so the
// round controls land one gap above it instead of on its top edge.
style={{ ['--m-stage-lift' as string]: onStage && mapActive ? '76px' : '0px' }}
>
<MapViewAuto
tripId={planner.tripId}
dawarichTrack={planner.dawarichTrail.track}
places={planner.mapPlaces}
dayPlaces={planner.dayPlaces}
route={planner.overviewActive ? planner.tripOverview.lines : planner.route}
routeColors={planner.overviewActive ? planner.tripOverview.lineColors : undefined}
focusPoints={planner.overviewActive ? planner.tripOverview.focusPoints : undefined}
routeVias={planner.routeVias}
showTransitRoutes={planner.transitRoutesShown}
places={stageMap ? stagePlaces : planner.mapPlaces}
dayPlaces={onStage ? undefined : planner.dayPlaces}
route={stageMap ? stageMap.lines : planner.overviewActive ? planner.tripOverview.lines : planner.route}
routeColors={stageMap ? stageMap.lineColors : planner.overviewActive ? planner.tripOverview.lineColors : undefined}
accessLines={stageMap ? stageMap.accessLines : undefined}
// A hit somebody tapped in the search sheet, or the stations the fuel search is
// offering, take the camera; with nothing pending the stage frames itself.
focusPoints={stageMap
? (planner.mapFocusPoints.length ? planner.mapFocusPoints : stageMap.focusPoints)
: planner.overviewActive ? planner.tripOverview.focusPoints : undefined}
routeVias={onStage ? planner.roadtripMapVias : planner.routeVias}
showTransitRoutes={onStage ? false : planner.transitRoutesShown}
// The route toggle belongs to one day, so the map needs that day to know
// which automated transports may ride it (#2019).
days={planner.days}
selectedDayId={planner.selectedDayId}
routeSegments={planner.overviewActive ? planner.tripOverview.segments : planner.routeSegments}
routeSegments={onStage ? undefined : planner.overviewActive ? planner.tripOverview.segments : planner.routeSegments}
selectedPlaceId={planner.selectedPlaceId}
onMarkerClick={planner.handleMarkerClick}
// Tap on empty map = deselect, same contract as desktop.
@@ -76,8 +139,14 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
// Transport overlay tap → the mobile transport detail sheet (desktop
// routes this through mapTransportDetail into the day sidebar instead).
onReservationClick={(rid: number) => 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 && (
<div className="pointer-events-none absolute left-4 right-4 z-[25] flex flex-col items-center gap-2 top-[calc(var(--m-safe-top,12px)+96px)]">
<PoiCategoryPill
fullWidth
@@ -117,7 +186,7 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
Tailwind rather than inline because the compass band is identified by being
the one element with an inline --bottom-nav-h, and a second would make that
ambiguous. */}
{mapActive && (!planner.roadtripActive || planner.dawarichEnabled) && (
{mapActive && !onStage && (!planner.roadtripActive || planner.dawarichEnabled) && (
<div className="pointer-events-none absolute left-3 right-3 z-[25] flex flex-col items-end gap-2 bottom-[calc(var(--bottom-nav-h,84px)+58px)]">
{!planner.roadtripActive && planner.overviewActive && (
<TripRouteOverviewPanel
@@ -0,0 +1,371 @@
import { AlertTriangle, Bike, CarFront, Footprints, Fuel, Hourglass, Moon, Pin, Plus, Search, Sunrise, Zap } from 'lucide-react'
import type { ReactNode } from 'react'
import { formatDurationShort, serviceColor } from '../../../../components/Roadtrip/roadtripModel'
import { STOP_KIND_BY_KEY } from '../../../../components/Roadtrip/stopKinds'
import { formatDistance } from '../../../../utils/units'
import type { StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
import type { RefuelSearch } from '../../../../components/Roadtrip/useRefuelSearch'
import type { RefuelCandidate } from '../../../../components/Roadtrip/refuelSuggestion'
import type { DistanceUnit, RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip'
import type { TranslationFn } from '../../../../types'
/**
* The four row types of the mobile drive chain, plus the two bands that interrupt it.
*
* What the desktop rail does in five stacked 8px badges per stop, this does in at most
* two 10.5px marks: on touch there is no hover, so a badge nobody can explain is worse
* than no badge. The rest of a stop's findings live in its sheet, written out as full
* sentences. Which mark survives is decided in `roadtripRowModel.pickWarning`, not here.
*
* Nothing in this file except a destination row reacts to a tap. Drive bands, discs,
* marks, the night block and the day-end point carry no role and no chevron, so
* nothing looks like a button that isn't one.
*/
export interface RowChrome {
t: TranslationFn
unit: DistanceUnit
}
/** 30px disc: a number for a destination, the kind's colour for a service stop. */
function Disc({ row }: { row: StopRow }) {
const kind = row.stop.stopType ? STOP_KIND_BY_KEY[row.stop.stopType] : undefined
if (row.service && kind) {
const Icon = kind.Icon
return (
<span
className="grid h-[30px] w-[30px] shrink-0 place-items-center rounded-full text-white"
// theme-lint-disable: the stop kinds share their colour with the map markers,
// which sit on tiles rather than on one of the app's own surfaces.
style={{ background: serviceColor(row.stop.stopType) }}
>
<Icon size={15} strokeWidth={2.1} aria-hidden="true" />
</span>
)
}
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.
<span className="grid h-[30px] w-[30px] shrink-0 place-items-center rounded-full bg-[color:var(--m-ic)] text-[0.75rem] font-bold tabular-nums text-m-ink">
{row.number}
</span>
)
}
function Mark({ icon, children, tone }: { icon: ReactNode; children: ReactNode; tone?: 'warn' }) {
return (
<span
className="inline-flex h-[22px] items-center gap-[4px] rounded-full px-[8px] font-geist text-[0.65625rem] font-semibold"
style={tone === 'warn'
? { background: 'color-mix(in srgb, var(--m-st-pending) 14%, transparent)', color: 'var(--m-st-pending)' }
: { background: 'var(--m-ic)', color: 'var(--m-muted)' }}
>
{icon}
{children}
</span>
)
}
/** 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 <Mark tone="warn" icon={<AlertTriangle size={10} strokeWidth={2} />}>{`+${warning.minutes ?? 0} min`}</Mark>
}
if (warning.code === 'range') {
return <Mark tone="warn" icon={<Fuel size={10} strokeWidth={2} />}>{formatDistance(warning.sinceKm ?? 0, unit)}</Mark>
}
if (warning.code === 'leg') {
return <Mark tone="warn" icon={<Hourglass size={10} strokeWidth={2} />}>{formatDurationShort((warning.overMinutes ?? 0) * 60)}</Mark>
}
return <Mark tone="warn" icon={<Moon size={10} strokeWidth={2} />}>{t('roadtrip.warn.overnight')}</Mark>
}
/**
* 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(
<Mark key="dwell" icon={<Hourglass size={10} strokeWidth={2} />}>{formatDurationShort(row.dwellMinutes * 60)}</Mark>,
)
}
if (row.warning) marks.push(<span key="warn">{warningMark(row.warning, chrome)}</span>)
if (!marks.length && row.offRoadMeters) {
marks.push(
<Mark key="off" icon={<Footprints size={10} strokeWidth={2} />}>
{formatDistance(row.offRoadMeters / 1000, unit)}
</Mark>,
)
}
return (
<div
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={e => { 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' }}
>
<span className="flex justify-center"><Disc row={row} /></span>
<span className="min-w-0 py-2">
<span className={`block text-[0.875rem] leading-[1.25] ${row.service ? 'truncate font-medium text-m-muted' : 'line-clamp-2 font-semibold text-m-ink'}`}>
{row.stop.name}
</span>
{marks.length > 0 && <span className="mt-[4px] flex flex-wrap items-center gap-[5px]">{marks}</span>}
</span>
{row.time && (
// dir=ltr so a clock reads the same way round in an RTL locale.
<span dir="ltr" className={`flex items-center gap-[3px] whitespace-nowrap text-[0.8125rem] tabular-nums ${row.pinned ? 'font-semibold text-m-ink' : 'font-medium text-m-faint'}`}>
{row.pinned && <Pin size={9} strokeWidth={2.4} className="flex-none text-m-faint" aria-label={t('roadtrip.stop.pinned')} />}
{row.time}
</span>
)}
</div>
)
}
const LEG_ICONS: Record<string, typeof CarFront> = {
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 (
<div className="grid items-center gap-x-[10px]" style={{ gridTemplateColumns: '34px 1fr auto' }}>
{/* 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". */}
<span className="flex min-h-[40px] flex-col items-center" aria-hidden="true">
<span className="w-[2px] flex-1" style={{ backgroundImage: 'repeating-linear-gradient(var(--m-conn) 0 4px, transparent 4px 8px)' }} />
</span>
<span className="my-1.5 flex items-center gap-[7px] rounded-[13px] bg-[color:var(--m-ic)] px-[11px] py-[7px]">
<Icon size={14} strokeWidth={2} className="flex-none text-m-muted" aria-hidden="true" />
<span className="truncate text-[0.75rem] font-semibold tabular-nums text-m-ink">{text}</span>
</span>
<span />
</div>
)
}
/**
* 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 (
<div
className="my-2 -mx-1 flex flex-col gap-[7px] rounded-[16px] border px-3 py-2.5"
style={{
borderColor: 'color-mix(in srgb, var(--m-st-danger) 30%, transparent)',
background: 'linear-gradient(180deg, color-mix(in srgb, var(--m-st-danger) 12%, var(--m-card)) 0%, var(--m-card) 100%)',
}}
>
<span className="flex items-center gap-[7px]" style={{ color: 'var(--m-st-danger)' }}>
{electric ? <Zap size={16} strokeWidth={2} aria-hidden="true" /> : <Fuel size={16} strokeWidth={2} aria-hidden="true" />}
<span className="text-[0.8125rem] font-bold">
{electric ? t('roadtrip.refuel.dryElectric') : t('roadtrip.refuel.dry')}
</span>
</span>
<span className="font-geist text-[0.71875rem] text-m-muted">
{t('roadtrip.refuel.after', { distance: formatDistance(Math.round(intoLegKm), unit) })}
</span>
{onSearch && (
<>
<button
type="button"
onClick={onSearch}
disabled={offline}
className="flex h-11 w-full items-center justify-center gap-[7px] rounded-full bg-m-act text-[0.8125rem] font-semibold text-m-actfg disabled:bg-[color:var(--m-ic)] disabled:text-m-faint"
>
<Search size={15} strokeWidth={2.2} aria-hidden="true" />
{electric ? t('roadtrip.refuel.findElectric') : t('roadtrip.refuel.find')}
</button>
{/* Blunt rather than greyed out: the button keeps its shape and says why. */}
{offline && (
<span className="font-geist text-[0.6875rem] leading-[1.4] text-m-faint">{t('mobileTrip.rtSearchOffline')}</span>
)}
</>
)}
{open && refuel.loading && (
<span className="font-geist text-[0.71875rem] text-m-muted">{t('roadtrip.refuel.looking')}</span>
)}
{/* 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 ? (
<ul className="flex flex-col gap-[6px]">
{refuel.results.slice(0, 3).map(poi => (
<RefuelOffer
key={poi.osm_id}
poi={poi}
chrome={chrome}
electric={electric}
onAccept={onAccept ? () => onAccept(poi) : undefined}
/>
))}
</ul>
) : (
/* Three sentences for three different facts. "Nothing on this stretch" after a
request that failed states something that was never checked. */
<span className="font-geist text-[0.71875rem] leading-[1.4] text-m-muted">
{refuel.outcome === 'none'
? t('roadtrip.refuel.none')
: refuel.outcome === 'incomplete'
? t('roadtrip.refuel.incomplete')
: t('roadtrip.refuel.failed')}
</span>
))}
</div>
)
}
/**
* 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 (
<li className="flex items-center gap-2.5 rounded-[14px] bg-[color:var(--m-card)] py-1.5 pe-1.5 ps-2.5">
<span
className="grid h-[26px] w-[26px] flex-none place-items-center rounded-full text-white"
// theme-lint-disable: the stop kinds share their colour with the map markers.
style={{ background: serviceColor(poi.category) }}
aria-hidden="true"
>
<KindIcon size={13} strokeWidth={2.1} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[0.78125rem] font-semibold text-m-ink">{poi.name}</span>
<span className="mt-px block truncate font-geist text-[0.6875rem] tabular-nums text-m-muted">
{[
t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, unit) }),
t('roadtrip.refuel.spare', { distance: formatDistance(Math.round(poi.spareKm), unit) }),
].join(' · ')}
</span>
</span>
{onAccept && (
<button
type="button"
onClick={onAccept}
aria-label={t(electric ? 'roadtrip.refuel.addElectric' : 'roadtrip.refuel.add', { name: poi.name })}
className="grid h-[38px] w-[38px] flex-none place-items-center rounded-full bg-[color:var(--m-ic)] text-m-ink"
>
<Plus size={16} strokeWidth={2.2} aria-hidden="true" />
</button>
)}
</li>
)
}
/** 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 (
<div className="my-1.5 flex min-h-[38px] items-center gap-2 rounded-[13px] bg-[color:var(--m-ic)] px-[11px] py-[7px]">
<Icon size={14} strokeWidth={2} className="flex-none text-m-muted" aria-hidden="true" />
<span className="text-[0.75rem] font-semibold text-m-muted">
{phase === 'end' ? t('roadtrip.window.stop') : t('roadtrip.window.resume')}
</span>
{time && <span className="ms-auto text-[0.8125rem] font-semibold tabular-nums text-m-ink">{time}</span>}
</div>
)
}
/**
* 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 (
<div
className="-mx-1 mb-2 mt-1 overflow-hidden rounded-[16px] border px-3 py-2.5"
style={{
borderColor: 'color-mix(in srgb, var(--m-st-info) 26%, transparent)',
background: 'linear-gradient(180deg, color-mix(in srgb, var(--m-st-info) 11%, var(--m-card)) 0%, var(--m-card) 100%)',
}}
>
<div className="flex items-center gap-[6px]">
<Moon size={13} strokeWidth={2} style={{ color: 'var(--m-st-info)' }} aria-hidden="true" />
<span className="font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-muted">
{t('roadtrip.spill.title', { number: fromDayNumber })}
</span>
{departs && (
<span className="ms-auto text-[0.71875rem] font-semibold tabular-nums text-m-ink">
{t('roadtrip.spill.departs', { time: departs })}
</span>
)}
</div>
{children && <div className="mt-1 px-[7px]">{children}</div>}
</div>
)
}
@@ -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<HTMLDivElement>(null)
const cardRef = useRef<HTMLDivElement>(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 && (
<div className="pointer-events-auto absolute left-4 right-4 top-[calc(var(--m-safe-top,12px)+96px)] z-[26]">
<MRtCorridorBar planner={planner} corridor={corridor} onOpen={() => shell.openSheet('rtsearch')} />
</div>
)
// ── 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 (
<div className="pointer-events-none absolute inset-0 z-20">
{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. */}
<div className="pointer-events-auto absolute left-4 right-4 bottom-[calc(var(--bottom-nav-h,84px)+4px)]">
{/* `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. */}
<StageBar planner={planner} shell={shell} rt={rt} />
</div>
</div>
)
}
// ── List half ────────────────────────────────────────────────────────────────
return (
<div ref={panelRef} className="absolute inset-0" {...daySwipe.handlers}>
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">{daySwipe.announcement}</span>
{searchBar}
<div
ref={cardRef}
className={`absolute inset-0 overflow-y-auto overscroll-contain px-4 pb-[calc(var(--bottom-nav-h,84px)+22px)] ${
searchBar ? 'pt-[calc(var(--m-safe-top,12px)+150px)]' : 'pt-[calc(var(--m-safe-top,12px)+102px)]'
}`}
>
{rt.empty || !stage ? (
<EmptyStage planner={planner} loading={rt.loading} />
) : (
<>
{/* 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. */}
<section className="rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-4 py-3.5">
<div className="flex items-center justify-between">
<span className="font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-muted">
{stageDateLabel(planner, stage.dayId) ?? t('roadtrip.day', { number: stage.dayNumber })}
</span>
{tint && (
// theme-lint-disable: the day colours are map paint, see dayColors.ts.
<span className="h-2 w-2 rounded-full" style={{ background: tint.line, boxShadow: `0 0 0 2px ${tint.casing}` }} aria-hidden="true" />
)}
</div>
<div className="mt-2 flex items-end justify-between gap-3">
<span className="min-w-0">
<span className="block font-geist text-[0.5625rem] font-bold uppercase tracking-[.08em] text-m-faint">
{t('roadtrip.stay.leave')}
</span>
<span className="block text-[1.875rem] font-extrabold leading-none tabular-nums text-m-ink">
{firstDeparture(rt.rows) ?? '-'}
</span>
</span>
<span className="min-w-0 text-right">
<span className="block font-geist text-[0.5625rem] font-bold uppercase tracking-[.08em] text-m-faint">
{t('roadtrip.stay.arrive')}
</span>
<span className="block text-[1.875rem] font-extrabold leading-none tabular-nums text-m-ink">
{lastArrival(rt.rows) ?? '-'}
</span>
</span>
</div>
<div className="mt-2.5 flex items-center gap-2 border-t border-[color:var(--m-rowbr)] pt-2.5">
<span className="font-geist text-[0.71875rem] font-semibold tabular-nums text-m-muted">
{stage.distance > 0
? t('roadtrip.leg.driveText', {
distance: formatDistance(stage.distance / 1000, unit),
time: formatDurationShort(stage.duration),
})
: t('roadtrip.summary.partial')}
</span>
<span className="ms-auto whitespace-nowrap rounded-full bg-[color:var(--m-ic)] px-2 py-[2px] font-geist text-[0.59375rem] font-bold text-m-muted">
{t('roadtrip.day.stopCount', { count: rt.stops })}
</span>
</div>
{stage.dayWarning && (
<div
className="mt-2 rounded-[13px] px-[11px] py-[7px]"
style={{ background: 'color-mix(in srgb, var(--m-st-pending) 12%, transparent)', color: 'var(--m-st-pending)' }}
>
<span className="flex items-center gap-[6px] text-[0.71875rem] font-semibold">
<AlertTriangle size={13} strokeWidth={2} aria-hidden="true" />
{t('roadtrip.limit.dayOver', {
time: formatDurationShort((stage.dayWarning.minutes - stage.dayWarning.limitMinutes) * 60),
})}
</span>
{/* The sentence the desktop hides in a title attribute. */}
<span className="mt-[3px] block font-geist text-[0.65625rem] text-m-muted">
{t('roadtrip.limit.hint')}
</span>
</div>
)}
</section>
{rt.upNext && <UpNext planner={planner} shell={shell} rt={rt} onOpen={openStop} />}
<section className="mt-2.5 overflow-hidden rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-3.5 pb-3 pt-1">
{rt.rows.map((row, i) => {
if (row.kind === 'stop') return <RtStopRow key={`s${i}`} row={row} chrome={chrome} onOpen={() => openStop(row)} />
if (row.kind === 'leg') return <RtLegRow key={`l${i}`} seg={row.seg} mode={row.mode} chrome={chrome} />
if (row.kind === 'auto') return <RtAutoRow key={`a${i}`} phase={row.phase} time={row.time} chrome={chrome} />
if (row.kind === 'spill') {
return <RtSpillRow key={`p${i}`} fromDayNumber={row.fromDayNumber} departs={row.departs} chrome={chrome} />
}
return (
<RtDryRow
key={`d${i}`}
intoLegKm={row.intoLegKm}
chrome={chrome}
electric={rt.electric}
offline={rt.offline}
refuel={planner.refuel}
dayId={stage.dayId}
legIndex={row.legIndex}
onSearch={planner.can('day_edit', planner.trip)
? () => 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}
/>
)
})}
</section>
</>
)}
</div>
</div>
)
}
/**
* 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<typeof useMRoadtrip>
}) {
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 (
<div className="flex items-center gap-2.5 rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-[14px] py-[11px] shadow-[0_16px_44px_-14px_rgba(0,0,0,.35)] backdrop-blur-[24px] backdrop-saturate-[1.6]">
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold text-m-ink">{t('roadtrip.title')}</span>
<span className="whitespace-nowrap font-geist text-[0.6875rem] font-semibold tabular-nums text-m-muted">
{formatDistance(planner.roadtripRoutes.totalDistance / 1000, unit)}
</span>
</div>
)
}
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 (
<button
type="button"
onClick={shell.toggleRtView}
className="flex w-full items-center gap-2.5 rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-[14px] py-[11px] text-left shadow-[0_16px_44px_-14px_rgba(0,0,0,.35)] backdrop-blur-[24px] backdrop-saturate-[1.6]"
>
{tint && (
// theme-lint-disable: map paint, see dayColors.ts.
<span className="h-[9px] w-[9px] flex-none rounded-full" style={{ background: tint.line, boxShadow: `0 0 0 2px ${tint.casing}` }} aria-hidden="true" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-[0.8125rem] font-semibold text-m-ink">
{last?.name ?? t('roadtrip.stop.none')}
</span>
<span className="mt-px block font-geist text-[0.6875rem] tabular-nums text-m-muted">
{[t('roadtrip.day.stopCount', { count: rt.stops }), lastArrivalOf(stage)].filter(Boolean).join(' · ')}
</span>
</span>
<span className="whitespace-nowrap font-geist text-[0.6875rem] font-semibold tabular-nums text-m-muted">
{stage.distance > 0 ? formatDistance(stage.distance / 1000, unit) : t('roadtrip.leg.pending')}
</span>
</button>
)
}
/**
* 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<typeof useMRoadtrip>
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 (
<section className="mt-2.5 rounded-[22px] border border-[color:var(--m-inbr)] bg-[color:var(--m-inner)] px-4 py-3.5 shadow-[0_18px_44px_-18px_rgba(0,0,0,.3)]">
<button type="button" onClick={() => onOpen(next.row)} className="w-full text-left">
<span className="flex items-center justify-between gap-2">
<span className="font-geist text-[0.65625rem] font-bold uppercase tracking-[.08em] text-m-muted">
{t('mobileTrip.upNext')}
</span>
{/* 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. */}
<span
className="whitespace-nowrap rounded-full px-2 py-[2px] text-[0.6875rem] font-semibold"
style={late
? { background: 'color-mix(in srgb, var(--m-st-pending) 16%, transparent)', color: 'var(--m-st-pending)' }
: { background: 'var(--m-ic)', color: 'var(--m-ink)' }}
>
{late
? t('mobileTrip.rtBehind', { time: formatDurationShort(-next.minutesUntil * 60) })
: t('mobileTrip.inCountdown', { time: formatDurationShort(next.minutesUntil * 60) })}
</span>
</span>
<span className="mt-1.5 block truncate text-[1.125rem] font-bold text-m-ink">{next.row.stop.name}</span>
{next.row.time && (
<span className="mt-[2px] block font-geist text-[0.75rem] tabular-nums text-m-muted">{next.row.time}</span>
)}
</button>
<div className="mt-3 flex gap-2">
<a
href={targets[0]?.url ?? '#'}
target="_blank"
rel="noopener noreferrer"
aria-disabled={!targets.length}
className={`flex h-11 flex-1 items-center justify-center gap-[7px] rounded-full bg-m-act text-[0.8125rem] font-semibold text-m-actfg shadow-[0_10px_24px_-10px_rgba(0,0,0,.45)] ${targets.length ? '' : 'pointer-events-none opacity-40'}`}
>
<Navigation size={15} strokeWidth={2.2} aria-hidden="true" />
{t('places.navigate')}
</a>
<button
type="button"
onClick={() => {
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]"
>
<MapPin size={17} strokeWidth={2} aria-hidden="true" />
</button>
</div>
</section>
)
}
/**
* 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 (
<div className="flex flex-col items-center justify-center px-5 py-16 text-center">
<MDancingTrek scene="guide" className="mb-2" />
<p className="text-[0.9375rem] font-semibold text-m-ink">
{loading ? t('roadtrip.summary.partial') : t('roadtrip.empty.title')}
</p>
{!loading && (
<p className="mt-1.5 max-w-[27ch] font-geist text-[0.78125rem] leading-[1.5] text-m-muted">
{t('mobileTrip.rtPlanOnDesktop')}
</p>
)}
</div>
)
}
/* ── 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<typeof useMRoadtrip>['rows']): string | null {
for (const row of rows) {
if (row.kind !== 'stop') continue
return row.departure ?? row.time
}
return null
}
function lastArrival(rows: ReturnType<typeof useMRoadtrip>['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!
}
@@ -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 = <Search size={16} strokeWidth={2.1} aria-hidden="true" />
let title = t('roadtrip.poi.title')
let meta: string | null = reachLabel
if (corridor.offline) {
icon = <WifiOff size={16} strokeWidth={2.1} aria-hidden="true" />
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 = <AlertTriangle size={16} strokeWidth={2.1} aria-hidden="true" />
title = t('roadtrip.poi.failed')
meta = null
} else if (corridor.loading) {
icon = <Loader2 size={16} strokeWidth={2.1} className="animate-spin" aria-hidden="true" />
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 (
<div className="flex items-center gap-2">
<button
type="button"
onClick={onOpen}
className="flex h-11 min-w-0 flex-1 items-center gap-2.5 rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] px-3.5 text-left shadow-[0_16px_44px_-16px_rgba(0,0,0,.35)] backdrop-blur-[24px] backdrop-saturate-[1.7]"
>
<span className="flex-none text-m-ink">{icon}</span>
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-semibold text-m-ink">{title}</span>
{meta && (
<span className="flex-none whitespace-nowrap font-geist text-[0.6875rem] font-semibold tabular-nums text-m-muted">
{meta}
</span>
)}
</button>
{trailing && (
<MIconBtn
size={44}
onClick={corridor.clear}
ariaLabel={t(corridor.loading ? 'common.cancel' : 'roadtrip.poi.clearResults')}
>
<X size={16} strokeWidth={2.2} />
</MIconBtn>
)}
</div>
)
}
@@ -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 (
<MSheet
open={open}
onClose={shell.closeSheet}
variant="bottom"
material="opaque"
ariaLabel={t('roadtrip.poi.title')}
>
<div className="flex-none px-[18px] pt-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)]">
<Search size={17} strokeWidth={2} className="text-m-ink" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[1.0625rem] font-bold leading-tight text-m-ink">{t('roadtrip.poi.title')}</div>
<div className="font-geist text-[0.71875rem] leading-snug text-m-muted">
{corridor.reach === 'stage'
? t('roadtrip.poi.wholeDay')
: corridor.anchored
? t('mobileTrip.rtFromNext')
: t('mobileTrip.rtFromStart')}
</div>
</div>
<MIconBtn variant="neutral" size={34} onClick={shell.closeSheet} ariaLabel={t('common.close')}>
<X size={15} strokeWidth={2.2} />
</MIconBtn>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-2 pt-1">
<Eyebrow className="mb-[7px] mt-3">{t('roadtrip.poi.looking')}</Eyebrow>
<div className="flex flex-wrap gap-[6px]">
{CORRIDOR_CATEGORIES.map(({ key, labelKey, Icon }) => (
<MChip
key={key}
size="tap"
active={corridor.categories.includes(key)}
onClick={() => corridor.toggleCategory(key)}
>
<Icon size={14} strokeWidth={2} />
{t(labelKey)}
</MChip>
))}
</div>
<Eyebrow className="mb-[7px] mt-3.5">{t('mobileTrip.rtReach')}</Eyebrow>
<div className="flex flex-wrap gap-[6px]">
<MChip size="tap" active={corridor.reach === 'ahead'} onClick={() => corridor.setReach('ahead')}>
{reachLabel}
</MChip>
<MChip size="tap" active={corridor.reach === 'stage'} onClick={() => corridor.setReach('stage')}>
{t('roadtrip.poi.wholeDay')}
</MChip>
</div>
<StatusBand corridor={corridor} t={t} />
{corridor.hits.length > 0 && (
<>
<ul className="mt-3 overflow-hidden rounded-[18px] border border-[color:var(--m-inbr)] bg-[color:var(--m-inner)]">
{corridor.hits.slice(0, MAX_ROWS).map((poi, i) => (
<HitRow
key={poi.osm_id}
poi={poi}
unit={unit}
t={t}
first={i === 0}
onShow={() => show(poi)}
onAdd={canAdd ? () => add(poi) : undefined}
/>
))}
</ul>
{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.
<p className="mt-2 text-center font-geist text-[0.6875rem] tabular-nums text-m-faint">
{t('roadtrip.poi.foundFiltered', { count: MAX_ROWS, total: corridor.hits.length })}
</p>
)}
</>
)}
</div>
<div className="flex-none border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
<button
type="button"
onClick={corridor.run}
disabled={!corridor.canSearch || corridor.loading}
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-m-act text-[0.875rem] font-semibold text-m-actfg disabled:opacity-40"
>
{corridor.loading
? <Loader2 size={16} strokeWidth={2.2} className="animate-spin" aria-hidden="true" />
: <Search size={16} strokeWidth={2.2} aria-hidden="true" />}
{corridor.loading
? t('roadtrip.poi.searching', { done: corridor.progress.done, total: corridor.progress.total })
: t('roadtrip.poi.search')}
</button>
</div>
</MSheet>
)
}
/**
* 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 <Note tone="warn" icon={<WifiOff size={13} strokeWidth={2} />}>{t('mobileTrip.rtSearchOffline')}</Note>
if (corridor.error) return <Note tone="warn" icon={<AlertTriangle size={13} strokeWidth={2} />}>{t('roadtrip.poi.failed')}</Note>
if (corridor.categories.length === 0) return <Note tone="calm">{t('roadtrip.poi.empty')}</Note>
const empty = corridor.answered && corridor.hits.length === 0
return (
<>
{empty && (
<Note tone="calm">
{corridor.reach === 'ahead' ? t('mobileTrip.rtNoneAhead') : t('mobileTrip.rtNoneOnStage')}
</Note>
)}
{notes.map(note => (
<Note key={note} tone="warn" icon={<AlertTriangle size={13} strokeWidth={2} />}>{note}</Note>
))}
</>
)
}
function Note({ tone, icon, children }: { tone: 'warn' | 'calm'; icon?: ReactNode; children: ReactNode }) {
return (
<div
className="mt-3 flex items-start gap-[7px] rounded-[15px] px-3 py-[9px] font-geist text-[0.71875rem] leading-[1.45]"
style={tone === 'warn'
? { background: 'color-mix(in srgb, var(--m-st-pending) 12%, transparent)', color: 'var(--m-st-pending)' }
: { background: 'var(--m-inner)', color: 'var(--m-muted)' }}
>
{icon && <span className="mt-px flex-none">{icon}</span>}
<span>{children}</span>
</div>
)
}
/**
* 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 (
<li className={`flex items-center gap-2.5 px-3 py-2 ${first ? '' : 'border-t border-[color:var(--m-rowbr)]'}`}>
<button type="button" onClick={onShow} className="flex min-w-0 flex-1 items-center gap-2.5 py-1 text-left">
<span
className="grid h-[30px] w-[30px] flex-none place-items-center rounded-[10px]"
// theme-lint-disable: the road-signage palette from `roadtripModel`, the same
// colour this kind of stop carries on the map and in the chain.
style={{ background: `${color}1f`, color }}
>
<Icon size={14} strokeWidth={1.9} aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[0.8125rem] font-semibold text-m-ink">{poi.name}</span>
<span className="mt-px block truncate font-geist text-[0.6875rem] tabular-nums text-m-muted">
{corridorHitLabel(poi, unit, t)}
</span>
</span>
</button>
{onAdd && (
<MIconBtn variant="neutral" size={34} onClick={onAdd} ariaLabel={t('roadtrip.poi.add')}>
<Plus size={15} strokeWidth={2.2} />
</MIconBtn>
)}
</li>
)
}
@@ -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<RoadtripStopType | null>(null)
const [dwell, setDwell] = useState<number>(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<RoadtripStopDraft | null>(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<void> => {
if (saving) return
setSaving(true)
try {
await saveStopDraft({ stopType, dwellMinutes: dwell })
} finally {
setSaving(false)
}
}
return (
<MSheet open={stopDraft != null} onClose={discard} variant="bottom" material="opaque" ariaLabel={draft.poi.name}>
<FormSheetHeader
icon={kind?.Icon ?? ParkingSquare}
title={draft.poi.name}
subtitle={draft.poi.address}
onClose={discard}
closeLabel={t('common.close')}
/>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-2">
<div className="font-geist text-[0.6875rem] tabular-nums text-m-muted">
{t('roadtrip.stop.landsOn', { day: draft.dayNumber, position: draft.position + 1 })}
</div>
{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.
<div className="mt-[6px] flex items-start gap-[6px] font-geist text-[0.6875rem] text-[color:var(--m-st-pending)]">
<AlertTriangle size={12} strokeWidth={2} className="mt-px flex-none" />
<span>{t('roadtrip.stop.duplicate', { name: stopDraftDuplicate })}</span>
</div>
)}
{overnightOnly ? (
<div className="mt-3 flex items-start gap-[9px] rounded-[16px] border border-[color:var(--m-inbr)] bg-[color:var(--m-inner)] px-3 py-[11px]">
<BedDouble size={15} strokeWidth={1.8} className="mt-px flex-none text-m-muted" />
<p className="font-geist text-[0.71875rem] leading-[1.5] text-m-muted">
{t('mobileTrip.rtNightDesktopOnly')}
</p>
</div>
) : (
<>
<Eyebrow className="mb-[6px] mt-3">{t('roadtrip.stop.kind')}</Eyebrow>
<div className="flex flex-wrap gap-[6px]">
{STOP_KINDS.map(({ key, labelKey, Icon }) => (
<MChip key={key} active={stopType === key} onClick={() => pickKind(key)}>
<Icon size={13} strokeWidth={2} />
{t(labelKey)}
</MChip>
))}
</div>
<Eyebrow className="mb-[6px] mt-3">{t('roadtrip.stop.stay')}</Eyebrow>
<div className="flex flex-wrap gap-[6px]">
{DWELL_CHOICES.map(minutes => (
<MChip
key={minutes}
active={dwell === minutes}
onClick={() => setDwell(minutes)}
className="tabular-nums"
>
<Hourglass size={12} strokeWidth={2} />
{formatDurationShort(minutes * 60)}
</MChip>
))}
</div>
</>
)}
</div>
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
<button
type="button"
onClick={discard}
className="flex h-11 items-center rounded-full px-3 text-[0.8125rem] font-semibold text-m-muted"
>
{t('common.discard')}
</button>
{!overnightOnly && (
<button
type="button"
onClick={() => { 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')}
</button>
)}
</div>
</MSheet>
)
}
@@ -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 (
<div className={`flex items-center justify-between gap-3 py-2 ${first ? '' : 'border-t border-[color:var(--m-rowbr)]'}`}>
<span className="min-w-0 flex-1 text-[0.8125rem] text-m-muted">{label}</span>
<span
className={`flex-none text-[0.8125rem] font-semibold tabular-nums ${value ? 'text-m-ink' : 'text-m-faint'}`}
>
{value ?? fallback}
</span>
</div>
)
}
/** One card of the sheet: eyebrow plus whatever the section is made of. */
function InfoCard({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="rounded-[16px] bg-[color:var(--m-ic)] px-3.5 py-3">
<Eyebrow>{label}</Eyebrow>
{children}
</div>
)
}
/**
* 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 (
<MSheet open={open} onClose={shell.closeSheet} variant="bottom" material="opaque" ariaLabel={t('mobileTrip.rtInfoTitle')}>
<div className="flex-none px-[18px] pt-4">
<TileHeader
icon={<VehicleIcon size={19} strokeWidth={1.8} />}
title={<span className="truncate">{t('mobileTrip.rtInfoTitle')}</span>}
onClose={shell.closeSheet}
closeLabel={t('common.close')}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px]">
{/* ── Vehicle: how far it goes, drawn as the tank it leaves the factory with ── */}
<div className="mt-[14px]">
<InfoCard label={t('roadtrip.limit.sectionVehicle')}>
<div className="mt-1.5 flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
<span className="flex items-baseline gap-1">
<span className="text-[1.375rem] font-extrabold leading-none tabular-nums text-m-ink">
{shown ?? '-'}
</span>
{shown !== null && (
<span className="text-[0.875rem] font-semibold leading-none text-m-muted">{unitLabel}</span>
)}
</span>
<span className="font-geist text-[0.65625rem] text-m-faint">
{shown === null ? t('roadtrip.limit.rangeEmpty') : perLabel}
</span>
</div>
<div
className="mt-2.5 h-2.5 w-full overflow-hidden rounded-full bg-[color:var(--m-trackoff)]"
role="img"
aria-label={barLabel}
>
{/* Cut as a mask rather than painted over, so the gaps are real holes and
the glass behind the sheet shows through them. */}
<div
className="flex h-full w-full"
style={mask ? { maskImage: mask, WebkitMaskImage: mask } : undefined}
aria-hidden="true"
>
<div className="h-full bg-m-act" style={{ width: `${shown === null ? 0 : fill}%` }} />
</div>
</div>
{notes.length > 0 && (
<div className="mt-2 font-geist text-[0.65625rem] leading-snug text-m-faint">
{notes.join(' · ')}
</div>
)}
</InfoCard>
</div>
{/* ── Limits: what every warning on the stage is measured against ── */}
<div className="mt-2.5">
<InfoCard label={t('roadtrip.limit.title')}>
<div className="mt-1">
{lines.map((line, i) => (
<LimitLine
key={line.key}
label={line.label}
value={line.value}
fallback={line.fallback}
first={i === 0}
/>
))}
</div>
</InfoCard>
</div>
<p className="mt-3 font-geist text-[0.6875rem] leading-snug text-m-faint">
{t('mobileTrip.rtDesktopNote')}
</p>
</div>
</MSheet>
)
}
@@ -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<RtStaySheetPayload | null>(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 (
<MSheet
open={open && !!stop}
onClose={shell.closeSheet}
variant="bottom"
material="opaque"
ariaLabel={t('roadtrip.stay.add')}
>
{stop && (
<>
<FormSheetHeader
title={t('roadtrip.stay.add')}
onClose={shell.closeSheet}
closeLabel={t('common.close')}
/>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-1">
{/* 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 && (
<div className="truncate text-[0.875rem] font-semibold text-m-ink">{stop.name}</div>
)}
<div className="mt-[3px] font-geist text-[0.65625rem] leading-snug text-m-faint">
{t('mobileTrip.rtStayScope')}
</div>
{/* 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. */}
<div className="mt-[18px] flex items-center justify-center gap-5">
<button
type="button"
onClick={() => nudge(-STEP)}
disabled={!canEdit || saving || minutes <= 0}
aria-label={t('mobileTrip.rtStayLess', { count: STEP })}
className={stepCls}
>
<Minus size={17} strokeWidth={2.2} aria-hidden="true" />
</button>
<div className="flex min-w-[6rem] flex-col items-center">
<span
aria-live="polite"
className="text-[2.5rem] font-extrabold leading-none tabular-nums text-m-ink"
>
{minutes}
</span>
<span className="mt-[7px] font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint">
{t('roadtrip.stay.custom')}
</span>
</div>
<button
type="button"
onClick={() => nudge(STEP)}
disabled={!canEdit || saving || minutes >= MAX}
aria-label={t('mobileTrip.rtStayMore', { count: STEP })}
className={stepCls}
>
<Plus size={17} strokeWidth={2.2} aria-hidden="true" />
</button>
</div>
{/* 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 && (
<div className="mt-[18px] grid grid-cols-4 gap-[7px]">
{PRESETS.map(value => (
<MChip
key={value}
active={minutes === value}
onClick={() => setMinutes(value)}
className="h-11 w-full justify-center tabular-nums"
>
{formatDurationShort(value * 60)}
</MChip>
))}
</div>
)}
{/* Arrival, arrow, new departure. Only this stop's own two ends. */}
{preview && (
<div className={`mt-[18px] flex items-center justify-center gap-4 rounded-[16px] px-3 py-[11px] ${INNER_CLS}`}>
<span className="flex flex-col items-center gap-[2px]">
<span className={captionCls}>{t('roadtrip.stay.arrive')}</span>
<span dir="ltr" className="text-[0.875rem] font-bold tabular-nums text-m-muted">
{preview.arrive}
</span>
</span>
<ArrowRight size={14} strokeWidth={2} className="mt-[11px] flex-none text-m-faint" aria-hidden="true" />
<span className="flex flex-col items-center gap-[2px]">
<span className={captionCls}>{t('roadtrip.stay.leave')}</span>
<span dir="ltr" className="text-[0.875rem] font-bold tabular-nums text-m-ink">
{preview.leave}
{preview.carry > 0 && (
<span className="ms-[3px]">
{`+${preview.carry}`}
<span className="sr-only">{` ${t('roadtrip.warn.overnight')}`}</span>
</span>
)}
</span>
</span>
</div>
)}
</div>
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
{/* 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. */}
<button
type="button"
onClick={() => 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')}
</button>
<button
type="button"
onClick={() => { 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')}
</button>
</div>
</>
)}
</MSheet>
)
}
@@ -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 (
<div
className="flex min-h-[44px] items-center gap-2.5 rounded-[13px] bg-[color:var(--m-ic)] px-3 py-2.5"
style={warn ? { color: 'var(--m-st-pending)' } : undefined}
>
<span className={`flex-none ${warn ? '' : 'text-m-muted'}`} aria-hidden="true">{icon}</span>
<span className={`min-w-0 flex-1 text-[0.75rem] font-medium leading-snug ${warn ? '' : 'text-m-muted'}`}>
{label}
</span>
{value && (
<span className={`flex-none text-[0.8125rem] font-semibold tabular-nums ${warn ? '' : 'text-m-ink'}`}>
{value}
</span>
)}
</div>
)
}
/** 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<HTMLButtonElement>
}) {
const inner = (
<>
<span className="flex-none text-m-muted" aria-hidden="true">{icon}</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[0.8125rem] font-semibold text-m-ink">{label}</span>
{value && (
<span className="mt-px block truncate font-geist text-[0.65625rem] tabular-nums text-m-faint">{value}</span>
)}
</span>
</>
)
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 <div className={box}>{inner}</div>
return <button ref={tileRef} type="button" onClick={onClick} className={`${box} border-0`}>{inner}</button>
}
/**
* 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<Located | null>(null)
if (live) heldRef.current = live
const located = live ?? heldRef.current
const [navOpen, setNavOpen] = useState(false)
const navBtnRef = useRef<HTMLButtonElement>(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 <MSheet open={false} onClose={shell.closeSheet} variant="bottom" />
}
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 <AlertTriangle size={15} strokeWidth={2} />
if (w.code === 'range') return <Fuel size={15} strokeWidth={2} />
return <Hourglass size={15} strokeWidth={2} />
}
// 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 (
<MSheet open={open && !!live} onClose={shell.closeSheet} variant="bottom" material="opaque" ariaLabel={stop.name}>
{/* ── Header: the disc the chain draws, the name, the way out ── */}
<div className="flex-none px-[18px] pt-4">
<div className="flex items-start gap-3">
{row.service ? (
kind && KindIcon ? (
<span
className="grid h-[34px] w-[34px] flex-none place-items-center rounded-full text-white"
// theme-lint-disable: the stop kinds share their colour with the map
// markers, which sit on tiles rather than on one of the app's surfaces.
style={{ background: kind.color }}
>
<KindIcon size={16} strokeWidth={2.1} aria-hidden="true" />
</span>
) : (
<span className="grid h-[34px] w-[34px] flex-none place-items-center rounded-full bg-[color:var(--m-ic)] text-m-muted">
<MapPin size={16} strokeWidth={2} aria-hidden="true" />
</span>
)
) : (
// 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.
<span className="grid h-[34px] w-[34px] flex-none place-items-center rounded-full bg-[color:var(--m-ic)] text-[0.8125rem] font-bold tabular-nums text-m-ink">
{row.number}
</span>
)}
<h2 className="min-w-0 flex-1 line-clamp-2 text-[1.0625rem] font-bold leading-tight text-m-ink">
{stop.name}
</h2>
<MIconBtn variant="neutral" size={34} onClick={shell.closeSheet} ariaLabel={t('common.close')}>
<X size={15} strokeWidth={2.2} />
</MIconBtn>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px]">
{/* ── The two clock readings, and where they come from ── */}
{(arrive || leave) && (
<div className="mt-[14px] rounded-[16px] bg-[color:var(--m-inner)] px-3.5 py-3">
<div className="flex items-start justify-between gap-3">
{arrive && (
<div className="min-w-0">
<Eyebrow>{t('roadtrip.stay.arrive')}</Eyebrow>
{/* dir=ltr so a clock reads the same way round in an RTL locale. */}
<div dir="ltr" className="mt-1 text-[1.375rem] font-extrabold leading-none tabular-nums text-m-ink">
{arrive}
</div>
</div>
)}
{leave && (
<div className="min-w-0 ms-auto text-end">
<Eyebrow>{t('roadtrip.stay.leave')}</Eyebrow>
<div dir="ltr" className="mt-1 text-[1.375rem] font-extrabold leading-none tabular-nums text-m-ink">
{leave}
</div>
</div>
)}
</div>
{/* The desktop says this in a tooltip, which a phone has nowhere to put. */}
<div className="mt-2 font-geist text-[0.65625rem] leading-snug text-m-faint">
{entry?.anchored ? t('roadtrip.stop.pinned') : t('roadtrip.stop.computed')}
</div>
</div>
)}
{/* ── What this stop costs, and what the drive to it went past ── */}
{(!!stayMinutes || !!row.offRoadMeters || warnings.length > 0) && (
<div className="mt-2.5 flex flex-col gap-2">
{!!stayMinutes && (
<Finding
icon={<Hourglass size={15} strokeWidth={2} />}
label={t('roadtrip.stop.stayShort')}
value={formatDurationShort(stayMinutes * 60)}
/>
)}
{!!row.offRoadMeters && (
<Finding
icon={<Footprints size={15} strokeWidth={2} />}
label={t('roadtrip.stop.offRoad', { distance: formatDistance(row.offRoadMeters / 1000, unit) })}
/>
)}
{warnings.map(w => (
<Finding key={`${w.code}-${w.index}`} icon={warningIcon(w)} label={warningText(w)} warn />
))}
</div>
)}
{/* ── Charging: availability and price, polled only while the sheet is up ── */}
{open && stop.stopType === 'charging' && (
<div className="mt-2.5">
<ChargingInfo placeId={stop.placeId} />
</div>
)}
{/* ── The two decisions a passenger makes on the road ── */}
<div className={`mt-2.5 grid gap-2 ${tiles > 1 ? 'grid-cols-2' : 'grid-cols-1'}`}>
{navTargets.length > 0 && (
<ActionTile
tileRef={navBtnRef}
icon={<Navigation size={16} strokeWidth={2} />}
label={t('inspector.navigation')}
value={navTargets.length === 1 ? navTargets[0].label : undefined}
onClick={openDirections}
/>
)}
<ActionTile
icon={<Clock size={16} strokeWidth={2} />}
label={t('roadtrip.stop.stayShort')}
value={stayMinutes ? formatDurationShort(stayMinutes * 60) : t('roadtrip.stay.none')}
onClick={canEditStay ? editStay : undefined}
/>
</div>
{navOpen && (
<NavigationMenu
targets={navTargets}
anchor={navBtnRef.current}
onClose={() => setNavOpen(false)}
title={t('inspector.openWith')}
/>
)}
{/* ── Stopping here for the night. Offline too: it is a plan, not a lookup. ── */}
{canEndDay && (
<div className="mt-2.5 flex min-h-[44px] items-center gap-2.5 rounded-[13px] bg-[color:var(--m-ic)] px-3 py-2">
<Moon
size={16}
strokeWidth={2}
className={`flex-none ${endDayActive ? 'text-m-ink' : 'text-m-muted'}`}
aria-hidden="true"
/>
<span className="min-w-0 flex-1 text-[0.8125rem] font-semibold text-m-ink">
{t('roadtrip.window.endHere')}
</span>
<MToggle
checked={endDayActive}
onChange={next => { void toggleEndDay(next) }}
ariaLabel={t('roadtrip.window.endHere')}
disabled={saving}
/>
</div>
)}
{/* ── Out to the map, with the stop marked on it ── */}
<div className="mt-2 border-t border-[color:var(--m-rowbr)] pt-2">
<MListRow
icon={MapPin}
label={t('mobileTrip.showOnMap')}
onClick={showOnMap}
className="min-h-[44px]"
/>
</div>
</div>
</MSheet>
)
}
@@ -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<typeof upNextStop>
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
@@ -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,
}
}
@@ -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<string, string> = {
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') {
@@ -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) {
<MTransportSheet planner={planner} shell={shell} />
<MBrowseActionsSheet planner={planner} shell={shell} />
<MMehrSheet planner={planner} shell={shell} />
{/* 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. */}
<MRtStopSheet planner={planner} shell={shell} />
<MRtStaySheet planner={planner} shell={shell} />
<MRtInfoSheet planner={planner} shell={shell} />
{/* 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. */}
<MRtCorridorSheet planner={planner} shell={shell} />
<MRtDraftSheet planner={planner} />
<MExportSheet planner={planner} shell={shell} />
<MNoteSheet
planner={planner}
@@ -6,6 +6,7 @@ import MCostsTab from './MCostsTab'
import MFilesTab from './MFilesTab'
import MCollabTab from './MCollabTab'
import MListsTab from './MListsTab'
import MRoadtripTab from '../roadtrip/MRoadtripTab'
import PluginFrame from '../../../../components/Plugins/PluginFrame'
/**
@@ -55,6 +56,11 @@ export default function MTripTabPanel({ planner, shell, tab }: MTripTabPanelProp
return <MCollabTab planner={planner} shell={shell} />
case 'listen':
return <MListsTab planner={planner} shell={shell} />
// 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 <MRoadtripTab planner={planner} shell={shell} tab={tab} />
default:
return <TabScroller>{null}</TabScroller>
}
@@ -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<Record<string, unknown>>,
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<string, Array<{ id: number }>>
/** 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)
})
})
+46 -9
View File
@@ -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,
+41
View File
@@ -201,6 +201,42 @@ export function buildPlanner(overrides: Partial<TripPlanner> = {}): 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> = {}): TripPlanner
export function buildShell(overrides: Partial<MTripShellApi> = {}): 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(),
@@ -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<string, unknown>,
@@ -39,7 +39,10 @@ const COMPASS: CompassMap = {
function renderArea(shellOver: Partial<MTripShellApi> = {}, plannerOver: Partial<TripPlanner> = {}) {
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(<MMapArea planner={planner} shell={shell} />) }
}
@@ -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<TripPlanner>)
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<TripPlanner>)
// 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)
})
})
@@ -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')
})
})
})
@@ -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> = {}): 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(<RtStopRow row={stopRow()} chrome={chrome} onOpen={vi.fn()} />)
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(
<RtStopRow
row={stopRow({
number: null,
service: true,
stop: { ...stopRow().stop, name: 'Shell Ebina', stopType: 'fuel' },
})}
chrome={chrome}
onOpen={vi.fn()}
/>,
)
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(
<RtStopRow
row={stopRow({
dwellMinutes: 45,
warning: { index: 1, code: 'late', minutes: 25 },
offRoadMeters: 120,
})}
chrome={chrome}
onOpen={vi.fn()}
/>,
)
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(<RtStopRow row={stopRow({ offRoadMeters: 120 })} chrome={chrome} onOpen={vi.fn()} />)
expect(screen.getByText('120 m')).toBeInTheDocument()
})
it('FE-MOB-RTROW-005: gives a pinned time the pin and the full colour', () => {
render(<RtStopRow row={stopRow({ pinned: true })} chrome={chrome} onOpen={vi.fn()} />)
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(<RtStopRow row={stopRow()} chrome={chrome} onOpen={vi.fn()} />)
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(<RtStopRow row={stopRow({ time: null })} chrome={chrome} onOpen={vi.fn()} />)
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(<RtStopRow row={stopRow()} chrome={chrome} onOpen={onOpen} />)
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(<RtStopRow row={stopRow()} chrome={chrome} onOpen={onOpen} />)
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(<RtStopRow row={stopRow({ warning })} chrome={chrome} onOpen={vi.fn()} />)
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(<RtStopRow row={stopRow({ warning })} chrome={chrome} onOpen={vi.fn()} />)
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(<RtStopRow row={stopRow({ warning })} chrome={chrome} onOpen={vi.fn()} />)
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(<RtStopRow row={stopRow({ warning })} chrome={chrome} onOpen={vi.fn()} />)
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(<RtLegRow seg={SEG} mode="driving" chrome={chrome} />)
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(<RtLegRow seg={{ ...SEG, durationText: undefined }} mode="driving" chrome={chrome} />)
expect(screen.getByText('roadtrip.leg.driveText:210 km,2 h 55 min')).toBeInTheDocument()
bare.unmount()
const { container } = render(<RtLegRow seg={SEG} mode="driving" chrome={chrome} />)
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(<RtLegRow seg={undefined} mode="driving" chrome={chrome} />)
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(<RtLegRow seg={SEG} mode="walking" chrome={chrome} />)
expect(walk.container.querySelector('.lucide-footprints')).not.toBeNull()
walk.unmount()
const plugin = render(<RtLegRow seg={SEG} mode="plugin:rail" chrome={chrome} />)
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(<RtDryRow {...props} onSearch={vi.fn()} />)
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(<RtDryRow {...props} electric onSearch={vi.fn()} />)
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(<RtDryRow {...props} onSearch={onSearch} />)
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(<RtDryRow {...props} offline onSearch={onSearch} />)
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(<RtDryRow {...props} offline />)
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<string, unknown> = {}) => ({
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<RefuelSearch> = {}) => ({
...idle, openFor: '7:1', ...over,
} as unknown as RefuelSearch)
it('FE-MOB-RTROW-027: says it is looking while the search runs', () => {
render(<RtDryRow {...props} refuel={answering({ loading: true })} onSearch={vi.fn()} />)
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(
<RtDryRow
{...props}
refuel={answering({ outcome: 'found', results: [offer()] })}
onSearch={vi.fn()}
onAccept={vi.fn()}
/>,
)
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(
<RtDryRow
{...props}
refuel={answering({ openFor: '7:4', outcome: 'found', results: [offer()] })}
onSearch={vi.fn()}
/>,
)
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(<RtDryRow {...props} refuel={refuel} onSearch={vi.fn()} onAccept={onAccept} />)
fireEvent.click(screen.getByRole('button', { name: 'roadtrip.refuel.add:Shell Ebina' }))
expect(onAccept).toHaveBeenCalledWith(expect.objectContaining({ osm_id: 'n1' }))
view.unmount()
render(<RtDryRow {...props} refuel={refuel} />)
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(<RtDryRow {...props} refuel={answering({ outcome: 'none' })} onSearch={vi.fn()} />)
expect(screen.getByText('roadtrip.refuel.none')).toBeInTheDocument()
none.unmount()
const cut = render(<RtDryRow {...props} refuel={answering({ outcome: 'incomplete' })} onSearch={vi.fn()} />)
expect(screen.getByText('roadtrip.refuel.incomplete')).toBeInTheDocument()
cut.unmount()
render(<RtDryRow {...props} refuel={answering({ outcome: 'failed' })} onSearch={vi.fn()} />)
// 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(<RtAutoRow phase="end" time="22:00" chrome={chrome} />)
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(<RtAutoRow phase="resume" time={null} chrome={chrome} />)
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(
<RtSpillRow fromDayNumber={1} departs="23:10" chrome={chrome}>
<span>Fuji Viewpoint</span>
</RtSpillRow>,
)
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(<RtSpillRow fromDayNumber={1} departs={null} chrome={chrome} />)
expect(screen.getByText('roadtrip.spill.title:1')).toBeInTheDocument()
expect(screen.queryByText(/roadtrip\.spill\.departs/)).toBeNull()
})
})
@@ -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<string, unknown>,
swipe: {} as { onSelectDay: (id: number) => void; describeDay: (i: number, n: number) => string },
}))
vi.mock('../../../../src/hooks/useRoadtripSettings', () => ({
useRoadtripSettings: (select: (p: Record<string, unknown>) => 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<typeof import('../../../../src/mobile/screens/trip/plan/useMPlanDaySwipe')>()
return {
...actual,
useMPlanDaySwipe: (config: Parameters<typeof actual.useMPlanDaySwipe>[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> = {}): 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> = {}): 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> = {}): TripPlanner {
return buildPlanner({
tripId: 7,
days: DAYS,
places: PLACES,
selectedDayId: 2,
roadtripRoutes: routes(),
askRefuel: vi.fn(),
...over,
} as Partial<TripPlanner>)
}
function renderTab(p: TripPlanner = planner(), shell: MTripShellApi = buildShell()) {
const view = render(<MRoadtripTab planner={p} shell={shell} tab="roadtrip" />)
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<TripPlanner> {
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(<MRoadtripTab planner={undated} shell={buildShell()} tab="roadtrip" />)
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<RoadtripDay>)
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<RoadtripDay>)
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')
})
})
})
@@ -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> = {}): 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<MRtCorridorController> = {}) {
const c = corridor(over)
const onOpen = vi.fn()
const view = render(<MRtCorridorBar planner={buildPlanner()} corridor={c} onOpen={onOpen} />)
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()
})
})
@@ -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> = {}): 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> = {}): 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<MRtCorridorController> = {},
p: Partial<TripPlanner> = {},
shellOver: Partial<MTripShellApi> = {},
) {
mocks.corridor = corridor(over)
const planner = buildPlanner({ can: () => true, ...p } as Partial<TripPlanner>)
const shell = buildShell({ sheet: { id: 'rtsearch' }, ...shellOver })
const view = render(<MRtCorridorSheet planner={planner} shell={shell} />)
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()
})
})
@@ -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> = {}): 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<string, unknown> = {}) {
const planner = buildPlanner({
stopDraft: draftOf('fuel'),
setStopDraft: vi.fn(),
saveStopDraft: vi.fn(async () => undefined),
stopDraftDuplicate: null,
...overrides,
} as unknown as Partial<TripPlanner>)
render(<MRtDraftSheet planner={planner} />)
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()
})
})
@@ -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 "<userId>:<tripId>".
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<string, unknown> = {}) {
seedPreferences(preferences)
const planner = buildPlanner({ tripId: TRIP_ID } as unknown as Partial<TripPlanner>)
const shell = buildShell({ sheet: { id: 'rtinfo' }, ...shellOverrides } as unknown as Partial<MTripShellApi>)
render(<MRtInfoSheet planner={planner} shell={shell} />)
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()
})
})
@@ -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<string, unknown> = {}) {
return buildPlanner({
tripId: 4,
selectedDayId: 11,
roadtripRoutes: { days: [STAGE] },
setRoadtripStay: vi.fn(async () => undefined),
...overrides,
} as unknown as Partial<TripPlanner>)
}
function makeShell(overrides: Record<string, unknown> = {}) {
return buildShell({
sheet: { id: 'rtstay', payload: { placeId: 202, minutes: 30, name: 'Bremen Marktplatz' } },
...overrides,
} as unknown as Partial<MTripShellApi>)
}
function renderSheet(plannerOverrides: Record<string, unknown> = {}, shellOverrides: Record<string, unknown> = {}) {
const planner = makePlanner(plannerOverrides)
const shell = makeShell(shellOverrides)
render(<MRtStaySheet planner={planner} shell={shell} />)
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()
})
})
@@ -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 }) => <div data-testid="charging-info">{placeId}</div>,
}))
function stop(over: Partial<RoadtripStop> & Pick<RoadtripStop, 'assignmentId' | 'placeId' | 'name'>): 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<string, unknown> = {}) {
return buildPlanner({
tripId: 4,
places: PLACES,
dailyTimesActive: true,
setRoadtripEndDay: vi.fn(async () => undefined),
roadtripRoutes: { days: [DAY_A, DAY_B] },
...overrides,
} as unknown as Partial<TripPlanner>)
}
function makeShell(overrides: Record<string, unknown> = {}) {
return buildShell({
sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 103 } },
...overrides,
} as unknown as Partial<MTripShellApi>)
}
function renderSheet(plannerOverrides: Record<string, unknown> = {}, shellOverrides: Record<string, unknown> = {}) {
const planner = makePlanner(plannerOverrides)
const shell = makeShell(shellOverrides)
render(<MRtStopSheet planner={planner} shell={shell} />)
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()
})
})
@@ -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 }) => <div data-testid="stub-rtdraft" data-trip={planner.tripId} />,
}))
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'])
})
})
@@ -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 <div data-testid="tab-panel">{tab}</div>
@@ -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<string, unknown> = {}) => ({
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<TripPlanner> = {}) {
} as Partial<TripPlanner>)
const Shell = () => (
<MTripShell
PlanTimeline={slot('plan-timeline')}
MapArea={slot('map-area')}
PlacesBrowser={slot('places-browser')}
PlanTimeline={PlanTimelineSlot}
MapArea={MapAreaSlot}
PlacesBrowser={PlacesBrowserSlot}
TabPanel={TabSlot}
Sheets={slot('sheets')}
Sheets={SheetsSlot}
/>
)
const view = render(<Shell />)
@@ -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<TripPlanner>)
// 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<TripPlanner>)
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<TripPlanner>)
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<TripPlanner>)
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<TripPlanner>)
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<TripPlanner>)
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<TripPlanner>)
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<TripPlanner>)
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<TripPlanner>)
// 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<TripPlanner>)
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<TripPlanner>)
// 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<TripPlanner>)
expect(screen.getByRole('button', { name: 'planner.dayN:4 · 50 km' })).toBeInTheDocument()
})
})
})
describe('buildShell fixture', () => {
@@ -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> = {}): 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> = {}): 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> = {}): 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> = {}): TripPlanner {
return buildPlanner({
tripId: 7,
days: DAYS,
selectedDayId: 2,
roadtripRoutes: routes(),
roadtripCorridor: corridor(),
...over,
} as Partial<TripPlanner>)
}
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)
})
})
+1
View File
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': 'عرض الرحلة كاملة',
'map.overview.hide': 'إخفاء الرحلة كاملة',
'map.overview.total': 'المسافة الإجمالية',
'map.attribution': 'مصادر الخريطة',
};
export default map;
+17
View File
@@ -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;
+1
View File
@@ -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;
+21
View File
@@ -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;
+1
View File
@@ -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;
+21
View File
@@ -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;
+1
View File
@@ -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;
+20
View File
@@ -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;
+1
View File
@@ -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;
+20
View File
@@ -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;
+1
View File
@@ -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;
+19
View File
@@ -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;
+1
View File
@@ -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;
+21
View File
@@ -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;
+1
View File
@@ -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;
+21
View File
@@ -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;
+1
View File
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': 'Εμφάνιση όλου του ταξιδιού',
'map.overview.hide': 'Απόκρυψη όλου του ταξιδιού',
'map.overview.total': 'Συνολική απόσταση',
'map.attribution': 'Πηγές χάρτη',
};
export default map;
+21
View File
@@ -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;
+1
View File
@@ -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;
+21
View File
@@ -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;
+1
View File
@@ -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;
+21
View File
@@ -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;
+1
View File
@@ -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;
+21
View File
@@ -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;
+1
View File
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': '旅程全体を表示',
'map.overview.hide': '旅程全体を非表示',
'map.overview.total': '合計距離',
'map.attribution': '地図のクレジット',
};
export default map;
+17
View File
@@ -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;
+1
View File
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': '전체 여행 보기',
'map.overview.hide': '전체 여행 숨기기',
'map.overview.total': '총 거리',
'map.attribution': '지도 출처',
};
export default map;
+17
View File
@@ -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;
+1
View File
@@ -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;
+19
View File
@@ -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;
+1
View File
@@ -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;
+20
View File
@@ -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;
+1
View File
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': 'Показать всю поездку',
'map.overview.hide': 'Скрыть всю поездку',
'map.overview.total': 'Общее расстояние',
'map.attribution': 'Источники карты',
};
export default map;
+21
View File
@@ -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;
+1
View File
@@ -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;
+19
View File
@@ -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;
+1
View File
@@ -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;
+19
View File
@@ -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;
+1
View File
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': 'Показати всю подорож',
'map.overview.hide': 'Сховати всю подорож',
'map.overview.total': 'Загальна відстань',
'map.attribution': 'Джерела карти',
};
export default map;
+21
View File
@@ -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;
+1
View File
@@ -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;
+21
View File
@@ -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;
+1
View File
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': '顯示整趟行程',
'map.overview.hide': '隱藏整趟行程',
'map.overview.total': '總距離',
'map.attribution': '地圖來源',
};
export default map;
+16
View File
@@ -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;
+1
View File
@@ -25,5 +25,6 @@ const map: TranslationStrings = {
'map.overview.show': '显示整个行程',
'map.overview.hide': '隐藏整个行程',
'map.overview.total': '总距离',
'map.attribution': '地图来源',
};
export default map;
+16
View File
@@ -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;