feat(roadtrip): other ways to drive a leg, on the phone

The desk offers alternative routes from the drive band between two stops;
the phone had nothing. A leg in the phone chain now carries one round
button that asks the router for other ways and switches to the map, where a
bar takes the stage bar's place: the ways as chips with their colour, time,
distance and what sets them apart, and a confirm button. On glass there is
no hover, so a tap on a chip or on a line only previews that road; confirm
writes it. The button shows only where the desk offers it (a routed leg
between chosen stops), for someone who may edit the day, and not offline,
since vias are written online only.

The picker could not have stayed open on a phone at all: the planner closed
it whenever road trip mode was off, and that mode is off on a phone by
design, so the next render shut it. The close now follows where the picker
can be seen (road trip mode on the desk, the road trip tab on a phone), and
useRouteAlternatives returns a memoised object.

The rules both shells need are shared: legReroutable in the row model,
alternativesPhase and alternativeSubline next to the overlays, and the desk
bar and sidebar read them too with no visible change. Both map engines take
an optional fitPadding, so the phone frames the leg between the day chips
and the bar instead of under them.
This commit is contained in:
Maurice
2026-09-17 00:10:51 +00:00
parent 9b415c8ab9
commit 6a6c7f1882
25 changed files with 1572 additions and 57 deletions
@@ -1074,6 +1074,50 @@ describe('MapView bounds fitting', () => {
rerender(<MapView places={places} fitKey={2} />)
expect(mapMock.fitBounds).not.toHaveBeenCalled()
})
it('FE-COMP-MAPVIEW-078: a caller padding frames the focus points instead of the phone margin, and the day fit keeps its own', () => {
// A phone shell lays a chip rail over the top of the map and a bar plus the dock over
// the bottom. The flat margin put the ends of a framed leg under either of them.
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 })
const leg: [number, number][] = [[48.1, 2.1], [48.3, 2.4]]
const chrome = { top: 120, right: 16, bottom: 290, left: 16 }
const { rerender } = render(<MapView places={[]} fitKey={1} />)
expect(mapMock.fitBounds).not.toHaveBeenCalled()
rerender(<MapView places={[]} fitKey={1} focusPoints={leg} fitPadding={chrome} />)
expect(mapMock.fitBounds).toHaveBeenCalledTimes(1)
expect(mapMock.fitBounds.mock.calls[0][1]).toMatchObject({ paddingTopLeft: [16, 120], paddingBottomRight: [16, 290] })
// Picking a day is not the caller's frame, so that fit keeps the phone margin.
const places = [buildMapPlace({ id: 1, lat: 48, lng: 2 }), buildMapPlace({ id: 2, lat: 48.2, lng: 2.2 })]
rerender(<MapView places={places} fitKey={2} focusPoints={leg} fitPadding={chrome} />)
expect(mapMock.fitBounds).toHaveBeenCalledTimes(2)
expect(mapMock.fitBounds.mock.calls[1][1]).toMatchObject({ paddingTopLeft: [40, 20], paddingBottomRight: [40, 20] })
// And without one, the focus fit falls back to that margin too.
const stage: [number, number][] = [[47, 1], [47.5, 1.5]]
rerender(<MapView places={places} fitKey={2} focusPoints={stage} />)
expect(mapMock.fitBounds).toHaveBeenCalledTimes(3)
expect(mapMock.fitBounds.mock.calls[2][1]).toMatchObject({ paddingTopLeft: [40, 20], paddingBottomRight: [40, 20] })
})
it('FE-COMP-MAPVIEW-079: the caller padding is read by value, so only new numbers refit the points on screen', () => {
const leg: [number, number][] = [[48.1, 2.1], [48.3, 2.4]]
const { rerender } = render(
<MapView places={[]} fitKey={1} focusPoints={leg} fitPadding={{ top: 120, right: 16, bottom: 290, left: 16 }} />,
)
expect(mapMock.fitBounds).toHaveBeenCalledTimes(1)
// A parent that builds the object inline hands a new one on every render. The camera
// belongs to the traveller between fits, so that alone must not take it back.
rerender(<MapView places={[]} fitKey={1} focusPoints={leg} fitPadding={{ top: 120, right: 16, bottom: 290, left: 16 }} />)
expect(mapMock.fitBounds).toHaveBeenCalledTimes(1)
// New numbers mean the chrome moved, so the same points are framed again around it.
rerender(<MapView places={[]} fitKey={1} focusPoints={leg} fitPadding={{ top: 120, right: 16, bottom: 98, left: 16 }} />)
expect(mapMock.fitBounds).toHaveBeenCalledTimes(2)
expect(mapMock.fitBounds.mock.calls[1][1]).toMatchObject({ paddingTopLeft: [16, 120], paddingBottomRight: [16, 98] })
})
})
describe('MapView photo thumbnails', () => {
+30 -10
View File
@@ -401,15 +401,33 @@ interface BoundsControllerProps {
* needs that leg on screen, which is neither the day nor the trip.
*/
focusPoints?: [number, number][]
/**
* What the caller's own chrome covers while `focusPoints` is framed, in pixels per edge.
*
* `paddingOpts` knows the planner's panels and nothing else, and on a phone it is a flat
* margin. A shell that lays its own bars over the map passes what they cover, so the
* frame lands in the part still visible. Only the fit on `focusPoints` reads it.
* Compared by value: the same numbers in a new object do not refit, while new numbers
* refit the points already handed over, because the chrome they must clear has moved.
*/
fitPadding?: ViewportPadding
}
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false, focusPoints }: BoundsControllerProps) {
/** A padding box as Leaflet's fit options spell it, corner pairs rather than edges. */
function leafletPadding(box: ViewportPadding): L.FitBoundsOptions {
return {
paddingTopLeft: [box.left, box.top],
paddingBottomRight: [box.right, box.bottom],
}
}
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false, focusPoints, fitPadding }: BoundsControllerProps) {
const map = useMap()
const prevFitKey = useRef(-1)
const awaitingRoute = useRef(false)
const fitRan = useRef(false)
const fitTo = useCallback((coords: [number, number][]) => {
const fitTo = useCallback((coords: [number, number][], padding: L.FitBoundsOptions = paddingOpts) => {
if (coords.length === 0) return
try {
const bounds = L.latLngBounds(coords)
@@ -428,7 +446,7 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
* (#1982). MapViewGL never had the nudge, so this also brings the two
* renderers back into agreement.
*/
map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true })
map.fitBounds(bounds, { ...padding, maxZoom: 16, animate: true })
}
} catch {}
}, [map, paddingOpts])
@@ -460,14 +478,18 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
fitTo([...places.map(p => [p.lat, p.lng] as [number, number]), ...routeCoords])
}, [routeCoords]) // eslint-disable-line react-hooks/exhaustive-deps
// The caller's padding as a value, so a parent that builds the object inline on every
// render does not move the camera each time it renders.
const fitPaddingKey = fitPadding ? [fitPadding.top, fitPadding.right, fitPadding.bottom, fitPadding.left].join(' ') : ''
// Frame whatever was handed over. Nothing happens when it empties, so closing the
// picker leaves the map where the user left it rather than snapping back.
useEffect(() => {
if (!focusPoints?.length) return
// A day fit that has not run yet must not overwrite this a moment later.
awaitingRoute.current = false
fitTo(focusPoints)
}, [focusPoints]) // eslint-disable-line react-hooks/exhaustive-deps
fitTo(focusPoints, fitPadding ? leafletPadding(fitPadding) : paddingOpts)
}, [focusPoints, fitPaddingKey]) // eslint-disable-line react-hooks/exhaustive-deps
return null
}
@@ -687,6 +709,7 @@ export const MapView = memo(function MapView({
onRemoveVia,
alternativeRoutes,
focusPoints,
fitPadding,
clusterLoosely = false,
activeAlternative,
onChooseAlternative,
@@ -748,10 +771,7 @@ export const MapView = memo(function MapView({
}
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
const paddingOpts = useMemo((): L.FitBoundsOptions => ({
paddingTopLeft: [paddingBox.left, paddingBox.top],
paddingBottomRight: [paddingBox.right, paddingBox.bottom],
}), [paddingBox])
const paddingOpts = useMemo(() => leafletPadding(paddingBox), [paddingBox])
// Open framed on the places rather than on the caller's default, so a trip in Japan shows
// Japan straight away instead of the world view followed by a flight across the planet.
@@ -1070,7 +1090,7 @@ export const MapView = memo(function MapView({
)}
<MapController center={center} zoom={zoom} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} />
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} fitPadding={fitPadding} />
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
<MapClickHandler onClick={onMapClick} />
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
@@ -2196,6 +2196,62 @@ describe('MapViewGL', () => {
)
expect(glMap.addSource).not.toHaveBeenCalledWith('trip-satellite', expect.anything())
})
it('FE-COMP-MAPVIEWGL-085: a caller padding frames the focus points instead of the phone margin, and the day fit keeps its own', async () => {
// A phone shell lays a chip rail over the top of the map and a bar plus the dock over
// the bottom. The flat 40px margin put the ends of a framed leg under either of them.
const original = window.innerWidth
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: 420 })
try {
const leg: [number, number][] = [[48.1, 2.1], [48.3, 2.4]]
const chrome = { top: 120, right: 16, bottom: 290, left: 16 }
const { rerender } = render(<MapViewGL places={[]} fitKey={1} />)
await act(async () => {})
expect(glMap.fitBounds).not.toHaveBeenCalled()
rerender(<MapViewGL places={[]} fitKey={1} focusPoints={leg} fitPadding={chrome} />)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(1)
expect(glMap.fitBounds.mock.calls[0][1]).toMatchObject({ padding: chrome, maxZoom: 15 })
// Picking a day is not the caller's frame, so that fit keeps the phone margin.
const places = [buildMapPlace({ id: 88, lat: 48.1, lng: 2.1 }), buildMapPlace({ id: 89, lat: 48.2, lng: 2.2 })]
rerender(<MapViewGL places={places} fitKey={2} focusPoints={leg} fitPadding={chrome} />)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(2)
expect(glMap.fitBounds.mock.calls[1][1]).toMatchObject({ padding: { top: 40, right: 20, bottom: 40, left: 20 } })
// And without one, the focus fit falls back to that margin too.
const stage: [number, number][] = [[47, 1], [47.5, 1.5]]
rerender(<MapViewGL places={places} fitKey={2} focusPoints={stage} />)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(3)
expect(glMap.fitBounds.mock.calls[2][1]).toMatchObject({ padding: { top: 40, right: 20, bottom: 40, left: 20 } })
} finally {
Object.defineProperty(window, 'innerWidth', { configurable: true, writable: true, value: original })
}
})
it('FE-COMP-MAPVIEWGL-086: the caller padding is read by value, so only new numbers refit the points on screen', async () => {
const leg: [number, number][] = [[48.1, 2.1], [48.3, 2.4]]
const { rerender } = render(
<MapViewGL places={[]} fitKey={1} focusPoints={leg} fitPadding={{ top: 120, right: 16, bottom: 290, left: 16 }} />,
)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(1)
// A parent that builds the object inline hands a new one on every render. The camera
// belongs to the traveller between fits, so that alone must not take it back.
rerender(<MapViewGL places={[]} fitKey={1} focusPoints={leg} fitPadding={{ top: 120, right: 16, bottom: 290, left: 16 }} />)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(1)
// New numbers mean the chrome moved, so the same points are framed again around it.
rerender(<MapViewGL places={[]} fitKey={1} focusPoints={leg} fitPadding={{ top: 120, right: 16, bottom: 98, left: 16 }} />)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(2)
expect(glMap.fitBounds.mock.calls[1][1]).toMatchObject({ padding: { top: 120, right: 16, bottom: 98, left: 16 } })
})
})
describe('MapViewGL attribution', () => {
const flushFrames = () => act(async () => { await new Promise(resolve => setTimeout(resolve, 40)) })
+18 -3
View File
@@ -40,7 +40,7 @@ import { resolveTrackColor, hasManualTrackColor } from './trackColors'
import { buildPoiPopupHtml } from './placePopup'
import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api/client'
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_ATTRIBUTION, SATELLITE_TILE_MAXZOOM } from '../../constants/mapDefaults'
import { computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
import { computeMapViewport, TILE_SIZE_GL, type ViewportPadding } from '../../utils/mapViewport'
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
@@ -185,6 +185,16 @@ interface Props {
* needs that leg on screen, which is neither the day nor the trip.
*/
focusPoints?: [number, number][]
/**
* What the caller's own chrome covers while `focusPoints` is framed, in pixels per edge.
*
* The default padding knows this component's panels and nothing else, and on a phone it
* is a flat margin. A shell that lays its own bars over the map passes what they cover,
* so the frame lands in the part still visible. Only the fit on `focusPoints` reads it.
* Compared by value: the same numbers in a new object do not refit, while new numbers
* refit the points already handed over, because the chrome they must clear has moved.
*/
fitPadding?: ViewportPadding
/**
* Let markers stay apart longer than usual.
*
@@ -656,6 +666,7 @@ export function MapViewGL({
zoom = DEFAULT_MAP_ZOOM,
fitKey = 0,
focusPoints,
fitPadding,
clusterLoosely = false,
hazards,
dawarichTrack = null,
@@ -2206,6 +2217,10 @@ export function MapViewGL({
if (routeArrivedForPendingFit) pendingRouteFitRef.current = null
}, [fitKey, routeFitKey]) // eslint-disable-line react-hooks/exhaustive-deps
// The caller's padding as a value, so a parent that builds the object inline on every
// render does not move the camera each time it renders.
const fitPaddingKey = fitPadding ? [fitPadding.top, fitPadding.right, fitPadding.bottom, fitPadding.left].join(' ') : ''
// Frame whatever was handed over. Nothing happens when it empties, so closing the
// picker leaves the map where the user left it rather than snapping back.
useEffect(() => {
@@ -2217,13 +2232,13 @@ export function MapViewGL({
pendingRouteFitRef.current = null
try {
map.fitBounds(bounds, {
padding: paddingOpts,
padding: fitPadding ?? paddingOpts,
maxZoom: 15,
pitch: enableMapbox3d ? 45 : 0,
duration: 400,
})
} catch { /* noop */ }
}, [focusPoints]) // eslint-disable-line react-hooks/exhaustive-deps
}, [focusPoints, fitPaddingKey]) // eslint-disable-line react-hooks/exhaustive-deps
// flyTo selected place
useEffect(() => {
@@ -4,9 +4,8 @@ import { useTranslation } from '../../i18n/TranslationContext'
import { Tooltip } from '../shared/Tooltip'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance } from '../../utils/units'
import { formatDurationShort } from './roadtripModel'
import type { LegAlternatives } from './useRouteAlternatives'
import type { AlternativeOverlay } from './alternativeOverlays'
import { alternativesPhase, alternativeSubline, type AlternativeOverlay } from './alternativeOverlays'
interface RoadtripAlternativesBarProps {
open: LegAlternatives | null
@@ -35,6 +34,7 @@ export default function RoadtripAlternativesBar({
const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
if (!open) return null
const phase = alternativesPhase(open, overlays)
return (
<div className="pointer-events-auto flex max-w-[min(92vw,640px)] flex-col gap-2 rounded-2xl border border-edge-faint bg-surface-elevated px-3 py-2.5 shadow-modal backdrop-blur">
@@ -53,14 +53,14 @@ export default function RoadtripAlternativesBar({
</button>
</div>
{open.loading ? (
{phase === 'loading' ? (
<p className="text-caption text-content-muted">{t('roadtrip.alt.loading')}</p>
) : open.error ? (
) : phase === 'failed' ? (
<p className="flex items-start gap-1.5 text-caption text-warning">
<AlertTriangle size={12} className="mt-0.5 shrink-0" aria-hidden />
{t('roadtrip.alt.failed')}
</p>
) : overlays.length < 2 ? (
) : phase === 'onlyOne' ? (
// One route back means there is genuinely only one sensible way to drive it.
<p className="text-caption text-content-muted">{t('roadtrip.alt.onlyOne')}</p>
) : (
@@ -88,9 +88,7 @@ export default function RoadtripAlternativesBar({
{formatDistance(alt.distance / 1000, distanceUnit)}
</span>
<span className="text-caption tabular-nums text-content-muted">
{alt.note
? alt.note
: t('roadtrip.alt.slower', { time: formatDurationShort(alt.slowerThanQuickest) })}
{alternativeSubline(alt, time => t('roadtrip.alt.slower', { time }))}
</span>
</span>
{/* Only where the drive time on the map came from the other engine. The
@@ -17,6 +17,7 @@ import { formatDistance } from '../../utils/units'
import { formatDate, formatClockTime } from '../../utils/formatters'
import { formatDurationShort, isServiceStopType, serviceColor, type ScheduleEntry, type ScheduleWarning, refuelsRange } from './roadtripModel'
import { STOP_KIND_BY_KEY } from './stopKinds'
import { legReroutable } from './roadtripRowModel'
import { spurWorthLabelling } from './accessSpur'
import StopKindPicker from './StopKindPicker'
import StopFillPicker from './StopFillPicker'
@@ -1416,7 +1417,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
{i < last && (!day.stops[i + 1].automaticNight || day.legs[i]?.distance !== 0) ? (
<DriveBand
leg={day.legs[i]}
onAskAlternatives={onAskAlternatives && !day.stops[i + 1].automaticNight ? () => onAskAlternatives(day.dayId, i) : undefined}
onAskAlternatives={onAskAlternatives && legReroutable(day, i) ? () => onAskAlternatives(day.dayId, i) : undefined}
alternativesOpen={openAlternatives?.dayId === day.dayId && openAlternatives.index === i}
/>
) : null}
@@ -1,10 +1,11 @@
import { describe, it, expect } from 'vitest'
import { buildAlternativeOverlays } from './alternativeOverlays'
import { alternativeSubline, alternativesPhase, buildAlternativeOverlays, type AlternativeOverlay } from './alternativeOverlays'
import { formatDurationShort } from './roadtripModel'
import { ALT_PRIMARY, ALT_SECONDARY, ALT_LABEL_PRIMARY_BG, ALT_LABEL_SECONDARY_BG } from './alternativeColors'
import type { RouteAlternative } from '../Map/RouteCalculator'
/**
* FE-ALTOVL-001..010 — turning the router's answers into something drawable.
* FE-ALTOVL-001..017: turning the router's answers into something drawable.
*
* Two decisions live here and neither is cosmetic: which of the offered roads is
* drawn as the one you are on, and where each label hangs. A label anchored on a
@@ -221,3 +222,36 @@ describe('buildAlternativeOverlays', () => {
expect(out[1].color).toBe(ALT_SECONDARY)
})
})
describe('alternativesPhase', () => {
const two = buildAlternativeOverlays([alt({ coordinates: line(20, 0) }), alt({ coordinates: line(20, 1) })], LABELS)
it('FE-ALTOVL-016: asking beats a failure, a failure beats an empty list, and only two roads are a choice', () => {
// A new question replaces the old answer and its error with it.
expect(alternativesPhase({ loading: true, error: true }, two)).toBe('loading')
expect(alternativesPhase({ loading: true, error: false }, [])).toBe('loading')
// A router that will not answer is not a leg with one sensible way.
expect(alternativesPhase({ loading: false, error: true }, [])).toBe('failed')
expect(alternativesPhase({ loading: false, error: true }, two)).toBe('failed')
expect(alternativesPhase({ loading: false, error: false }, [])).toBe('onlyOne')
expect(alternativesPhase({ loading: false, error: false }, two.slice(0, 1))).toBe('onlyOne')
expect(alternativesPhase({ loading: false, error: false }, two)).toBe('choose')
})
})
describe('alternativeSubline', () => {
const overlay = (over: Partial<AlternativeOverlay>): AlternativeOverlay => ({
...buildAlternativeOverlays([alt({ coordinates: line(20, 0) }), alt({ coordinates: line(20, 1) })], LABELS)[1],
...over,
})
it('FE-ALTOVL-017: an offer is named by what it is, and only a plain one by how much slower it is', () => {
const slower = (time: string) => `${time} slower`
for (const note of ['No motorway', 'Current', 'Fastest']) {
expect(alternativeSubline(overlay({ note, slowerThanQuickest: 1800 }), slower)).toBe(note)
}
// The caller words the difference; the figure is the same short duration the map prints.
expect(alternativeSubline(overlay({ note: '', slowerThanQuickest: 1800 }), slower))
.toBe(`${formatDurationShort(1800)} slower`)
})
})
@@ -1,6 +1,7 @@
import { formatDurationShort } from './roadtripModel'
import { ALT_PRIMARY, ALT_SECONDARY, ALT_LABEL_PRIMARY_BG, ALT_LABEL_SECONDARY_BG } from './alternativeColors'
import type { RouteAlternative } from '../Map/RouteCalculator'
import type { LegAlternatives } from './useRouteAlternatives'
/** One offered route, ready to draw: the line, its colour, and where its label sits. */
export interface AlternativeOverlay {
@@ -154,3 +155,39 @@ export function buildAlternativeOverlays(
}
})
}
/** What a picker has to say about one leg, whichever shell draws it. */
export type AlternativesPhase = 'loading' | 'failed' | 'onlyOne' | 'choose'
/**
* Which of its four states a picker is in.
*
* Both bars branch on this, so the desk and the phone cannot disagree about when a leg has
* a choice. Asking wins over a failure, because a new question replaces the old answer
* and its error with it. A failure wins over an empty list, so a router that will not
* answer is never read as a leg with only one sensible way. Fewer than two overlays is
* that single way: `buildAlternativeOverlays` draws nothing for one answer, and one road
* is not a choice.
*/
export function alternativesPhase(
open: Pick<LegAlternatives, 'loading' | 'error'>,
overlays: readonly AlternativeOverlay[],
): AlternativesPhase {
if (open.loading) return 'loading'
if (open.error) return 'failed'
return overlays.length < 2 ? 'onlyOne' : 'choose'
}
/**
* The second line an offer is listed with: what this way is, or else how much slower.
*
* Its own note wins, for the reason the note exists: a road offered because the motorway
* was left out of it is taken for that, not for its minutes. Without one the line is the
* difference to the quickest, worded by the caller (a translation this module stays free
* of). The desk bar's docstring tells what two copies of one figure once did, the driven
* road called "Fastest" beside an offer called quicker, so both bars read this line here
* instead of each keeping its own.
*/
export function alternativeSubline(alt: AlternativeOverlay, slower: (time: string) => string): string {
return alt.note || slower(formatDurationShort(alt.slowerThanQuickest))
}
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
destinationCount,
firstStopOfPlace,
legReroutable,
pickWarning,
roadtripRows,
stageClocks,
@@ -14,7 +15,7 @@ import {
import type { ScheduleEntry, ScheduleWarning } from './roadtripModel'
import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
// FE-RTROW-001 to FE-RTROW-037
// FE-RTROW-001 to FE-RTROW-039
function stop(name: string, over: Partial<RoadtripStop> = {}): RoadtripStop {
return {
@@ -443,3 +444,31 @@ describe('firstStopOfPlace', () => {
expect(firstStopOfPlace([day([stop('Nacht', { placeId: 303, automaticNight: { phase: 'end', fromDayNumber: 1 } })])], 303)).toBeNull()
})
})
describe('legReroutable', () => {
it('FE-RTROW-038: a routed leg between two stops anybody chose can be offered other ways, a service stop included', () => {
const d = day([stop('Bremen'), stop('Aral', { stopType: 'fuel' }), stop('Kassel')])
expect(legReroutable(d, 0)).toBe(true)
expect(legReroutable(d, 1)).toBe(true)
})
it('FE-RTROW-039: no leg into or out of an automatic night, none without a route, none past either end', () => {
// Bremen, Kassel, automatic night, Fulda: the rail offers the first leg only.
const withNight = day([stop('Bremen'), stop('Kassel'), night(), stop('Fulda')])
expect(legReroutable(withNight, 0)).toBe(true)
// Into the marker where the window closed, and out of it again the morning after.
expect(legReroutable(withNight, 1)).toBe(false)
expect(legReroutable(withNight, 2)).toBe(false)
expect(legReroutable(day([night('start'), stop('Fulda')]), 0)).toBe(false)
// A leg the router has not answered for yet has nothing to weigh an offer against.
const unrouted = day([stop('A'), stop('B'), stop('C')], { legs: [seg(0)] })
expect(legReroutable(unrouted, 0)).toBe(true)
expect(legReroutable(unrouted, 1)).toBe(false)
// The last stop has no leg after it, whatever the legs array happens to hold.
const extra = day([stop('A'), stop('B')], { legs: [seg(0), seg(1)] })
expect(legReroutable(extra, 1)).toBe(false)
expect(legReroutable(extra, -1)).toBe(false)
})
})
@@ -152,6 +152,26 @@ export function roadtripRows(day: RoadtripDay): RoadtripRow[] {
return rows
}
/**
* Whether the leg leaving stop `index` can be offered other ways of driving it.
*
* The desktop rail's rule, kept here so the phone chain offers the button on exactly the
* legs the rail does. A leg needs a route, because an unrouted one has nothing to weigh an
* offer against. And neither of its ends may be an automatic night: that point is the
* shell's own marker for where the daily window closed, a spot somewhere along the road
* rather than a stop anyone chose. The router's offers from or to it would reshape a
* stretch of a longer drive, and the via a choice writes is filed by the position of a
* stored stop, which the marker is not. The rail draws the band out of that marker
* without the control for the same reason.
*/
export function legReroutable(day: RoadtripDay, index: number): boolean {
return index >= 0
&& index < day.stops.length - 1
&& !!day.legs[index]
&& !day.stops[index].automaticNight
&& !day.stops[index + 1].automaticNight
}
/** 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
@@ -12,7 +12,7 @@ import type { RoadtripStop } from './useRoadtripRoutes'
import type { RoadtripVia } from '@trek/shared'
/**
* FE-ALTHOOK-001..009 — asking for other ways of driving one leg.
* FE-ALTHOOK-001..010: asking for other ways of driving one leg.
*
* The case that carries the feature: when the leg already carries vias, the road
* actually being driven is NOT among what the router offers for the two bare
@@ -147,4 +147,25 @@ describe('useRouteAlternatives', () => {
expect(signals[1].aborted).toBe(true)
expect(result.current.open).toBeNull()
})
it('FE-ALTHOOK-010: the state keeps its identity until the picker itself changes', async () => {
// The planner keys its close gate on this object. A new one per render ran that
// effect on every render, so any render at all was a chance to close the picker.
const { result, rerender } = renderHook(() => useRouteAlternatives())
const idle = result.current
rerender()
expect(result.current).toBe(idle)
act(() => { result.current.ask(4, 1, from, to, 'driving', []) })
const asking = result.current
expect(asking).not.toBe(idle)
// The functions themselves never change, only the object carrying the new `open`.
expect(asking.ask).toBe(idle.ask)
expect(asking.close).toBe(idle.close)
await waitFor(() => expect(result.current.open?.loading).toBe(false))
const answered = result.current
rerender()
expect(result.current).toBe(answered)
})
})
@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { calculateAlternatives, calculateRoute, type RouteAlternative } from '../Map/RouteCalculator'
import type { RoadtripStop } from './useRoadtripRoutes'
import type { RoadtripVia } from '@trek/shared'
@@ -108,5 +108,11 @@ export function useRouteAlternatives(): RouteAlternativesState {
})
}, [])
return { open, ask, close }
// One object for as long as nothing in it changes. The planner keys its close gate and
// the callbacks that ask, choose and focus on this state as a whole, so a fresh object
// per render re-ran that gate and rebuilt those callbacks on every render of the
// planner, whatever had caused it. Whether the picker stays open is still the gate's own
// condition to decide (a new `open` runs it either way); the memo only stops that work
// from repeating on renders that changed nothing about the picker.
return useMemo(() => ({ open, ask, close }), [open, ask, close])
}
+106 -3
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { useCallback, useLayoutEffect, useMemo, useRef, useState, type RefObject } from 'react'
import { MapViewAuto } from '../../../../components/Map/MapViewAuto'
import { MapCompassPill, type CompassMap } from '../../../../components/Map/MapCompassPill'
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../../../components/Map/MapLayerSwitcher'
@@ -12,8 +12,10 @@ import { stageMapData } from '../../../../components/Roadtrip/stageMap'
import { useRoadtripSettings } from '../../../../hooks/useRoadtripSettings'
import { useSettingsStore } from '../../../../store/settingsStore'
import { useTripStore } from '../../../../store/tripStore'
import { RT_ALT_BAR_LIFT } from '../roadtrip/useMRtAlternatives'
import type { MMapAreaProps } from '../MTripShell'
import type { Poi } from '../../../../components/Map/poiCategories'
import type { ViewportPadding } from '../../../../utils/mapViewport'
/** One array, so an explore set switched off does not move every pin on the stage. */
const NO_POIS: Poi[] = []
@@ -25,6 +27,71 @@ const NO_POIS: Poi[] = []
*/
const COMPASS_LEFT = MAP_LAYER_SWITCHER_INSET + MAP_ROUND_CONTROL_SIZE + 8
/** The safe-area insets at the top and the bottom of the screen, in pixels. */
interface SafeInsets {
top: number
bottom: number
}
const NO_INSETS: SafeInsets = { top: 0, bottom: 0 }
/**
* The safe-area insets as numbers, read off a probe that is padded by them.
*
* The frame handed to the map engine is pixels, and `env()` only resolves inside CSS. On a
* phone with a notch the chrome sits a status bar lower (and a home indicator higher) than
* its classes spell out, so a frame worked out without the insets would put the far end of
* a leg under the day chips. A computed padding is where the browser hands the resolved
* inset back. Read on mount and again on a resize, which is what turning the phone does.
*/
function useSafeInsets(probe: RefObject<HTMLElement | null>): SafeInsets {
const [insets, setInsets] = useState<SafeInsets>(NO_INSETS)
useLayoutEffect(() => {
const read = () => {
const el = probe.current
if (!el) return
const style = window.getComputedStyle(el)
const top = Number.parseFloat(style.paddingTop) || 0
const bottom = Number.parseFloat(style.paddingBottom) || 0
setInsets(prev => (prev.top === top && prev.bottom === bottom ? prev : { top, bottom }))
}
read()
window.addEventListener('resize', read)
return () => window.removeEventListener('resize', read)
}, [probe])
return insets
}
/**
* Where the roads offered for a leg are framed: in the strip of map between the day chips
* and the alternatives bar, rather than behind either of them.
*
* Both engines fit a phone with a flat margin that knows nothing of this shell's bars, and
* with the picker open those cover most of the screen: that margin would leave the leg's
* ends and their time pills under the chips and under a 174px bar. The numbers are the
* ones the classes on this screen are built from, so a change there has to be carried here.
*
* Top: the safe area, then `--m-safe-top`'s own 12px, the chip rail's 50px offset under it
* and its 42px height, then a 12px gap so a time pill at the far end is not flush against
* the rail. The search bar that sits below the rail on the stage steps away while the
* picker is open, so the rail is the lowest chrome up there.
*
* Bottom: the safe area, the dock's 74px (62px tall, 12px off the bottom), the bar's lift
* (its height and the 15px gap between the dock and its foot) and the 38px credit row
* over that. That is the very line `--bottom-nav-h` puts the round controls' band on, so
* the frame ends where they begin, and they only take the two corners of it.
*
* The sides keep the 20px both engines already give a phone: nothing floats there.
*/
function alternativesFitPadding(insets: SafeInsets): ViewportPadding {
return {
top: insets.top + 12 + 50 + 42 + 12,
right: 20,
bottom: insets.bottom + 74 + RT_ALT_BAR_LIFT + 38,
left: 20,
}
}
/** A camera focus the planner is holding, and the day that was on screen when it arrived. */
interface HeldFocus {
points: readonly [number, number][]
@@ -133,6 +200,20 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
if (focus !== heldFocus) setHeldFocus(focus)
const focusPending = focus.live && planner.mapFocusPoints.length > 0
// Handed over only once there are roads to frame, not as soon as the picker opens: the
// engines refit whatever they hold when this padding changes, and while the router is
// still thinking that is the stage, which would be flown out to a smaller frame just
// before the answer flies it to the leg. So the padding and the roads arrive in one
// render and the camera moves once, and on close both go in one render as well.
// Memoised for the reason the stage data is, the shell re-rendering on every store write.
const insetProbe = useRef<HTMLSpanElement>(null)
const insets = useSafeInsets(insetProbe)
const framingOffers = onStage && planner.alternativeOverlays.length > 0
const fitPadding = useMemo(
() => (framingOffers ? alternativesFitPadding(insets) : undefined),
[framingOffers, insets],
)
/**
* A pin on the road trip tab opens its stop: the sheet the chain row opens, not the
* place inspector.
@@ -201,9 +282,21 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
className="m-credit-corner absolute inset-0 isolate overflow-hidden bg-[color:var(--m-mapb)] [--m-map-floor:calc(env(safe-area-inset-bottom,0px)+74px+var(--m-stage-lift,0px))] [--bottom-nav-h:calc(var(--m-map-floor)+38px)]"
// 76px is the stage bar's own height plus the gap it keeps on both sides, so the
// credit lands one gap above the bar instead of on its top edge, and the round
// controls one credit row above that.
style={{ ['--m-stage-lift' as string]: onStage && mapActive ? '76px' : '0px' }}
// controls one credit row above that. While other ways of driving a leg are on offer
// their bar stands in that slot instead, taller, and the floor clears it the same way.
style={{
['--m-stage-lift' as string]: onStage && mapActive
? (planner.routeAlternatives.open ? `${RT_ALT_BAR_LIFT}px` : '76px')
: '0px',
}}
>
{/* Measures the safe area for the alternatives frame, see useSafeInsets. Out of the
flow and invisible, so it takes no room and catches no tap. */}
<span
ref={insetProbe}
aria-hidden="true"
className="pointer-events-none invisible absolute left-0 top-0 pb-[env(safe-area-inset-bottom,0px)] pt-[env(safe-area-inset-top,0px)]"
/>
<MapViewAuto
tripId={planner.tripId}
dawarichTrack={planner.dawarichTrail.track}
@@ -253,6 +346,16 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
: marker => planner.openAddPlaceFromPoi(marker, planner.selectedDayId)}
onViewportChange={poi.onViewportChange}
onMapReady={setGlMap}
// Other ways of driving the leg the stage asked about, and the one lit up.
alternativeRoutes={onStage ? planner.alternativeOverlays : undefined}
activeAlternative={onStage ? planner.highlightedAlternative : undefined}
// A tap on a road or its time pill only lights it; the bar's confirm takes it. The
// desk hands `chooseRouteAlternative` in here and previews on hover, but glass has
// no hover, so the same wiring would save a via on the very first touch of a line.
// No highlight handler either: a touch fires emulated mouse enter and leave events,
// and the leave would put out the road the tap has just lit.
onChooseAlternative={onStage ? planner.setHighlightedAlternative : undefined}
fitPadding={fitPadding}
/>
{/* Floating map chrome — only while the map view is front-most. The POI bar
@@ -1,4 +1,4 @@
import { AlertTriangle, Bike, CarFront, Footprints, Fuel, Hourglass, Moon, Pin, Plus, RotateCcw, Sunrise, X, Zap } from 'lucide-react'
import { AlertTriangle, Bike, CarFront, Footprints, Fuel, Hourglass, Moon, Pin, Plus, RotateCcw, Shuffle, Sunrise, X, Zap } from 'lucide-react'
import type { CSSProperties, ReactNode } from 'react'
import MDancingTrek from '../../../components/MDancingTrek'
import MIconBtn from '../../../components/MIconBtn'
@@ -19,9 +19,11 @@ import type { TranslationFn } from '../../../../types'
* 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.
* Few things in the chain react to a tap, and each looks like the control it is. A
* destination row is its own tap target, a leg has one round button that asks for other
* ways of driving it, and the dry band has its reserve lamp and the offers it brings
* back. The drive pill, 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 {
@@ -140,13 +142,32 @@ const LEG_ICONS: Record<string, typeof CarFront> = {
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 }: {
/**
* The drive between two stops. 12px rather than the desktop's 10: a four-hour leg
* deserves the space it takes.
*
* Other ways of driving it are asked for with one 40px round button at the end of the
* row, never with the whole band as on the desktop rail. The chain scrolls under a thumb,
* and with a band-wide target the tap that only meant to stop that scroll, landing on a
* leg, would cost up to five routing requests and a jump to the map. So the pill stays a
* line of text, and the button is the one thing on the row that acts: a small, separate
* target that keeps that cost behind a gesture nobody makes by accident.
*
* Open, the button takes the inner surface and the full ink rather than filling with the
* action colour, for the reason the rail gives: a filled chip in a column of quiet rows
* reads as a button that was pressed and stuck. Offline it keeps its place but dims,
* because a choice is saved as a via and vias are online only. Without `onAlternatives`
* (no permission, or a leg that cannot be rerouted) the column stays empty.
*/
export function RtLegRow({ seg, mode, chrome, onAlternatives, alternativesOpen = false, alternativesDisabled = false }: {
seg: RouteSegment | undefined
mode: string | null
chrome: RowChrome
/** Asks for other ways of driving this leg. Absent means the leg offers none. */
onAlternatives?: () => void
/** True while the picker is open on this leg. */
alternativesOpen?: boolean
alternativesDisabled?: boolean
}) {
const { t } = chrome
const Icon = mode && LEG_ICONS[mode] ? LEG_ICONS[mode] : mode?.startsWith('plugin:') ? Zap : CarFront
@@ -161,11 +182,28 @@ export function RtLegRow({ seg, mode, chrome }: {
<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]">
<span className="my-1.5 flex min-w-0 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 />
{onAlternatives ? (
<button
type="button"
onClick={onAlternatives}
disabled={alternativesDisabled}
aria-pressed={alternativesOpen}
aria-label={t('roadtrip.alt.ask')}
className={`grid h-10 w-10 flex-none place-items-center rounded-full border disabled:opacity-35 ${
alternativesOpen
? 'border-[color:var(--m-inbr)] bg-[color:var(--m-inner)] text-m-ink'
: 'border-transparent bg-[color:var(--m-ic)] text-m-muted'
}`}
>
<Shuffle size={16} strokeWidth={2} aria-hidden="true" />
</button>
) : (
<span />
)}
</div>
)
}
@@ -246,8 +284,9 @@ export function RtDryRow({ intoLegKm, chrome, electric, onSearch, offline, refue
>
<div className="flex min-h-[44px] items-center gap-2.5">
{/* Renamed on this span only, never on the band: the offers below are drawn in
--m-ink too, and they have to stay ink. Not a tap target either, because
nothing in the chain but a destination row reacts to a tap. */}
--m-ink too, and they have to stay ink. Not a tap target either: the lamp at
the end of this line is its one control, and a mascot that reacted too would
be a second one that looks like none. */}
<span
className="pointer-events-none flex flex-none"
style={{ '--m-ink': 'var(--m-st-danger)', '--m-bg': DRY_GROUND } as CSSProperties}
@@ -3,7 +3,9 @@ import { AlertTriangle, MapPin, Navigation } from 'lucide-react'
import { useMPlanDaySwipe } from '../plan/useMPlanDaySwipe'
import { showStopOnMap, useMRoadtrip } from './useMRoadtrip'
import { useMRtCorridor } from './useMRtCorridor'
import { useMRtAlternatives } from './useMRtAlternatives'
import MRtCorridorBar from './MRtCorridorBar'
import MRtAlternativesBar from './MRtAlternativesBar'
import { RtAutoRow, RtDryRow, RtLegRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows'
import { badgeLabel, distanceBadge } from './stageBadges'
import MBadge from '../../../components/MBadge'
@@ -17,7 +19,7 @@ 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'
import { legReroutable, type StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
/**
* The road trip tab: one day of the drive, as a chain or on the map.
@@ -38,6 +40,9 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
const cardRef = useRef<HTMLDivElement>(null)
const rt = useMRoadtrip(planner)
const corridor = useMRtCorridor(planner, shell)
// Called once here and handed down, like `rt`: the chain's leg buttons and the bar over
// the map read one controller, so a leg shows pressed for exactly the picker on the map.
const alts = useMRtAlternatives(planner, shell)
const unit = useSettingsStore(s => s.settings.distance_unit)
const chrome: RowChrome = { t, unit }
@@ -81,10 +86,15 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
// ── 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.
//
// While other ways of driving a leg are on offer, their bar takes the stage bar's slot
// and the search bar steps away. The picker is modal on the map: its lines are the
// question on screen, a corridor search started under it would draw its pins over
// them, and the band it frees at the top is room the leg is framed into.
if (shell.rtView === 'map') {
return (
<div className="pointer-events-none absolute inset-0 z-20">
{searchBar}
{!alts.open && 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
@@ -93,7 +103,9 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
{/* `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} rt={rt} onOpen={openStop} />
{alts.open
? <MRtAlternativesBar planner={planner} alts={alts} />
: <StageBar planner={planner} rt={rt} onOpen={openStop} />}
</div>
</div>
)
@@ -197,7 +209,22 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
<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 === 'leg') {
// Only where the desk rail offers it too (legReroutable), and with the
// card's day id, the one the desk passes: the planner finds the day each
// stop is stored on by itself.
return (
<RtLegRow
key={`l${i}`}
seg={row.seg}
mode={row.mode}
chrome={chrome}
onAlternatives={alts.canAsk && legReroutable(stage, row.index) ? () => alts.ask(stage.dayId, row.index) : undefined}
alternativesOpen={alts.isOpenFor(stage.dayId, row.index)}
alternativesDisabled={!alts.editable}
/>
)
}
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} />
@@ -0,0 +1,178 @@
import { AlertTriangle, Check, Loader2, Shuffle, X } from 'lucide-react'
import MBadge from '../../../components/MBadge'
import MIconBtn from '../../../components/MIconBtn'
import { alternativeSubline, type AlternativeOverlay } from '../../../../components/Roadtrip/alternativeOverlays'
import { useSettingsStore } from '../../../../store/settingsStore'
import { formatDistance } from '../../../../utils/units'
import { badgeLabel } from './stageBadges'
import { RT_ALT_BAR_HEIGHT, type MRtAlternativesController } from './useMRtAlternatives'
import type { TripPlanner } from '../MTripShell'
export interface MRtAlternativesBarProps {
planner: TripPlanner
alts: MRtAlternativesController
}
/**
* The ways of driving one leg, over the map that draws them.
*
* It takes the stage bar's slot rather than a sheet: the answer is the lines on the map,
* and a sheet big enough for three offers would cover the very roads it asks about.
*
* Three rows at fixed heights, because the map lifts its credit and its round controls by
* this bar's height (RT_ALT_BAR_LIFT), and a bar that grew with its text would slide under
* them. So every line truncates or clamps instead of wrapping into another row.
*
* A chip previews and the button takes. The chip lights its road on the map, and so does
* a tap on the line itself; nothing is written until the confirm, which stays off until a
* road other than the one already driven is picked. The chips go quiet while the choice
* is being saved, so a second tap cannot pick another road under a write in flight.
*/
export default function MRtAlternativesBar({ planner, alts }: MRtAlternativesBarProps) {
const { t } = planner
const title = t('roadtrip.alt.title')
return (
<section
aria-label={title}
className="flex flex-col gap-2 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]"
style={{ height: RT_ALT_BAR_HEIGHT }}
>
<div className="flex h-8 flex-none items-center gap-2">
<Shuffle size={14} strokeWidth={2} className="flex-none text-m-muted" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-muted">
{title}
</span>
<MIconBtn variant="neutral" size={32} ariaLabel={t('common.close')} onClick={alts.cancel}>
<X size={15} strokeWidth={2.2} aria-hidden="true" />
</MIconBtn>
</div>
<div className="h-[58px] flex-none">
<AlternativesBody planner={planner} alts={alts} />
</div>
<div className="flex h-11 flex-none items-center gap-2.5">
{/* The desk hides this sentence in a tooltip on the chip. There is no hover here,
so it stands beside the button whose choice it qualifies. */}
{alts.picked?.otherEngine && (
<span className="line-clamp-2 min-w-0 flex-1 font-geist text-[0.65625rem] leading-[1.35] text-m-muted">
{t('roadtrip.alt.otherEngine')}
</span>
)}
<button
type="button"
onClick={alts.confirm}
disabled={!alts.canConfirm}
aria-busy={alts.saving}
// Saving keeps the fill: the button is busy with the choice, not unavailable.
className={`flex h-11 min-w-0 items-center justify-center gap-[7px] rounded-full bg-m-act px-5 text-[0.8125rem] font-semibold text-m-actfg ${
alts.saving ? '' : 'disabled:bg-[color:var(--m-ic)] disabled:text-m-faint'
} ${alts.picked?.otherEngine ? 'flex-none' : 'flex-1'}`}
>
{alts.saving
? <Loader2 size={15} strokeWidth={2.2} className="flex-none animate-spin" aria-hidden="true" />
: <Check size={15} strokeWidth={2.2} className="flex-none" aria-hidden="true" />}
<span className="truncate">{t('common.confirm')}</span>
</button>
</div>
</section>
)
}
/** The middle row: what the router said, or the roads to pick from. */
function AlternativesBody({ planner, alts }: MRtAlternativesBarProps) {
const { t } = planner
const unit = useSettingsStore(s => s.settings.distance_unit)
if (alts.phase === 'loading') {
return (
<p role="status" className="flex h-full items-center gap-2 text-[0.8125rem] font-medium text-m-muted">
<Loader2 size={15} strokeWidth={2.2} className="flex-none animate-spin" aria-hidden="true" />
<span className="truncate">{t('roadtrip.alt.loading')}</span>
</p>
)
}
if (alts.phase === 'failed') {
return (
<p role="status" className="flex h-full items-center gap-2 text-[0.8125rem] font-medium text-m-ink">
<AlertTriangle size={15} strokeWidth={2.2} className="flex-none text-[color:var(--m-st-pending)]" aria-hidden="true" />
<span className="line-clamp-2">{t('roadtrip.alt.failed')}</span>
</p>
)
}
if (alts.phase === 'onlyOne') {
// One road back is genuinely one sensible way to drive the leg, not a failure.
return (
<p role="status" className="flex h-full items-center text-[0.8125rem] font-medium text-m-muted">
<span className="line-clamp-2">{t('roadtrip.alt.onlyOne')}</span>
</p>
)
}
// No phase means no open picker. The tab mounts the bar only while one is open, so this
// is at most the render it closes in, and "only one way" there would describe no leg.
if (alts.phase !== 'choose') return null
// Scrolls sideways rather than wrapping, because the row has a fixed height. The negative
// margin lets a chip slide under the bar's own edge instead of stopping short of it.
return (
<div className="-mx-[14px] flex h-full snap-x gap-2 overflow-x-auto px-[14px]">
{alts.overlays.map(alt => (
<AlternativeChip
key={alt.index}
alt={alt}
picked={alts.picked?.index === alt.index}
disabled={alts.saving}
distance={formatDistance(alt.distance / 1000, unit)}
subline={alternativeSubline(alt, time => t('roadtrip.alt.slower', { time }))}
onPick={() => alts.pick(alt.index)}
/>
))}
</div>
)
}
/**
* One offered road: its colour, its drive time, its length and what sets it apart.
*
* The drive time comes first because it is the exact text of the pill on the map, and it
* is that text rather than the swatch that ties a chip to its line: every offer is drawn
* in the same pale blue. The distance is a badge, a figure on its own the way the stage
* bar sets its figures, and the chip spells its parts out in its name for the reason the
* stage bar does.
*/
function AlternativeChip({ alt, picked, disabled, distance, subline, onPick }: {
alt: AlternativeOverlay
picked: boolean
disabled: boolean
distance: string
subline: string
onPick: () => void
}) {
return (
<button
type="button"
onClick={onPick}
disabled={disabled}
aria-pressed={picked}
aria-label={badgeLabel([alt.label, distance, subline])}
className={`flex h-full min-w-[128px] max-w-[232px] flex-none snap-start items-center gap-2.5 rounded-[16px] border px-3 text-start disabled:opacity-60 ${
picked ? 'border-[color:var(--m-act)] bg-[color:var(--m-inner)]' : 'border-[color:var(--m-cbr)] bg-[color:var(--m-ic)]'
}`}
>
<span
aria-hidden="true"
className="h-3 w-3 flex-none rounded-full"
// theme-lint-disable: the very colour this road is drawn in on the map, see alternativeColors.ts.
style={{ background: alt.color }}
/>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-[0.8125rem] font-semibold tabular-nums text-m-ink">{alt.label}</span>
<MBadge caps={false}>{distance}</MBadge>
</span>
<span className="mt-[3px] block truncate font-geist text-[0.6875rem] text-m-muted">{subline}</span>
</span>
</button>
)
}
@@ -0,0 +1,136 @@
import { useEffect, useTransition } from 'react'
import { alternativesPhase, type AlternativeOverlay, type AlternativesPhase } from '../../../../components/Roadtrip/alternativeOverlays'
import type { LegAlternatives } from '../../../../components/Roadtrip/useRouteAlternatives'
import type { MTripShellApi, TripPlanner } from '../MTripShell'
/**
* The alternatives bar's fixed height, in pixels. MRtAlternativesBar is drawn at exactly
* this, and the map floor below is worked out from it, so the two cannot drift apart.
*/
export const RT_ALT_BAR_HEIGHT = 174
/**
* How far the map floor rises while the alternatives bar stands over the dock, in pixels.
*
* The bar's height plus the same 15px the 76px stage lift adds to the 61px stage bar: the
* gap between the top of the dock and the foot of a bar standing over it. So the floor
* lands on this bar's top edge as it lands on that one's, and the credit and the round
* controls clear this bar the way they clear that one.
*
* A number rather than a CSS length because the map's fit padding is added up from it.
* Kept in this module rather than in the bar's, so the map area reads it without pulling
* a component in.
*/
export const RT_ALT_BAR_LIFT = RT_ALT_BAR_HEIGHT + 15
export interface MRtAlternativesController {
/** The leg being reconsidered and what came back for it. Null while no picker is open. */
open: LegAlternatives | null
/** The very overlays the map draws, so a chip and its line can never disagree. */
overlays: AlternativeOverlay[]
/** Which of its four states the open picker is in. Null while none is open. */
phase: AlternativesPhase | null
/** The road a tap is previewing, lit on the map and waiting for confirm. */
picked: AlternativeOverlay | null
/** May this person reshape the drive at all. Without it no leg offers the question. */
canAsk: boolean
/** False offline: a choice is written as a via, and vias are online only. */
editable: boolean
canConfirm: boolean
/** True while the chosen road is being written. */
saving: boolean
isOpenFor: (dayId: number, legIndex: number) => boolean
ask: (dayId: number, legIndex: number) => void
pick: (index: number) => void
confirm: () => void
cancel: () => void
}
/**
* Other ways of driving a leg, as a phone asks for them and takes one.
*
* Holds no picker state of its own. The road trip tab is mounted in two places, one per
* half of the list and map switch, so it remounts on every toggle, and a picker living
* here would be gone the moment somebody looked back at the chain. Everything stays in
* the planner, where the desk keeps it too: the open leg, the offers, the overlays and
* the road lit on the map. What this adds is what a desk does not need.
*
* A tap previews and a confirm takes. On the desk a pointer hovers to preview and a
* click writes; glass has no hover, so the first touch on a chip or a line would already
* be the write. Here that touch only lights the road (`pick`), and the bar's button
* commits it (`confirm`).
*
* The question is always about the leg on screen. Asking goes to the map, where the
* answer is drawn, and a stage change closes the picker, because offers drawn for a leg
* of another day are lines nobody on this stage asked about.
*/
export function useMRtAlternatives(planner: TripPlanner, shell: MTripShellApi): MRtAlternativesController {
const { open, close } = planner.routeAlternatives
const overlays = planner.alternativeOverlays
const editable = planner.roadtripVias.editable
const [saving, startSaving] = useTransition()
const phase = open ? alternativesPhase(open, overlays) : null
const highlighted = planner.highlightedAlternative
const picked = highlighted == null ? null : overlays.find(o => o.index === highlighted) ?? null
// The road already driven is no choice: taking it writes nothing and only closes the
// picker, which the close button already does without pretending to save.
const canConfirm = !!picked && phase === 'choose' && !open?.routes[picked.index]?.current && editable && !saving
const isOpenFor = (dayId: number, legIndex: number) => open?.dayId === dayId && open.index === legIndex
// The day chips, a swipe and the all days switch all move the stage under an open
// picker. `close` is the planner's stable callback, so this runs when the leg or the
// stage changes and not on every render.
const selectedDayId = planner.selectedDayId
useEffect(() => {
if (open && open.dayId !== selectedDayId) close()
}, [open, selectedDayId, close])
const ask = (dayId: number, legIndex: number) => {
// The planner's own ask toggles a leg that is already open, which is the desk
// button's second click. Here the pressed button on the chain is the way back to the
// answer on the map, so it must not close what it is pointing at.
if (!isOpenFor(dayId, legIndex)) {
// A pick belongs to the leg it was made on. Carried over, it would light a road
// on the next leg by its position in a list that leg never offered.
planner.setHighlightedAlternative(null)
// An open fuel search keeps the camera on its stations (see mapFocusPoints), so the
// leg would be asked about and never framed. The picker is modal on the map anyway.
planner.refuel.close()
// The card's day, as the desk passes it: the planner looks the stops up on that
// card and works out the day each one is stored on by itself.
planner.askRouteAlternatives(dayId, legIndex)
}
if (shell.rtView === 'list') shell.toggleRtView()
}
const pick = (index: number) => planner.setHighlightedAlternative(index)
const confirm = () => {
if (!canConfirm || !picked) return
const index = picked.index
// A transition rather than a flag of our own, so the pending state ends when the
// write does. The planner reports a failure itself and leaves the picker open for
// another go, and closes it once the via is saved.
startSaving(async () => {
await planner.chooseRouteAlternative(index)
})
}
return {
open,
overlays,
phase,
picked,
canAsk: planner.can('day_edit', planner.trip),
editable,
canConfirm,
saving,
isOpenFor,
ask,
pick,
confirm,
cancel: close,
}
}
@@ -1,5 +1,5 @@
import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo'
// FE-TP-ROAD-001 to FE-TP-ROAD-097
// FE-TP-ROAD-001 to FE-TP-ROAD-099
import React from 'react'
import { renderHook, act, waitFor } from '@testing-library/react'
import { TranslationProvider } from '../../i18n/TranslationContext'
@@ -145,7 +145,11 @@ const rt = vi.hoisted(() => {
ask: vi.fn(),
close: vi.fn(),
}
return { vias, routes, corridor, alt, routesArgs }
// Hands out a fresh copy of `alt` on every render when set, the identity the real hook
// had before it was memoised. A stable fixture runs an effect keyed on it once and never
// again, which is exactly how a close gate that fired on every render went unseen.
const altFresh = { current: false }
return { vias, routes, corridor, alt, altFresh, routesArgs }
})
vi.mock('../../components/Roadtrip/useRoadtripVias', () => ({ useRoadtripVias: () => rt.vias }))
@@ -153,7 +157,9 @@ 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/useRouteAlternatives', () => ({
useRouteAlternatives: () => (rt.altFresh.current ? { ...rt.alt } : rt.alt),
}))
vi.mock('../../components/Roadtrip/useFollowTrack', () => ({
useFollowTrack: () => ({ busy: false, dayId: null, run: vi.fn(), attach: vi.fn(), detach: vi.fn() }),
}))
@@ -311,6 +317,7 @@ beforeEach(() => {
rt.corridor.search.spine = []
rt.corridor.insertIndexFor.mockReturnValue(1)
rt.alt.open = null
rt.altFresh.current = false
usePluginStore.setState({ plugins: [], loaded: true })
useBackgroundTasksStore.setState({ tasks: [] })
@@ -2123,4 +2130,41 @@ describe('useTripPlanner road trip: the phone feed', () => {
expect(desk.result.current.stopDraft).toMatchObject({ dayId: 5, editing: { placeId: 102, stopType: 'fuel' } })
expect(desk.result.current.showPlaceForm).toBe(false)
})
it('FE-TP-ROAD-098: a picker open on the drive tab survives the next render, with the mode still off', async () => {
// The blocker the phone picker hit. The close gate read `roadtripActive`, which is false
// on a phone by design, and ran on every render because the hook handed out a new
// object each time: asking set the picker loading, the render after it closed it again.
// The fixture hands out a fresh object per render so the gate is proven on its own,
// whatever identity the hook keeps.
seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] })
sessionStorage.setItem('trip-tab-42', 'roadtrip')
rt.alt.open = { dayId: 5, index: 0, routes: [], loading: true, error: false }
rt.altFresh.current = true
const { result, rerender } = await renderPhone()
rt.alt.close.mockClear()
rerender()
rerender()
expect(rt.alt.close).not.toHaveBeenCalled()
expect(result.current.roadtripActive).toBe(false)
expect(result.current.roadtripMode).toBe(false)
})
it('FE-TP-ROAD-099: leaving the drive tab on a phone closes the picker, though the feed stays on', async () => {
// The phone's counterpart of FE-TP-ROAD-044: the overlay depends only on the picker, so
// a tab that no longer shows the bar must not leave the other roads drawn on its map.
seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] })
sessionStorage.setItem('trip-tab-42', 'roadtrip')
rt.alt.open = { dayId: 5, index: 0, routes: [], loading: false, error: false }
const { result } = await renderPhone()
rt.alt.close.mockClear()
act(() => { result.current.handleTabChange('plan') })
await waitFor(() => expect(rt.alt.close).toHaveBeenCalled())
// The routed legs are kept (FE-TP-ROAD-094), so it is the tab that closed it, not the feed.
expect(result.current.roadtripFeedActive).toBe(true)
})
})
@@ -1328,9 +1328,16 @@ export function useTripPlanner() {
// on the picker — so flipping the mode off left pale blue alternatives, their
// casings and their drive-time pills drawn on an ordinary planner map, with no
// road trip UI left to dismiss them from.
//
// The gate is where the picker can be seen, not the mode. `roadtripActive` is false
// on a phone by design (see `roadtripMode`), so gating on it alone closed a picker
// the phone had just opened, on the very next render. On the phone the picker lives
// on the drive tab, so it stays open there and closes once the tab is left. At desk
// width `isMobile` is false and this is exactly `roadtripActive`, as it always was.
const alternativesShown = roadtripActive || (isMobile && roadtripFeedActive && activeTab === 'roadtrip')
useEffect(() => {
if (!roadtripActive) routeAlternatives.close()
}, [roadtripActive, routeAlternatives])
if (!alternativesShown) routeAlternatives.close()
}, [alternativesShown, routeAlternatives])
/**
* The offered routes as the map draws them: line, colour, and the label that sits on
+14
View File
@@ -236,6 +236,20 @@ export function buildPlanner(overrides: Partial<TripPlanner> = {}): TripPlanner
},
askRefuel: vi.fn(),
acceptRefuel: vi.fn(),
// Other ways of driving a leg, with no picker open. The road trip tab and the map area
// read the picker on every render, so the closed shape belongs in the base.
routeAlternatives: { open: null, ask: vi.fn(), close: vi.fn() },
askRouteAlternatives: vi.fn(),
chooseRouteAlternative: vi.fn(async () => undefined),
alternativeOverlays: [],
alternativeFocusPoints: [],
highlightedAlternative: null,
setHighlightedAlternative: vi.fn(),
// The drive's vias, online and empty. `editable` is what the leg buttons read.
roadtripVias: {
byDay: {}, trackByDay: {}, stale: false, editable: true,
add: vi.fn(), addMany: vi.fn(), move: vi.fn(), remove: vi.fn(), reanchor: vi.fn(),
},
mapFocusPoints: [],
focusRoadtripPoint: vi.fn(),
handlePoiClick: vi.fn(),
+117 -2
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, render, screen } from '../../../helpers/render'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { buildPlace } from '../../../helpers/factories'
@@ -10,8 +10,11 @@ import { useSettingsStore } from '../../../../src/store/settingsStore'
import { useTripStore } from '../../../../src/store/tripStore'
import { seedStore } from '../../../helpers/store'
import type { Place } from '../../../../src/types'
import type { AlternativeOverlay } from '../../../../src/components/Roadtrip/alternativeOverlays'
import type { LegAlternatives } from '../../../../src/components/Roadtrip/useRouteAlternatives'
import { RT_ALT_BAR_LIFT } from '../../../../src/mobile/screens/trip/roadtrip/useMRtAlternatives'
// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-027
// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-032
//
// The stage's pins come out of the trip store rather than the planner's map list, so the
// stage fixtures seed the store and leave `mapPlaces` to stand for what the plan tab shows.
@@ -132,6 +135,37 @@ const tapPin = (placeId?: number) => (mocks.props.onMarkerClick as (id?: number)
/** What the area handed the renderer that a fresh copy would make it draw again. */
const DRAWN = ['places', 'route', 'routeColors', 'accessLines', 'focusPoints'] as const
/** A picker open on leg 0 of day 3, with or without roads back yet. */
function withPicker(overlays: AlternativeOverlay[], over: Partial<TripPlanner> = {}): Partial<TripPlanner> {
const base = buildPlanner()
const open: LegAlternatives = { dayId: 3, index: 0, routes: [], loading: overlays.length === 0, error: false }
return {
routeAlternatives: { ...base.routeAlternatives, open },
alternativeOverlays: overlays,
...over,
} as Partial<TripPlanner>
}
/** Two offered roads, as far as the map area reads them. */
const OFFERS = [
{ index: 0, coordinates: [[53.55, 9.99], [53.87, 10.69]], color: 'blue', label: '1 h', note: 'Current' },
{ index: 1, coordinates: [[53.55, 9.99], [53.7, 10.2], [53.87, 10.69]], color: 'pale', label: '1 h 10 min', note: '' },
] as unknown as AlternativeOverlay[]
/** The probe the area reads the safe area off, answering like a phone with a notch. */
function notchedPhone(top: string, bottom: string) {
const real = window.getComputedStyle.bind(window)
return vi.spyOn(window, 'getComputedStyle').mockImplementation((el: Element, pseudo?: string | null) => (
el.className.toString().includes('safe-area-inset-top')
? ({ paddingTop: top, paddingBottom: bottom } as CSSStyleDeclaration)
: real(el, pseudo)
))
}
afterEach(() => {
vi.restoreAllMocks()
})
beforeEach(() => {
mocks.glMap = COMPASS
mocks.prefs = {}
@@ -510,6 +544,87 @@ describe('MMapArea', () => {
expect(stageLayer.style.getPropertyValue('--m-stage-lift')).toBe('76px')
})
it('FE-MOB-MAPAREA-028: on the stage the map draws the offered roads, and a tap on one only lights it', () => {
const { planner } = renderArea(
{ trTab: 'roadtrip', rtView: 'map' },
withPicker(OFFERS, { highlightedAlternative: 1 }),
)
expect(mocks.props.alternativeRoutes).toBe(planner.alternativeOverlays)
expect(mocks.props.activeAlternative).toBe(1)
;(mocks.props.onChooseAlternative as (index: number) => void)(0)
// Glass has no hover to preview with, so the desk's click that takes a road would save a
// via on the first touch. The bar's confirm is what takes it here.
expect(planner.setHighlightedAlternative).toHaveBeenCalledWith(0)
expect(planner.chooseRouteAlternative).not.toHaveBeenCalled()
// A touch fires emulated enter and leave events, and the leave would put the road out.
expect(mocks.props.onHighlightAlternative).toBeUndefined()
})
it('FE-MOB-MAPAREA-029: the plan tab draws no offered roads and frames nothing for them', () => {
renderArea({ trTab: 'plan' }, withPicker(OFFERS, { highlightedAlternative: 1 }))
expect(mocks.props.alternativeRoutes).toBeUndefined()
expect(mocks.props.activeAlternative).toBeUndefined()
expect(mocks.props.onChooseAlternative).toBeUndefined()
expect(mocks.props.fitPadding).toBeUndefined()
})
it('FE-MOB-MAPAREA-030: with the picker open the floor clears its taller bar, and the compass is still the one inline --bottom-nav-h', () => {
const { container, unmount } = renderArea({ trTab: 'roadtrip', mapFront: true }, withPicker([]))
const layer = container.firstElementChild as HTMLElement
// 174px of bar and the stage bar's 15px gap, as soon as the question is asked.
expect(layer.style.getPropertyValue('--m-stage-lift')).toBe(`${RT_ALT_BAR_LIFT}px`)
expect(layer.style.getPropertyValue('--m-stage-lift')).toBe('189px')
expect(layer.getAttribute('style')).not.toContain('--bottom-nav-h')
expect(container.querySelectorAll('[style*="--bottom-nav-h"]')).toHaveLength(1)
expect(compassBand(container)).not.toBeNull()
unmount()
// Behind the chain the bar is not on screen, so nothing lifts.
const behind = renderArea({ trTab: 'roadtrip', mapFront: false }, withPicker([]))
expect((behind.container.firstElementChild as HTMLElement).style.getPropertyValue('--m-stage-lift')).toBe('0px')
})
it('FE-MOB-MAPAREA-031: the offered roads are framed between the day chips and the bar, once they are there', () => {
const shell = stageShell()
const asking = buildPlanner(withPicker([]))
const { rerender } = render(<MMapArea planner={asking} shell={shell} />)
// Nothing to frame while the router is asked: a padding handed over now would refit
// the stage into a smaller frame just before the answer moves the camera again.
expect(mocks.props.fitPadding).toBeUndefined()
const answered = { ...asking, ...withPicker(OFFERS) } as TripPlanner
rerender(<MMapArea planner={answered} shell={shell} />)
// Top: 12 of --m-safe-top, the rail's 50 offset and 42 height, a 12 gap. Bottom: the
// dock's 74, the bar's lift and the 38 credit row. The sides keep the phone's 20.
expect(mocks.props.fitPadding).toEqual({ top: 116, right: 20, bottom: 74 + RT_ALT_BAR_LIFT + 38, left: 20 })
const first = mocks.props.fitPadding
// Kept between renders, for the reason the stage data is.
rerender(<MMapArea planner={{ ...answered }} shell={shell} />)
expect(mocks.props.fitPadding).toBe(first)
// Closed, the stage frames itself with the engines' own margins again.
rerender(<MMapArea planner={{ ...answered, ...withPicker([]), routeAlternatives: buildPlanner().routeAlternatives }} shell={shell} />)
expect(mocks.props.fitPadding).toBeUndefined()
})
it('FE-MOB-MAPAREA-032: on a phone with a notch the frame moves in by the safe area, and follows a turn of the phone', () => {
const style = notchedPhone('59px', '34px')
renderArea({ trTab: 'roadtrip', rtView: 'map' }, withPicker(OFFERS))
expect(mocks.props.fitPadding).toEqual({ top: 59 + 116, right: 20, bottom: 34 + 301, left: 20 })
// Turned sideways the insets go to the edges, and the frame lets go of them.
style.mockRestore()
notchedPhone('0px', '21px')
act(() => { window.dispatchEvent(new Event('resize')) })
expect(mocks.props.fitPadding).toEqual({ top: 116, right: 20, bottom: 21 + 301, left: 20 })
})
it('FE-MOB-MAPAREA-027: the layer sets only the lift inline, so the compass stays the one inline --bottom-nav-h', () => {
for (const shellOver of [{ trTab: 'plan' }, { trTab: 'roadtrip', mapFront: true }] as Partial<MTripShellApi>[]) {
const { container, unmount } = renderArea(shellOver)
@@ -9,7 +9,7 @@ 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-037
// FE-MOB-RTROW-001 to FE-MOB-RTROW-040
// Same echo strategy as tests/helpers/mobileTrip: assertions stay on keys, not copy.
const t: TranslationFn = (key, params) =>
@@ -178,7 +178,7 @@ describe('RtStopRow', () => {
})
describe('RtLegRow', () => {
it('FE-MOB-RTROW-014: is not a control, no button, no role, nothing focusable', () => {
it('FE-MOB-RTROW-014: without a way to ask for other roads the leg 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()
@@ -212,6 +212,53 @@ describe('RtLegRow', () => {
const plugin = render(<RtLegRow seg={SEG} mode="plugin:rail" chrome={chrome} />)
expect(plugin.container.querySelector('.lucide-zap')).not.toBeNull()
})
it('FE-MOB-RTROW-038: other ways are one round button beside the pill, and the pill stays text', () => {
const onAlternatives = vi.fn()
const { container } = render(<RtLegRow seg={SEG} mode="driving" chrome={chrome} onAlternatives={onAlternatives} />)
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(1)
const ask = screen.getByRole('button', { name: 'roadtrip.alt.ask' })
expect(ask.querySelector('.lucide-shuffle')).not.toBeNull()
// A separate 40px target: a tap that stops a scroll on the pill asks the router nothing.
expect(ask.className).toContain('h-10')
expect(ask.className).toContain('w-10')
const drive = screen.getByText('roadtrip.leg.driveText:210 km,2 h 40 min')
expect(ask.contains(drive)).toBe(false)
expect(drive.closest('[role]')).toBeNull()
fireEvent.click(drive)
expect(onAlternatives).not.toHaveBeenCalled()
fireEvent.click(ask)
expect(onAlternatives).toHaveBeenCalledTimes(1)
expect(container.querySelectorAll('[tabindex]')).toHaveLength(0)
})
it('FE-MOB-RTROW-039: shows pressed while its leg is open, in full ink rather than a filled button', () => {
const closed = render(<RtLegRow seg={SEG} mode="driving" chrome={chrome} onAlternatives={vi.fn()} />)
const idle = screen.getByRole('button', { name: 'roadtrip.alt.ask' })
expect(idle).toHaveAttribute('aria-pressed', 'false')
expect(idle.className).toContain('text-m-muted')
closed.unmount()
render(<RtLegRow seg={SEG} mode="driving" chrome={chrome} onAlternatives={vi.fn()} alternativesOpen />)
const open = screen.getByRole('button', { name: 'roadtrip.alt.ask' })
expect(open).toHaveAttribute('aria-pressed', 'true')
expect(open.className).toContain('text-m-ink')
// A filled chip in a column of quiet rows reads as a button pressed and stuck.
expect(open.className).not.toContain('bg-m-act')
})
it('FE-MOB-RTROW-040: offline the button keeps its place but does nothing', () => {
const onAlternatives = vi.fn()
render(<RtLegRow seg={SEG} mode="driving" chrome={chrome} onAlternatives={onAlternatives} alternativesDisabled />)
const ask = screen.getByRole('button', { name: 'roadtrip.alt.ask' })
expect(ask).toBeDisabled()
fireEvent.click(ask)
expect(onAlternatives).not.toHaveBeenCalled()
})
})
describe('RtDryRow', () => {
@@ -7,8 +7,9 @@ import MRoadtripTab from '../../../../src/mobile/screens/trip/roadtrip/MRoadtrip
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'
import type { LegAlternatives } from '../../../../src/components/Roadtrip/useRouteAlternatives'
// FE-MOB-RTTAB-001 to FE-MOB-RTTAB-044
// FE-MOB-RTTAB-001 to FE-MOB-RTTAB-050
//
// The stage bar pictures the place its day ends at. It reads that place out of the trip
// store rather than the planner, the unfiltered list, so the picture tests seed the store.
@@ -745,6 +746,122 @@ describe('MRoadtripTab', () => {
})
})
describe('other ways of driving a leg', () => {
const askButtons = () => screen.queryAllByRole('button', { name: 'roadtrip.alt.ask' })
/** The planner with a picker open on one leg, still asking the router. */
function withPicker(open: Partial<LegAlternatives>, over: Partial<TripPlanner> = {}): TripPlanner {
const base = buildPlanner()
return planner({
routeAlternatives: { ...base.routeAlternatives, open: { dayId: 2, index: 0, routes: [], loading: true, error: false, ...open } },
...over,
})
}
it('FE-MOB-RTTAB-045: offers the question on the legs the desk rail offers it on, and only to an editor', () => {
const full = renderTab()
// Two routed legs between real stops. The third runs into the automatic day end,
// which is the shell's own marker and not a road anyone can reshape.
expect(askButtons()).toHaveLength(2)
const kyotoLeg = screen.getByText('roadtrip.leg.driveText:210 km,2 h 40 min').closest('.grid') as HTMLElement
expect(within(kyotoLeg).getByRole('button', { name: 'roadtrip.alt.ask' })).toBeInTheDocument()
full.unmount()
// Routed or not, that last leg stays a line of text: the fixture leaves it unrouted
// too, so a routed one pins that the day end, not the missing route, is the reason.
const intoNight = renderTab(planner({
roadtripRoutes: routes({ days: [stage({ legs: [seg('62 km', '1 h'), seg('210 km', '2 h 40 min'), seg('12 km', '10 min')] })] }),
}))
expect(askButtons()).toHaveLength(2)
const nightLeg = screen.getByText('roadtrip.leg.driveText:12 km,10 min').closest('.grid') as HTMLElement
expect(within(nightLeg).queryByRole('button')).toBeNull()
intoNight.unmount()
// A leg the routing has not answered for has nothing to weigh an offer against.
const unrouted = renderTab(planner({ roadtripRoutes: routes({ days: [stage({ legs: [seg('62 km', '1 h'), undefined, undefined] })] }) }))
expect(askButtons()).toHaveLength(1)
// Both unanswered legs are drawn, only neither of them asks.
expect(screen.getAllByText('roadtrip.leg.pending')).toHaveLength(2)
unrouted.unmount()
renderTab(planner({ can: vi.fn(() => false) }))
expect(askButtons()).toHaveLength(0)
})
it('FE-MOB-RTTAB-046: asks with the card\'s day, clears the old pick and the fuel search, and goes to the map', () => {
const p = planner()
const { shell } = renderTab(p)
fireEvent.click(askButtons()[0])
expect(p.setHighlightedAlternative).toHaveBeenCalledWith(null)
expect(p.refuel.close).toHaveBeenCalledTimes(1)
// Day 2, the card: the leg's first stop is stored on day 1, and the planner works
// that out from the card itself, the way the desk rail asks.
expect(p.askRouteAlternatives).toHaveBeenCalledWith(2, 0)
expect(p.askRouteAlternatives).not.toHaveBeenCalledWith(1, expect.anything())
expect(shell.toggleRtView).toHaveBeenCalledTimes(1)
})
it('FE-MOB-RTTAB-047: the leg with the picker open shows pressed, and tapping it goes back to the answer without asking again', () => {
const p = withPicker({ index: 0 })
const { shell } = renderTab(p)
const [open, other] = askButtons()
expect(open).toHaveAttribute('aria-pressed', 'true')
expect(other).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(open)
// The planner toggles an open leg shut, and the traveller came back for its answer.
expect(p.askRouteAlternatives).not.toHaveBeenCalled()
expect(p.routeAlternatives.close).not.toHaveBeenCalled()
expect(shell.toggleRtView).toHaveBeenCalledTimes(1)
})
it('FE-MOB-RTTAB-048: on the map the picker takes the stage bar\'s slot and the search bar steps away', () => {
const shell = buildShell({ rtView: 'map' })
const open = renderTab(withPicker({}), shell)
const bar = screen.getByRole('region', { name: 'roadtrip.alt.title' })
expect(within(bar).getByText('roadtrip.alt.loading')).toBeInTheDocument()
// In the very wrapper the stage bar sits in, just above the dock.
expect((bar.parentElement as HTMLElement).className).toContain('bottom-[calc(var(--bottom-nav-h,84px)+4px)]')
expect(screen.queryByRole('button', { name: /Kyoto Station/ })).toBeNull()
expect(screen.queryByText('roadtrip.poi.title')).toBeNull()
open.unmount()
renderTab(planner(), shell)
expect(screen.queryByRole('region', { name: 'roadtrip.alt.title' })).toBeNull()
expect(screen.getByRole('button', { name: /Kyoto Station/ })).toBeInTheDocument()
expect(screen.getByText('roadtrip.poi.title')).toBeInTheDocument()
})
it('FE-MOB-RTTAB-049: offline the leg buttons stay where they are and do nothing', () => {
const base = buildPlanner()
const p = planner({ roadtripVias: { ...base.roadtripVias, editable: false } })
renderTab(p)
expect(askButtons()).toHaveLength(2)
for (const button of askButtons()) expect(button).toBeDisabled()
fireEvent.click(askButtons()[0])
expect(p.askRouteAlternatives).not.toHaveBeenCalled()
})
it('FE-MOB-RTTAB-050: a picker left open on another stage is closed, and one on this stage survives the list', () => {
const elsewhere = withPicker({ dayId: 1 })
const first = renderTab(elsewhere)
expect(elsewhere.routeAlternatives.close).toHaveBeenCalledTimes(1)
first.unmount()
// The chain is a look back at the stage, not a way out of the question.
const here = withPicker({ dayId: 2, index: 1 })
renderTab(here)
expect(here.routeAlternatives.close).not.toHaveBeenCalled()
expect(askButtons()[1]).toHaveAttribute('aria-pressed', 'true')
})
})
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()
@@ -0,0 +1,204 @@
import { describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen, within } from '../../../helpers/render'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import MRtAlternativesBar from '../../../../src/mobile/screens/trip/roadtrip/MRtAlternativesBar'
import { RT_ALT_BAR_HEIGHT, useMRtAlternatives } from '../../../../src/mobile/screens/trip/roadtrip/useMRtAlternatives'
import { buildAlternativeOverlays } from '../../../../src/components/Roadtrip/alternativeOverlays'
import type { LegAlternatives, OfferedRoute } from '../../../../src/components/Roadtrip/useRouteAlternatives'
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
// FE-MOB-RTALT-001 to FE-MOB-RTALT-011
//
// Rendered through the real controller hook, so a tap is checked all the way to what the
// planner is asked to do, and the pending state is the transition's own.
/**
* Three ways of driving one leg: the road already driven, a slower offer, and a road
* without the motorway that the avoidance router priced.
*/
const ROUTES: OfferedRoute[] = [
{ coordinates: [[35.7, 139.8], [35.4, 138.6], [34.98, 135.75]], distance: 210_000, duration: 9_600, divergence: null, current: true },
{ coordinates: [[35.7, 139.8], [35.9, 138.2], [34.98, 135.75]], distance: 198_000, duration: 10_320, divergence: { lat: 35.9, lng: 138.2 } },
{ coordinates: [[35.7, 139.8], [35.2, 138.9], [34.98, 135.75]], distance: 236_000, duration: 11_400, divergence: { lat: 35.2, lng: 138.9 }, avoids: 'motorway', engine: 'valhalla' },
]
function overlays(routes: OfferedRoute[] = ROUTES) {
return buildAlternativeOverlays(routes, {
fastest: 'Fastest', current: 'Current', noMotorway: 'No motorway', noToll: 'No tolls', noFerry: 'No ferry',
})
}
function planner(open: Partial<LegAlternatives> | null, over: Partial<TripPlanner> = {}): TripPlanner {
const base = buildPlanner()
const leg = open ? { dayId: 2, index: 0, routes: ROUTES, loading: false, error: false, ...open } : null
return buildPlanner({
selectedDayId: 2,
routeAlternatives: { ...base.routeAlternatives, open: leg },
alternativeOverlays: leg ? overlays(leg.routes) : [],
...over,
} as Partial<TripPlanner>)
}
function Harness({ planner: p }: { planner: TripPlanner }) {
const alts = useMRtAlternatives(p, buildShell({ rtView: 'map' }))
return <MRtAlternativesBar planner={p} alts={alts} />
}
function renderBar(p: TripPlanner) {
return { planner: p, ...render(<Harness planner={p} />) }
}
const confirmButton = () => screen.getByRole('button', { name: 'common.confirm' })
const chips = () => screen.queryAllByRole('button', { pressed: false }).concat(screen.queryAllByRole('button', { pressed: true }))
describe('MRtAlternativesBar', () => {
it('FE-MOB-RTALT-001: while the router is asked it says so, offers nothing and confirms nothing', () => {
renderBar(planner({ loading: true, routes: [] }))
const bar = screen.getByRole('region', { name: 'roadtrip.alt.title' })
expect(within(bar).getByRole('status')).toHaveTextContent('roadtrip.alt.loading')
expect(chips()).toHaveLength(0)
expect(confirmButton()).toBeDisabled()
})
it('FE-MOB-RTALT-002: a router that did not answer is a failure, never a leg with one way', () => {
renderBar(planner({ error: true, routes: [] }))
expect(screen.getByRole('status')).toHaveTextContent('roadtrip.alt.failed')
expect(screen.queryByText('roadtrip.alt.onlyOne')).toBeNull()
expect(chips()).toHaveLength(0)
expect(confirmButton()).toBeDisabled()
})
it('FE-MOB-RTALT-003: one road back is the only sensible way, with nothing to pick', () => {
renderBar(planner({ routes: [ROUTES[0]] }))
expect(screen.getByRole('status')).toHaveTextContent('roadtrip.alt.onlyOne')
expect(chips()).toHaveLength(0)
expect(confirmButton()).toBeDisabled()
})
it('FE-MOB-RTALT-004: one chip per road, its drive time first, then its length and what sets it apart', () => {
const p = planner({})
renderBar(p)
const offered = chips()
expect(offered).toHaveLength(3)
const [driven, slower, noMotorway] = p.alternativeOverlays
const second = screen.getByRole('button', { name: `${slower.label}, 198 km, roadtrip.alt.slower:12 min` })
// The time pill's text leads, because that text is what ties the chip to its line.
expect(second.textContent).toBe(`${slower.label}198 kmroadtrip.alt.slower:12 min`)
// The length is a figure in a badge, in its unit's own case.
const badge = within(second).getByText('198 km')
expect(badge.className).toContain('rounded-full')
expect(badge.className).not.toContain('uppercase')
// The swatch is painted in the very colour the road is drawn in.
const swatch = second.querySelector('[aria-hidden="true"]') as HTMLElement
expect(swatch).toHaveStyle({ background: slower.color })
// Its own note wins over the difference: the road driven, the road without the motorway.
expect(screen.getByRole('button', { name: `${driven.label}, 210 km, Current` })).toHaveAttribute('aria-pressed', 'false')
expect(screen.getByRole('button', { name: `${noMotorway.label}, 236 km, No motorway` })).toBeInTheDocument()
})
it('FE-MOB-RTALT-005: a tap on a chip lights its road and writes nothing', () => {
const p = planner({})
renderBar(p)
fireEvent.click(chips()[1])
expect(p.setHighlightedAlternative).toHaveBeenCalledWith(1)
expect(p.chooseRouteAlternative).not.toHaveBeenCalled()
})
it('FE-MOB-RTALT-006: confirm waits for a road other than the one driven, then takes it once', async () => {
const none = renderBar(planner({}))
expect(confirmButton()).toBeDisabled()
none.unmount()
const driven = renderBar(planner({}, { highlightedAlternative: 0 }))
expect(screen.getByRole('button', { pressed: true })).toHaveAccessibleName(/Current/)
expect(confirmButton()).toBeDisabled()
driven.unmount()
const { planner: p } = renderBar(planner({}, { highlightedAlternative: 1 }))
expect(confirmButton()).toBeEnabled()
// Awaited so the write the tap starts has landed before the test ends.
await act(async () => { fireEvent.click(confirmButton()) })
expect(p.chooseRouteAlternative).toHaveBeenCalledTimes(1)
expect(p.chooseRouteAlternative).toHaveBeenCalledWith(1)
})
it('FE-MOB-RTALT-007: while the choice is saved nothing can be tapped twice, and it all comes back after', async () => {
let land: () => void = () => {}
const p = planner({}, {
highlightedAlternative: 1,
chooseRouteAlternative: vi.fn(() => new Promise<void>(resolve => { land = resolve })),
})
renderBar(p)
fireEvent.click(confirmButton())
const busy = confirmButton()
expect(busy).toBeDisabled()
expect(busy).toHaveAttribute('aria-busy', 'true')
expect(busy.querySelector('.animate-spin')).not.toBeNull()
// Still filled: the button is busy with the choice, not unavailable.
expect(busy.className).not.toContain('disabled:bg-')
for (const chip of chips()) expect(chip).toBeDisabled()
fireEvent.click(busy)
expect(p.chooseRouteAlternative).toHaveBeenCalledTimes(1)
await act(async () => { land() })
expect(confirmButton()).toBeEnabled()
expect(confirmButton().querySelector('.animate-spin')).toBeNull()
for (const chip of chips()) expect(chip).toBeEnabled()
})
it('FE-MOB-RTALT-008: the close button closes the picker', () => {
const p = planner({})
renderBar(p)
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
expect(p.routeAlternatives.close).toHaveBeenCalledTimes(1)
})
it('FE-MOB-RTALT-009: a road timed by the other router says so beside the button, and only then', () => {
const other = renderBar(planner({}, { highlightedAlternative: 2 }))
const note = screen.getByText('roadtrip.alt.otherEngine')
expect(note.className).toContain('line-clamp-2')
// Beside the button it qualifies, which gives up its full width for the sentence.
expect(note.parentElement).toBe(confirmButton().parentElement)
expect(confirmButton().className).toContain('flex-none')
other.unmount()
renderBar(planner({}, { highlightedAlternative: 1 }))
expect(screen.queryByText('roadtrip.alt.otherEngine')).toBeNull()
expect(confirmButton().className).toContain('flex-1')
})
it('FE-MOB-RTALT-010: offline a valid pick still cannot be confirmed', () => {
const base = buildPlanner()
renderBar(planner({}, { highlightedAlternative: 1, roadtripVias: { ...base.roadtripVias, editable: false } }))
expect(screen.getByRole('button', { pressed: true })).toBeInTheDocument()
expect(confirmButton()).toBeDisabled()
})
it('FE-MOB-RTALT-011: the bar stands at the height the map lifts by, and says nothing once closed', () => {
const open = renderBar(planner({}))
const bar = screen.getByRole('region', { name: 'roadtrip.alt.title' })
expect(bar).toHaveStyle({ height: `${RT_ALT_BAR_HEIGHT}px` })
// A fixed height cannot take a second line, so the title truncates rather than wraps.
expect(within(bar).getByText('roadtrip.alt.title').className).toContain('truncate')
open.unmount()
// The render the picker closes in: no leg, so no sentence about one.
renderBar(planner(null))
expect(screen.queryByRole('status')).toBeNull()
expect(chips()).toHaveLength(0)
})
})
@@ -0,0 +1,203 @@
import { describe, expect, it, vi } from 'vitest'
import { act, renderHook } from '@testing-library/react'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import {
RT_ALT_BAR_HEIGHT, RT_ALT_BAR_LIFT, useMRtAlternatives,
} from '../../../../src/mobile/screens/trip/roadtrip/useMRtAlternatives'
import { buildAlternativeOverlays } from '../../../../src/components/Roadtrip/alternativeOverlays'
import type { LegAlternatives, OfferedRoute } from '../../../../src/components/Roadtrip/useRouteAlternatives'
import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
// FE-MOB-RTALTH-001 to FE-MOB-RTALTH-009
//
// The hook holds no picker state, so every case is a planner shape and what the hook
// reads out of it or asks of it.
/** Three ways of driving one leg: the road already driven first, then two offers. */
const ROUTES: OfferedRoute[] = [
{ coordinates: [[35.7, 139.8], [35.4, 138.6], [34.98, 135.75]], distance: 210_000, duration: 9_600, divergence: null, current: true },
{ coordinates: [[35.7, 139.8], [35.9, 138.2], [34.98, 135.75]], distance: 198_000, duration: 10_320, divergence: { lat: 35.9, lng: 138.2 } },
{ coordinates: [[35.7, 139.8], [35.2, 138.9], [34.98, 135.75]], distance: 236_000, duration: 11_400, divergence: { lat: 35.2, lng: 138.9 }, avoids: 'motorway' },
]
const OVERLAYS = buildAlternativeOverlays(ROUTES, {
fastest: 'Fastest', current: 'Current', noMotorway: 'No motorway', noToll: 'No tolls', noFerry: 'No ferry',
})
function leg(over: Partial<LegAlternatives> = {}): LegAlternatives {
return { dayId: 2, index: 1, routes: ROUTES, loading: false, error: false, ...over }
}
/** A planner with the picker open on leg 1 of day 2, answered with three roads. */
function planner(over: Partial<TripPlanner> = {}, open: LegAlternatives | null = leg()): TripPlanner {
const base = buildPlanner()
return buildPlanner({
selectedDayId: 2,
routeAlternatives: { ...base.routeAlternatives, open },
alternativeOverlays: open ? OVERLAYS : [],
...over,
} as Partial<TripPlanner>)
}
function setup(p: TripPlanner = planner(), shell: MTripShellApi = buildShell()) {
const view = renderHook(({ pl, sh }) => useMRtAlternatives(pl, sh), { initialProps: { pl: p, sh: shell } })
return { ...view, planner: p, shell }
}
describe('useMRtAlternatives', () => {
it('FE-MOB-RTALTH-001: a leg is open only when both its day and its position match', () => {
const { result } = setup()
expect(result.current.isOpenFor(2, 1)).toBe(true)
// The same position on another card, and another leg of the same card.
expect(result.current.isOpenFor(1, 1)).toBe(false)
expect(result.current.isOpenFor(2, 0)).toBe(false)
const closed = setup(planner({}, null))
expect(closed.result.current.isOpenFor(2, 1)).toBe(false)
})
it('FE-MOB-RTALTH-002: reads the phase and the picked road off the planner, and has neither while closed', () => {
const closed = setup(planner({}, null))
expect(closed.result.current.open).toBeNull()
expect(closed.result.current.phase).toBeNull()
expect(closed.result.current.picked).toBeNull()
const asking = setup(planner({ alternativeOverlays: [] }, leg({ loading: true, routes: [] })))
expect(asking.result.current.phase).toBe('loading')
const picked = setup(planner({ highlightedAlternative: 2 }))
expect(picked.result.current.phase).toBe('choose')
expect(picked.result.current.overlays).toBe(OVERLAYS)
expect(picked.result.current.picked).toBe(OVERLAYS[2])
// A pick the overlays do not hold is no pick, rather than a road nobody is shown.
const stray = setup(planner({ highlightedAlternative: 7 }))
expect(stray.result.current.picked).toBeNull()
})
it('FE-MOB-RTALTH-003: confirm needs a road other than the one driven, a connection and a choice to make', () => {
expect(setup(planner()).result.current.canConfirm).toBe(false)
// The road already driven writes nothing, so there is nothing to confirm.
expect(setup(planner({ highlightedAlternative: 0 })).result.current.canConfirm).toBe(false)
expect(setup(planner({ highlightedAlternative: 1 })).result.current.canConfirm).toBe(true)
const base = buildPlanner()
const offline = setup(planner({ highlightedAlternative: 1, roadtripVias: { ...base.roadtripVias, editable: false } }))
expect(offline.result.current.editable).toBe(false)
expect(offline.result.current.canConfirm).toBe(false)
// A pick left over from the answer before is not a choice while the router is asked again.
const asking = setup(planner({ highlightedAlternative: 1 }, leg({ loading: true })))
expect(asking.result.current.canConfirm).toBe(false)
})
it('FE-MOB-RTALTH-004: asking a new leg clears the old pick and the fuel search, then goes to the map', () => {
const p = planner({}, null)
const shell = buildShell({ rtView: 'list' })
const { result } = setup(p, shell)
act(() => { result.current.ask(2, 0) })
expect(p.setHighlightedAlternative).toHaveBeenCalledWith(null)
expect(p.refuel.close).toHaveBeenCalledTimes(1)
expect(p.askRouteAlternatives).toHaveBeenCalledWith(2, 0)
// The pick is cleared before the new question goes out.
const cleared = vi.mocked(p.setHighlightedAlternative).mock.invocationCallOrder[0]
expect(cleared).toBeLessThan(vi.mocked(p.askRouteAlternatives).mock.invocationCallOrder[0])
expect(shell.toggleRtView).toHaveBeenCalledTimes(1)
// Already on the map, the switch would take the traveller back to the chain.
const onMap = buildShell({ rtView: 'map' })
const second = setup(planner({}, null), onMap)
act(() => { second.result.current.ask(2, 0) })
expect(second.planner.askRouteAlternatives).toHaveBeenCalledWith(2, 0)
expect(onMap.toggleRtView).not.toHaveBeenCalled()
})
it('FE-MOB-RTALTH-005: asking the leg already open only goes back to its answer', () => {
const p = planner({ highlightedAlternative: 1 })
const shell = buildShell({ rtView: 'list' })
const { result } = setup(p, shell)
act(() => { result.current.ask(2, 1) })
// The planner's ask toggles an open leg shut, and the pick on it is still wanted.
expect(p.askRouteAlternatives).not.toHaveBeenCalled()
expect(p.setHighlightedAlternative).not.toHaveBeenCalled()
expect(p.refuel.close).not.toHaveBeenCalled()
expect(shell.toggleRtView).toHaveBeenCalledTimes(1)
})
it('FE-MOB-RTALTH-006: a stage change closes the picker, and nothing else does', () => {
const p = planner()
const { rerender, shell } = setup(p)
expect(p.routeAlternatives.close).not.toHaveBeenCalled()
// Re-rendered on the same stage, as the shell does on every store write.
rerender({ pl: { ...p }, sh: shell })
expect(p.routeAlternatives.close).not.toHaveBeenCalled()
rerender({ pl: { ...p, selectedDayId: 3 }, sh: shell })
expect(p.routeAlternatives.close).toHaveBeenCalledTimes(1)
// The all days view has no stage at all, so no leg of one either.
const allDays = planner({ selectedDayId: null })
setup(allDays)
expect(allDays.routeAlternatives.close).toHaveBeenCalledTimes(1)
// With nothing open there is nothing to close.
const closed = planner({ selectedDayId: 3 }, null)
setup(closed)
expect(closed.routeAlternatives.close).not.toHaveBeenCalled()
})
it('FE-MOB-RTALTH-007: confirm writes the picked road once and is pending until the write lands', async () => {
let land: () => void = () => {}
const p = planner({
highlightedAlternative: 2,
chooseRouteAlternative: vi.fn(() => new Promise<void>(resolve => { land = resolve })),
})
const { result } = setup(p)
act(() => { result.current.confirm() })
expect(p.chooseRouteAlternative).toHaveBeenCalledWith(2)
expect(result.current.saving).toBe(true)
expect(result.current.canConfirm).toBe(false)
// A second tap while the first is in flight writes nothing more.
act(() => { result.current.confirm() })
expect(p.chooseRouteAlternative).toHaveBeenCalledTimes(1)
await act(async () => { land() })
expect(result.current.saving).toBe(false)
expect(result.current.canConfirm).toBe(true)
})
it('FE-MOB-RTALTH-008: a pick only lights a road, cancel closes, and the question needs day edit rights', () => {
const p = planner()
const { result } = setup(p)
act(() => { result.current.pick(1) })
expect(p.setHighlightedAlternative).toHaveBeenCalledWith(1)
expect(p.chooseRouteAlternative).not.toHaveBeenCalled()
act(() => { result.current.confirm() })
// Nothing picked in this planner, so the confirm stays a no-op.
expect(p.chooseRouteAlternative).not.toHaveBeenCalled()
act(() => { result.current.cancel() })
expect(p.routeAlternatives.close).toHaveBeenCalledTimes(1)
expect(result.current.canAsk).toBe(true)
expect(p.can).toHaveBeenCalledWith('day_edit', p.trip)
const viewer = setup(planner({ can: vi.fn(() => false) }))
expect(viewer.result.current.canAsk).toBe(false)
})
it('FE-MOB-RTALTH-009: the map lifts its floor by the bar and the gap the stage bar keeps', () => {
// 61px stage bar plus 15 is the 76px stage lift; the same 15 between the dock and this bar.
expect(RT_ALT_BAR_HEIGHT).toBe(174)
expect(RT_ALT_BAR_LIFT).toBe(189)
})
})