fix(roadtrip): a place tapped on the phone stage map opens its stop

The bar over the road trip map names a place, but a tap on it switched to
the whole day's chain and the place never opened. It now opens that stop's
sheet, the one its row in the chain opens. Its name, clock and tap come off
one row (stageEnd), so the bar no longer pairs the destination with the
automatic day end's clock (Kyoto Station 22:00 while the head card said
12:40). The header's list switch stays the way back to the chain.

A pin on the stage went through the plan tab's marker click, which moves
the place selection and brought up the plan tab's place card. On the road
trip tab a pin now opens its stop on the stage card, or the first routed
visit in the all days view, and only a place no routed day stops at still
gets the place card. The handler keeps one identity for the life of the
map. "Show on map" in the stop sheet and the up next card move the camera
through focusRoadtripPoint instead of the selection, picking the stop's
card first when it is not on screen. A pending focus now lets go once the
day moves, so later stages frame their own drive again.

Stage pins come from the trip store instead of the plan tab's map list.
That list carries the plan tab's day declutter and place filters, so after
the all days switch and back most pins of the next stage were missing, and
the unplanned filter or hidden service stops removed stops the chain draws.
This commit is contained in:
Maurice
2026-09-16 22:49:44 +00:00
parent 5b60dda150
commit 9de18634fe
9 changed files with 493 additions and 62 deletions
@@ -1,9 +1,11 @@
import { describe, expect, it } from 'vitest'
import {
destinationCount,
firstStopOfPlace,
pickWarning,
roadtripRows,
stageClocks,
stageEnd,
stageOf,
upNextStop,
type RoadtripRow,
@@ -12,7 +14,7 @@ import {
import type { ScheduleEntry, ScheduleWarning } from './roadtripModel'
import type { RoadtripDay, RoadtripStop, RouteSegment } from '@trek/shared/roadtrip'
// FE-RTROW-001 to FE-RTROW-033
// FE-RTROW-001 to FE-RTROW-037
function stop(name: string, over: Partial<RoadtripStop> = {}): RoadtripStop {
return {
@@ -393,3 +395,51 @@ describe('stageClocks', () => {
expect(stageClocks(roadtripRows(unsplit))).toEqual({ start: '18:30', arrive: '10:00' })
})
})
describe('stageEnd', () => {
it('FE-RTROW-034: names the last stop the chain draws with its own arrival, not the automatic day end after it', () => {
// The reported bar: Kyoto Station reached at 12:40 sat beside 22:00, where the window closed.
const d = day([stop('Bremen'), stop('Aral', { stopType: 'fuel' }), stop('Kyoto Station'), night()], {
schedule: { entries: [entry('09:00'), entry('10:30'), entry('12:40'), entry('22:00')], warnings: [] },
})
const end = stageEnd(roadtripRows(d))
expect(end?.stop.name).toBe('Kyoto Station')
expect(end?.time).toBe('12:40')
// A service stop is still a stop the drive ends at, so it is the one named.
const fuelLast = timedDay(['09:00', '11:00'], [stop('A'), stop('Aral', { stopType: 'fuel' })])
expect(stageEnd(roadtripRows(fuelLast))?.stop.name).toBe('Aral')
})
it('FE-RTROW-035: a stage drawn with nothing but its night markers, and no rows at all, end nowhere', () => {
expect(stageEnd(roadtripRows(day([night('start'), night('end')])))).toBeNull()
expect(stageEnd([])).toBeNull()
})
})
describe('firstStopOfPlace', () => {
it('FE-RTROW-036: on one card, a place visited twice answers with its first visit and a night on it never does', () => {
// A loop day: out of the hotel in the morning, back to it at night.
const loop = day([
stop('Nacht', { placeId: 300, assignmentId: 90, automaticNight: { phase: 'start', fromDayNumber: 1 } }),
stop('Hotel', { placeId: 300, assignmentId: 1 }),
stop('Museum', { placeId: 301, assignmentId: 2 }),
stop('Hotel', { placeId: 300, assignmentId: 3 }),
])
expect(firstStopOfPlace([loop], 300)?.assignmentId).toBe(1)
expect(firstStopOfPlace([loop], 301)?.assignmentId).toBe(2)
})
it('FE-RTROW-037: across days it keeps day order, a card handed alone ignores the days before it, and no stop gives null', () => {
const monday = day([stop('Town', { placeId: 301, assignmentId: 11 })], { dayId: 7 })
const tuesday = day([stop('Town', { placeId: 301, assignmentId: 21 }), stop('Lake', { placeId: 302, assignmentId: 22 })], { dayId: 8 })
expect(firstStopOfPlace([monday, tuesday], 301)?.assignmentId).toBe(11)
expect(firstStopOfPlace([tuesday], 301)?.assignmentId).toBe(21)
expect(firstStopOfPlace([monday, tuesday], 302)?.assignmentId).toBe(22)
expect(firstStopOfPlace([monday, tuesday], 999)).toBeNull()
expect(firstStopOfPlace([], 301)).toBeNull()
// Only an automatic night sits on this position, which is not a stop anybody chose.
expect(firstStopOfPlace([day([stop('Nacht', { placeId: 303, automaticNight: { phase: 'end', fromDayNumber: 1 } })])], 303)).toBeNull()
})
})
@@ -197,6 +197,48 @@ export function stageClocks(rows: readonly RoadtripRow[]): { start: string | nul
return { start, arrive }
}
/**
* The stop a stage ends on: the last one the chain draws, a service stop included.
*
* The map half names it in its bar and opens it on a tap, so the name, the clock beside it
* and the sheet the tap brings up all come off this one row and cannot name three different
* things. The clock used to be read off the schedule on its own, which found the automatic
* day end whenever the window closed after the last stop: a station reached at 12:40 sat
* beside 22:00, the moment the day ran out rather than anything that happens there.
*
* Null for a stage without a single stop, one drawn with nothing but its night markers.
* There is nothing for a tap to open then, and the bar says so by not being a button.
*/
export function stageEnd(rows: readonly RoadtripRow[]): StopRow | null {
for (let i = rows.length - 1; i >= 0; i--) {
const row = rows[i]
if (row.kind === 'stop') return row
}
return null
}
/**
* The stop a place's pin stands for on the road trip map: its first visit in the days given.
*
* A pin is drawn once per place, while a place can be several stops, like the hotel a loop
* day leaves in the morning and comes back to at night, or a town the drive passes on two
* days. Over a stage the caller hands in that one card, so a pin opens the visit on the
* stage being looked at, however many days before it stop at the same place. Over the
* whole drive it hands in every routed day, and the first visit in day order is the one a
* traveller reading the drive from its start reaches first.
*
* An automatic night can sit on a place's position, but nobody chose to stop there, so it
* never answers. Null when none of the days stops at the place, which is a pin the road
* trip cannot explain and the caller hands to the plan's own place inspector instead.
*/
export function firstStopOfPlace(days: readonly RoadtripDay[], placeId: number): RoadtripStop | null {
for (const day of days) {
const stop = day.stops.find(s => !s.automaticNight && s.placeId === placeId)
if (stop) return stop
}
return null
}
/**
* The next destination the plan still owes you, measured against the clock.
*
+81 -11
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'
import { useCallback, useMemo, useRef, useState } 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'
@@ -7,10 +7,11 @@ import { DawarichTrailPill } from '../../../../components/Map/DawarichTrailPill'
import PoiCategoryPill from '../../../../components/Map/PoiCategoryPill'
import { usePoiExplore } from '../../../../components/Map/usePoiExplore'
import { useMergedMapPois } from '../../../../components/Map/useMergedMapPois'
import { stageOf } from '../../../../components/Roadtrip/roadtripRowModel'
import { firstStopOfPlace, stageOf } from '../../../../components/Roadtrip/roadtripRowModel'
import { stageMapData } from '../../../../components/Roadtrip/stageMap'
import { useRoadtripSettings } from '../../../../hooks/useRoadtripSettings'
import { useSettingsStore } from '../../../../store/settingsStore'
import { useTripStore } from '../../../../store/tripStore'
import type { MMapAreaProps } from '../MTripShell'
import type { Poi } from '../../../../components/Map/poiCategories'
@@ -24,6 +25,29 @@ const NO_POIS: Poi[] = []
*/
const COMPASS_LEFT = MAP_LAYER_SWITCHER_INSET + MAP_ROUND_CONTROL_SIZE + 8
/** A camera focus the planner is holding, and the day that was on screen when it arrived. */
interface HeldFocus {
points: readonly [number, number][]
dayId: number | null
live: boolean
}
/**
* Lets go of a pending focus once the day moves off the one it arrived with.
*
* The planner keeps a point it was asked to show until its next routing round, whatever
* day is picked in the meantime, and a pending focus wins over the stage's own frame. So
* after "show on map" every stage swiped to afterwards stayed on that one point instead of
* framing its drive. Coming back to the day does not bring the point back either: the
* traveller has moved on since, and the camera belongs to the stage they came back to. A
* new focus is a new array, held again from the day it arrives together with.
*/
function holdFocus(prev: HeldFocus, points: readonly [number, number][], dayId: number | null): HeldFocus {
if (prev.points !== points) return { points, dayId, live: true }
if (prev.live && prev.dayId !== dayId) return { points, dayId, live: false }
return prev
}
/**
* Fullscreen map layer of the mobile trip screen (plan tab). Stays mounted for
* the whole plan-tab lifetime — the plan timeline / places browser overlays
@@ -40,9 +64,10 @@ const COMPASS_LEFT = MAP_LAYER_SWITCHER_INSET + MAP_ROUND_CONTROL_SIZE + 8
* button on the right, all riding the --bottom-nav-h contract the map already reads
* so they cannot drift apart.
*
* Marker data honours the shared places category filter (#1541) because
* planner.mapPlaces is derived from tripStore's placesCategoryFilter — the
* same set the places browser renders, so the two can't desync.
* On the plan tab, marker data honours the shared places category filter (#1541)
* because planner.mapPlaces is derived from tripStore's placesCategoryFilter, the
* same set the places browser renders, so the two can't desync. The road trip
* stage draws the stops of its chain instead, see `stagePlaces`.
*/
export default function MMapArea({ planner, shell }: MMapAreaProps) {
const poi = usePoiExplore()
@@ -51,6 +76,7 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const dayColorsOn = useRoadtripSettings(s => s.roadtrip_day_colors, planner.tripId)
const tripPlaces = useTripStore(s => s.places)
// One instance, two tabs. `mapFront` is true whenever the map is the front layer
// in either of them, so the floating chrome below keys off that rather than off
@@ -80,13 +106,56 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
// Only what the stage carries, so a trip's other 200 pins stay off a screen that
// is answering one question. Without a stage (the all-days view) every planned
// place comes back, which is what the drive looks like end to end.
//
// Out of the trip store rather than `planner.mapPlaces`, which is the plan tab's map.
// That list drops the pins of every day its declutter has put away, and this tab moves
// the day without touching the declutter (a swipe, or a chip tap while the plan tab sits
// on its list): after the all days switch and back, the next stage picked came up with
// most of its pins missing. It also runs the places browser's filters, where 'unplanned'
// hides every stop a drive has, and it lacks the service stops hidden from the day lists
// that the chain still draws. The category filter stays off the stage too, on purpose:
// the stage is one day's chain on a map, and a stop the chain lists with no pin under its
// line, hidden by a control on another tab, reads as a broken map rather than a filter.
const stagePlaces = useMemo(
() => stageMap && stage
? planner.mapPlaces.filter(p => stageMap.placeIds.has(p.id))
? tripPlaces.filter(p => p.lat != null && p.lng != null && stageMap.placeIds.has(p.id))
: planner.roadtripMapPlaces,
[stageMap, stage, planner.mapPlaces, planner.roadtripMapPlaces],
[stageMap, stage, tripPlaces, planner.roadtripMapPlaces],
)
// Computed during render rather than in an effect, so the frame handed over below is
// already the right one in the render a day change happens in. See holdFocus.
const [heldFocus, setHeldFocus] = useState<HeldFocus>(
() => ({ points: planner.mapFocusPoints, dayId: planner.selectedDayId, live: true }),
)
const focus = holdFocus(heldFocus, planner.mapFocusPoints, planner.selectedDayId)
if (focus !== heldFocus) setHeldFocus(focus)
const focusPending = focus.live && planner.mapFocusPoints.length > 0
/**
* A pin on the road trip tab opens its stop: the sheet the chain row opens, not the
* place inspector.
*
* The plan tab's marker click moves the planner's place selection, and the inspector
* opens off that selection, so a tap on a stop of the drive came up with the plan tab's
* card over the map and no word about the stop. Over a stage the stop is found on that
* card, over the whole drive on the first routed day that stops there (firstStopOfPlace).
* A pin no routed day stops at still gets the inspector: the road trip has nothing to
* say about it, and the place does.
*
* One handler for the life of the map, reading the latest values through a ref. Leaflet
* rebuilds every marker whenever this identity moves, and the shell re-renders on every
* store write and on every sheet it opens, this handler's own included.
*/
const latest = useRef({ planner, shell, stage })
latest.current = { planner, shell, stage }
const openStagePin = useCallback((placeId?: number) => {
const { planner: now, shell: chrome, stage: card } = latest.current
const stop = placeId == null ? null : firstStopOfPlace(card ? [card] : now.roadtripRoutes.days, placeId)
if (stop) chrome.openSheet('rtstop', { dayId: stop.ownerDayId, assignmentId: stop.assignmentId })
else now.handleMarkerClick(placeId)
}, [])
/**
* The pins, and they are not the same question on the two tabs.
*
@@ -131,10 +200,11 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
route={stageMap ? stageMap.lines : planner.overviewActive ? planner.tripOverview.lines : planner.route}
routeColors={stageMap ? stageMap.lineColors : planner.overviewActive ? planner.tripOverview.lineColors : undefined}
accessLines={stageMap ? stageMap.accessLines : undefined}
// A hit somebody tapped in the search sheet, or the stations the fuel search is
// offering, take the camera; with nothing pending the stage frames itself.
// A hit somebody tapped in the search sheet, a stop shown from its sheet, or the
// stations the fuel search is offering take the camera while their day is on
// screen; with nothing pending the stage frames itself.
focusPoints={stageMap
? (planner.mapFocusPoints.length ? planner.mapFocusPoints : stageMap.focusPoints)
? (focusPending ? planner.mapFocusPoints : stageMap.focusPoints)
: planner.overviewActive ? planner.tripOverview.focusPoints : undefined}
routeVias={onStage ? planner.roadtripMapVias : planner.routeVias}
showTransitRoutes={onStage ? false : planner.transitRoutesShown}
@@ -144,7 +214,7 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
selectedDayId={planner.selectedDayId}
routeSegments={onStage ? undefined : planner.overviewActive ? planner.tripOverview.segments : planner.routeSegments}
selectedPlaceId={planner.selectedPlaceId}
onMarkerClick={planner.handleMarkerClick}
onMarkerClick={onStage ? openStagePin : planner.handleMarkerClick}
// Tap on empty map = deselect, same contract as desktop.
onMapClick={planner.handleMapClick}
// The chip rail names a day at all times on mobile, so a place dropped on
@@ -1,7 +1,7 @@
import { useRef } from 'react'
import { AlertTriangle, MapPin, Navigation } from 'lucide-react'
import { useMPlanDaySwipe } from '../plan/useMPlanDaySwipe'
import { useMRoadtrip } from './useMRoadtrip'
import { showStopOnMap, useMRoadtrip } from './useMRoadtrip'
import { useMRtCorridor } from './useMRtCorridor'
import MRtCorridorBar from './MRtCorridorBar'
import { RtAutoRow, RtDryRow, RtLegRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows'
@@ -92,7 +92,7 @@ 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} shell={shell} rt={rt} />
<StageBar planner={planner} rt={rt} onOpen={openStop} />
</div>
</div>
)
@@ -178,7 +178,7 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
)}
</section>
{rt.upNext && <UpNext planner={planner} shell={shell} rt={rt} onOpen={openStop} />}
{rt.upNext && <UpNext planner={planner} shell={shell} rt={rt} stageDayId={stage.dayId} onOpen={openStop} />}
<section className="mt-2.5 overflow-hidden rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-3.5 pb-3 pt-1">
{rt.rows.map((row, i) => {
@@ -221,11 +221,16 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
* One line rather than a sheet that can be dragged open. A drag sheet brings a panel
* height the map fit has to account for and lifts the locate and style buttons with
* it, which is a bigger change than this half of the screen needs to be useful.
*
* It reads as a place, so a tap opens that place's stop, the same sheet its row in the
* chain opens. It used to switch to the chain instead, which broke the promise its name
* makes; the header's list switch is the way back to the chain. Name, clock and tap all
* come off one row (see stageEnd), so the bar can never name one stop and open another.
*/
function StageBar({ planner, shell, rt }: {
function StageBar({ planner, rt, onOpen }: {
planner: MTripTabPanelProps['planner']
shell: MTripTabPanelProps['shell']
rt: ReturnType<typeof useMRoadtrip>
onOpen: (row: StopRow) => void
}) {
const { t } = planner
const unit = useSettingsStore(s => s.settings.distance_unit)
@@ -245,32 +250,33 @@ function StageBar({ planner, shell, rt }: {
}
const tint = dayColorsOn ? dayColor(stage.dayNumber) : null
const named = stage.stops.filter(s => !s.automaticNight)
const last = named.length ? named[named.length - 1] : undefined
const end = rt.end
return (
<button
type="button"
onClick={shell.toggleRtView}
className="flex w-full items-center gap-2.5 rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-[14px] py-[11px] text-left shadow-[0_16px_44px_-14px_rgba(0,0,0,.35)] backdrop-blur-[24px] backdrop-saturate-[1.6]"
>
const inner = (
<>
{tint && (
// theme-lint-disable: map paint, see dayColors.ts.
<span className="h-[9px] w-[9px] flex-none rounded-full" style={{ background: tint.line, boxShadow: `0 0 0 2px ${tint.casing}` }} aria-hidden="true" />
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-[0.8125rem] font-semibold text-m-ink">
{last?.name ?? t('roadtrip.stop.none')}
{end?.stop.name ?? t('roadtrip.stop.none')}
</span>
<span className="mt-px block font-geist text-[0.6875rem] tabular-nums text-m-muted">
{[t('roadtrip.day.stopCount', { count: rt.stops }), lastArrivalOf(stage)].filter(Boolean).join(' · ')}
{[t('roadtrip.day.stopCount', { count: rt.stops }), end?.time].filter(Boolean).join(' · ')}
</span>
</span>
<span className="whitespace-nowrap font-geist text-[0.6875rem] font-semibold tabular-nums text-m-muted">
{stage.distance > 0 ? formatDistance(stage.distance / 1000, unit) : t('roadtrip.leg.pending')}
</span>
</button>
</>
)
const box = 'flex w-full items-center gap-2.5 rounded-[22px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] px-[14px] py-[11px] text-left shadow-[0_16px_44px_-14px_rgba(0,0,0,.35)] backdrop-blur-[24px] backdrop-saturate-[1.6]'
// A stage without a single stop has nothing for a tap to open, so a plain block rather
// than a dead button, the rule the whole-drive line above follows too.
if (!end) return <div className={box}>{inner}</div>
return <button type="button" onClick={() => onOpen(end)} className={box}>{inner}</button>
}
/**
@@ -281,10 +287,11 @@ function StageBar({ planner, shell, rt }: {
* planned time stays put and the delta sits beside it, because the planned time is the
* number you want to compare against.
*/
function UpNext({ planner, shell, rt, onOpen }: {
function UpNext({ planner, shell, rt, stageDayId, onOpen }: {
planner: MTripTabPanelProps['planner']
shell: MTripTabPanelProps['shell']
rt: ReturnType<typeof useMRoadtrip>
stageDayId: number
onOpen: (row: StopRow) => void
}) {
const { t } = planner
@@ -333,10 +340,8 @@ function UpNext({ planner, shell, rt, onOpen }: {
</a>
<button
type="button"
onClick={() => {
planner.setSelectedPlaceId(next.row.stop.placeId)
if (shell.rtView === 'list') shell.toggleRtView()
}}
// Up next is a stop of the stage on screen, so the day stays put and only the camera moves.
onClick={() => showStopOnMap(planner, shell, next.row.stop, stageDayId)}
aria-label={t('mobileTrip.showOnMap')}
className="flex h-11 w-11 flex-none items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-glass)] text-m-ink backdrop-blur-[24px] backdrop-saturate-[1.7]"
>
@@ -373,14 +378,6 @@ function EmptyStage({ planner, loading }: { planner: MTripTabPanelProps['planner
/* ── small helpers ────────────────────────────────────────────────────────── */
function lastArrivalOf(stage: { schedule: { entries: { arrival: string | null }[] } }): string | null {
for (let i = stage.schedule.entries.length - 1; i >= 0; i--) {
const a = stage.schedule.entries[i]?.arrival
if (a) return a
}
return null
}
function stageDateLabel(planner: MTripTabPanelProps['planner'], dayId: number): string | null {
const day = planner.days.find(d => d.id === dayId)
if (!day?.date) return null
@@ -18,6 +18,7 @@ import { getNavigationTargets, openNavigationTarget } from '../../../../componen
import { NavigationMenu } from '../../../../components/shared/NavigationMenu'
import { formatDistance } from '../../../../utils/units'
import { Eyebrow, displayTime } from '../sheets/MTripSheetUi'
import { showStopOnMap } from './useMRoadtrip'
import type { DistanceUnit } from '../../../../types'
import type { RoadtripDay, ScheduleWarning } from '@trek/shared/roadtrip'
@@ -252,9 +253,11 @@ export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) {
}
}
// The camera rather than the place selection, for the reason the end-day switch above
// stays off it: the place inspector would come up over the map. `day` is the card the
// stop is drawn on, so a stop reached after a night drive shows on the stage that has it.
const showOnMap = () => {
if (shell.rtView === 'list') shell.toggleRtView()
planner.setSelectedPlaceId(stop.placeId)
showStopOnMap(planner, shell, stop, day.dayId)
shell.closeSheet()
}
@@ -1,11 +1,11 @@
import { useEffect, useMemo, useState } from 'react'
import { destinationCount, roadtripRows, stageClocks, stageOf, upNextStop } from '../../../../components/Roadtrip/roadtripRowModel'
import { destinationCount, roadtripRows, stageClocks, stageEnd, stageOf, upNextStop } from '../../../../components/Roadtrip/roadtripRowModel'
import { useRoadtripSettings } from '../../../../hooks/useRoadtripSettings'
import { useSettingsStore } from '../../../../store/settingsStore'
import { isEffectivelyOffline, onNetworkModeChange } from '../../../../sync/networkMode'
import type { RoadtripDay } from '@trek/shared/roadtrip'
import type { TripPlanner } from '../MTripShell'
import type { RoadtripRow } from '../../../../components/Roadtrip/roadtripRowModel'
import type { MTripShellApi, TripPlanner } from '../MTripShell'
import type { RoadtripRow, StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
/** Minutes since midnight, local time. */
const nowMinutes = (): number => {
@@ -21,6 +21,8 @@ export interface MRoadtripController {
rows: RoadtripRow[]
/** The head card's two figures, both taken from the arrival column the rows print. See stageClocks. */
clocks: ReturnType<typeof stageClocks>
/** The stop the bar over the map names and opens, with the clock it shows. See stageEnd. */
end: StopRow | null
stops: number
/** True while the routing round is still working through the trip's days. */
loading: boolean
@@ -60,6 +62,7 @@ export function useMRoadtrip(planner: TripPlanner): MRoadtripController {
const rows = useMemo(() => (stage ? roadtripRows(stage) : []), [stage])
const clocks = useMemo(() => stageClocks(rows), [rows])
const end = useMemo(() => stageEnd(rows), [rows])
// "Today" is the stage's own date, not the selected day's index: a trip can be
// planned for next year, and a countdown on a day in March is noise.
@@ -73,6 +76,7 @@ export function useMRoadtrip(planner: TripPlanner): MRoadtripController {
stage,
rows,
clocks,
end,
stops: stage ? destinationCount(stage) : 0,
loading: roadtripRoutes.loading,
empty: !roadtripRoutes.loading && roadtripRoutes.days.length === 0,
@@ -83,6 +87,32 @@ export function useMRoadtrip(planner: TripPlanner): MRoadtripController {
}
}
/**
* Brings one stop of the drive into view on the map half, from wherever it was asked for.
*
* Through the camera and never through the planner's place selection: the place inspector
* opens off that selection, so selecting the place came up with the plan tab's card over
* the very map this was meant to show. The corridor search shows its hits the same way.
*
* `cardDayId` is the card the stop is DRAWN on, which after a night drive is not the day it
* is stored on. When that card is not the stage on screen it becomes the stage first, so the
* line around the stop the camera lands on is the stage the bar is about. With skipFit, as
* the day swipe does it: on the stage the camera follows focus points, and here the focus
* is the stop. The map area lets a focus go once the day moves off the one it arrived with,
* so the day is picked first, in the same tap, and the focus arrives together with it
* instead of being let go by it (see MMapArea).
*/
export function showStopOnMap(
planner: TripPlanner,
shell: MTripShellApi,
stop: { lat: number; lng: number },
cardDayId: number,
): void {
if (planner.selectedDayId !== cardDayId) planner.handleSelectDay(cardDayId, true)
planner.focusRoadtripPoint(stop.lat, stop.lng)
if (shell.rtView === 'list') shell.toggleRtView()
}
/** The trip's own distance unit, for every figure the stage prints. */
export function useDistanceUnit() {
return useSettingsStore(s => s.settings.distance_unit)
+170 -5
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '../../../helpers/render'
import { act, render, screen } from '../../../helpers/render'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'
import { buildPlace } from '../../../helpers/factories'
import type { AccessSpur, RoadtripDay } from '@trek/shared/roadtrip'
@@ -7,8 +7,14 @@ import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/
import type { CompassMap } from '../../../../src/components/Map/MapCompassPill'
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../../../src/components/Map/MapLayerSwitcher'
import { useSettingsStore } from '../../../../src/store/settingsStore'
import { useTripStore } from '../../../../src/store/tripStore'
import { seedStore } from '../../../helpers/store'
import type { Place } from '../../../../src/types'
// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-018
// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-025
//
// 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.
const mocks = vi.hoisted(() => ({
poi: {} as Record<string, unknown>,
@@ -84,6 +90,7 @@ function stageDay(): RoadtripDay {
function stagePlanner(selectedDayId: number | null): TripPlanner {
const base = buildPlanner()
const spur: AccessSpur = { line: [[53.87, 10.69], [53.871, 10.692]], meters: 180, stopKey: '53.87000,10.69000,,' }
seedStore(useTripStore, { places: [buildPlace({ id: 11 }), buildPlace({ id: 12 }), buildPlace({ id: 99 })] })
return buildPlanner({
selectedDayId,
mapPlaces: [buildPlace({ id: 11 }), buildPlace({ id: 12 }), buildPlace({ id: 99 })],
@@ -99,6 +106,29 @@ function stagePlanner(selectedDayId: number | null): TripPlanner {
} as unknown as Partial<TripPlanner>)
}
/**
* Two routed days. Lübeck is passed on the day before the stage, and the stage itself is a
* loop that leaves Hamburg in the morning and comes back to it at night.
*/
function drivePlanner(selectedDayId: number | null, over: Partial<TripPlanner> = {}): TripPlanner {
const base = stagePlanner(selectedDayId)
const stage = stageDay()
const [hamburg, luebeck] = stage.stops
const before = { ...stage, dayId: 2, dayNumber: 1, stops: [{ ...luebeck, assignmentId: 21, ownerDayId: 2, ownerIndex: 0 }] }
const loop = { ...stage, dayNumber: 2, stops: [hamburg, luebeck, { ...hamburg, assignmentId: 33, ownerIndex: 2 }] }
return {
...base,
roadtripRoutes: { ...base.roadtripRoutes, days: [before, loop], lineDays: [2] },
...over,
} as TripPlanner
}
/** The shell with the road trip map in front. */
const stageShell = () => buildShell({ view: 'map', mapFront: true, trTab: 'roadtrip', rtView: 'map' })
/** The pin handler the area last handed the renderer. */
const tapPin = (placeId?: number) => (mocks.props.onMarkerClick as (id?: number) => void)(placeId)
/** What the area handed the renderer that a fresh copy would make it draw again. */
const DRAWN = ['places', 'route', 'routeColors', 'accessLines', 'focusPoints'] as const
@@ -110,6 +140,7 @@ beforeEach(() => {
moved: false, toggle: vi.fn(), searchArea: vi.fn(), onViewportChange: vi.fn(),
}
useSettingsStore.setState(s => ({ settings: { ...s.settings, map_poi_pill_enabled: true } }))
seedStore(useTripStore, { places: [], placesFilter: 'all', placesCategoryFilter: new Set<string>() })
})
describe('MMapArea', () => {
@@ -309,12 +340,13 @@ describe('MMapArea', () => {
rerender(<MMapArea planner={planner} shell={shell} />)
expect(mocks.props.routeColors).toHaveLength(1)
// The store is where the stage reads its places from, and a store write re-renders the area.
const renamed = buildPlace({ id: 12, name: 'Travemünde' })
const edited = { ...planner, mapPlaces: [planner.mapPlaces[0], renamed, planner.mapPlaces[2]] }
rerender(<MMapArea planner={edited} shell={shell} />)
const stored = useTripStore.getState().places
act(() => { useTripStore.setState({ places: [stored[0], renamed, stored[2]] }) })
expect(mocks.props.places).toContain(renamed)
const allDays = { ...edited, selectedDayId: null }
const allDays = { ...planner, selectedDayId: null }
rerender(<MMapArea planner={allDays} shell={shell} />)
expect(mocks.props.places).toBe(allDays.roadtripMapPlaces)
expect(mocks.props.focusPoints).toEqual([])
@@ -325,4 +357,137 @@ describe('MMapArea', () => {
expect(mocks.props.focusPoints).toBeUndefined()
expect(mocks.props.places).toBe(allDays.mapPlaces)
})
it('FE-MOB-MAPAREA-019: a pin on the stage opens its stop on this card, never the place inspector', () => {
const planner = drivePlanner(3)
const shell = stageShell()
render(<MMapArea planner={planner} shell={shell} />)
// Lübeck is also a stop the day before; the stage on screen is the one it opens on.
tapPin(12)
expect(shell.openSheet).toHaveBeenLastCalledWith('rtstop', { dayId: 3, assignmentId: 32 })
// Hamburg is left in the morning and come back to at night: the pin opens the first visit.
tapPin(11)
expect(shell.openSheet).toHaveBeenLastCalledWith('rtstop', { dayId: 3, assignmentId: 31 })
// The planner's selection is what the place inspector opens off, so it is never moved.
expect(planner.handleMarkerClick).not.toHaveBeenCalled()
expect(planner.setSelectedPlaceId).not.toHaveBeenCalled()
expect(planner.selectAssignment).not.toHaveBeenCalled()
})
it('FE-MOB-MAPAREA-020: over the whole drive a pin opens its first routed visit, and a place no day stops at gets the inspector', () => {
const planner = drivePlanner(null)
const shell = stageShell()
render(<MMapArea planner={planner} shell={shell} />)
tapPin(12)
expect(shell.openSheet).toHaveBeenLastCalledWith('rtstop', { dayId: 2, assignmentId: 21 })
tapPin(11)
expect(shell.openSheet).toHaveBeenLastCalledWith('rtstop', { dayId: 3, assignmentId: 31 })
tapPin(99)
expect(planner.handleMarkerClick).toHaveBeenCalledWith(99)
expect(shell.openSheet).toHaveBeenCalledTimes(2)
})
it('FE-MOB-MAPAREA-021: a picked day with no stage to show searches the whole drive, and falls back the same way', () => {
// A quiet day the routing round has no card for.
const quiet = drivePlanner(7)
const shell = stageShell()
const { unmount } = render(<MMapArea planner={quiet} shell={shell} />)
tapPin(12)
expect(shell.openSheet).toHaveBeenLastCalledWith('rtstop', { dayId: 2, assignmentId: 21 })
tapPin(99)
expect(quiet.handleMarkerClick).toHaveBeenCalledWith(99)
unmount()
// Still routing: a day is picked, nothing has come back yet, and the inspector is all there is.
const base = drivePlanner(3)
const routing = { ...base, roadtripRoutes: { ...base.roadtripRoutes, days: [], loading: true } } as TripPlanner
const waiting = stageShell()
render(<MMapArea planner={routing} shell={waiting} />)
tapPin(11)
expect(routing.handleMarkerClick).toHaveBeenCalledWith(11)
expect(waiting.openSheet).not.toHaveBeenCalled()
// A handler called without a place clears the way the planner's own does.
tapPin(undefined)
expect(routing.handleMarkerClick).toHaveBeenLastCalledWith(undefined)
})
it('FE-MOB-MAPAREA-022: the plan tab keeps the planner\'s own marker door, map tap and selection', () => {
const planner = drivePlanner(3, { selectedPlaceId: 12 })
const shell = buildShell({ view: 'map', mapFront: true, trTab: 'plan' })
render(<MMapArea planner={planner} shell={shell} />)
expect(mocks.props.onMarkerClick).toBe(planner.handleMarkerClick)
expect(mocks.props.onMapClick).toBe(planner.handleMapClick)
expect(mocks.props.selectedPlaceId).toBe(12)
tapPin(12)
expect(planner.handleMarkerClick).toHaveBeenCalledWith(12)
expect(shell.openSheet).not.toHaveBeenCalled()
})
it('FE-MOB-MAPAREA-023: the stage\'s pin handler keeps its identity across renders and reads the latest stage', () => {
const planner = drivePlanner(3)
const shell = stageShell()
const { rerender } = render(<MMapArea planner={planner} shell={shell} />)
const first = mocks.props.onMarkerClick
// New planner and shell objects, the way the shell hands them over on every store write.
// Leaflet rebuilds every marker when this identity moves.
rerender(<MMapArea planner={{ ...planner }} shell={stageShell()} />)
expect(mocks.props.onMarkerClick).toBe(first)
// Swiped to the day before: the same handler now answers for that stage and that shell.
const next = stageShell()
rerender(<MMapArea planner={{ ...planner, selectedDayId: 2 }} shell={next} />)
expect(mocks.props.onMarkerClick).toBe(first)
tapPin(12)
expect(next.openSheet).toHaveBeenCalledWith('rtstop', { dayId: 2, assignmentId: 21 })
expect(shell.openSheet).not.toHaveBeenCalled()
})
it('FE-MOB-MAPAREA-024: stage pins are the stops of its chain, whatever the plan tab\'s declutter and filters hide', () => {
// A pump hidden from the day lists, and a category filter set in the places browser.
const pump = buildPlace({ id: 12, stop_type: 'fuel', category_id: 3 } as Partial<Place>)
seedStore(useTripStore, { placesFilter: 'unplanned', placesCategoryFilter: new Set(['9']) })
const planner = stagePlanner(3)
seedStore(useTripStore, { places: [buildPlace({ id: 11, category_id: 4 }), pump, buildPlace({ id: 99 })] })
// The plan tab's list after "all days" twice and a chip tap: its declutter still names
// the day before, so it carries none of this stage's places.
const decluttered = { ...planner, mapPlaces: [buildPlace({ id: 99 })] } as TripPlanner
render(<MMapArea planner={decluttered} shell={stageShell()} />)
expect((mocks.props.places as Place[]).map(p => p.id)).toEqual([11, 12])
// A place without a position has nowhere to stand, on the stage as anywhere else.
act(() => {
useTripStore.setState({ places: [buildPlace({ id: 11 }), { ...pump, lat: null, lng: null } as unknown as Place] })
})
expect((mocks.props.places as Place[]).map(p => p.id)).toEqual([11])
})
it('FE-MOB-MAPAREA-025: a shown point holds the camera on its own day only, and a day change hands the frame back', () => {
const planner = drivePlanner(3)
const shell = stageShell()
const point: [number, number][] = [[50, 8]]
const { rerender } = render(<MMapArea planner={{ ...planner, mapFocusPoints: point }} shell={shell} />)
expect(mocks.props.focusPoints).toBe(point)
// The planner keeps the point until its next routing round. The swipe frames its stage anyway.
rerender(<MMapArea planner={{ ...planner, selectedDayId: 2, mapFocusPoints: point }} shell={shell} />)
expect(mocks.props.focusPoints).toEqual([[53.87, 10.69]])
// Coming back does not bring the old point back: the camera belongs to the stage now.
rerender(<MMapArea planner={{ ...planner, selectedDayId: 3, mapFocusPoints: point }} shell={shell} />)
expect(mocks.props.focusPoints).not.toBe(point)
expect(mocks.props.focusPoints).toEqual(expect.arrayContaining([[53.55, 9.99], [53.87, 10.69]]))
// A new point arriving together with its day, as Show on map from another card does, is held.
const shown: [number, number][] = [[53.87, 10.69]]
rerender(<MMapArea planner={{ ...planner, selectedDayId: 2, mapFocusPoints: shown }} shell={shell} />)
expect(mocks.props.focusPoints).toBe(shown)
})
})
@@ -6,7 +6,7 @@ import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/
import type { Day, Place } from '../../../../src/types'
import type { RoadtripDay, RoadtripRoutes, RouteSegment } from '@trek/shared/roadtrip'
// FE-MOB-RTTAB-001 to FE-MOB-RTTAB-032
// FE-MOB-RTTAB-001 to FE-MOB-RTTAB-034
// The preference store is a zustand slice keyed by user and trip; the tab only ever
// reads two flags out of it, so the hook is the smaller seam.
@@ -363,15 +363,19 @@ describe('MRoadtripTab', () => {
expect(shell.openSheet).toHaveBeenCalledWith('rtstop', { dayId: 1, assignmentId: 501 })
})
it('FE-MOB-RTTAB-017: sends the next stop to the map half and switches to it', () => {
it('FE-MOB-RTTAB-017: shows the next stop on the map half by moving the camera, not the place selection', () => {
const date = freezeAt(7, 0)
const p = planner(today(date))
const { shell } = renderTab(p)
fireEvent.click(screen.getByLabelText('mobileTrip.showOnMap'))
expect(p.setSelectedPlaceId).toHaveBeenCalledWith(101)
expect(p.focusRoadtripPoint).toHaveBeenCalledWith(35.36, 138.73)
expect(shell.toggleRtView).toHaveBeenCalledTimes(1)
// The place inspector opens off the selection and would come up over the stage.
expect(p.setSelectedPlaceId).not.toHaveBeenCalled()
// Up next is always a stop of the stage on screen, so the day stays where it is.
expect(p.handleSelectDay).not.toHaveBeenCalled()
})
it('FE-MOB-RTTAB-018: leaves the navigate link dead when the stop has no place row behind it', () => {
@@ -426,12 +430,15 @@ describe('MRoadtripTab', () => {
expect(screen.queryByText('roadtrip.window.stop')).toBeNull()
})
it('FE-MOB-RTTAB-023: names the last stop of the day, its count, its arrival and its distance', () => {
it('FE-MOB-RTTAB-023: names the last stop of the day, its count, that stop\'s own arrival and the distance', () => {
renderTab(planner(), mapShell())
const bar = screen.getByRole('button', { name: /Kyoto Station/ })
expect(within(bar).getByText('Kyoto Station')).toBeInTheDocument()
expect(within(bar).getByText('roadtrip.day.stopCount:2 · 22:00')).toBeInTheDocument()
// 12:40 is when Kyoto Station is reached. The bar used to print 22:00, the automatic
// day end after it, which is where the window closed and not a clock of the stop named.
expect(within(bar).getByText('roadtrip.day.stopCount:2 · 12:40')).toBeInTheDocument()
expect(within(bar).queryByText(/22:00/)).toBeNull()
expect(within(bar).getByText('412 km')).toBeInTheDocument()
})
@@ -447,12 +454,55 @@ describe('MRoadtripTab', () => {
expect(within(bar).getByText('roadtrip.leg.pending')).toBeInTheDocument()
})
it('FE-MOB-RTTAB-025: switches back to the chain from the bar', () => {
it('FE-MOB-RTTAB-025: opens the stop the bar names, the sheet its row in the chain opens', () => {
const { shell } = renderTab(planner(), mapShell())
fireEvent.click(screen.getByRole('button', { name: /Kyoto Station/ }))
expect(shell.toggleRtView).toHaveBeenCalledTimes(1)
expect(shell.openSheet).toHaveBeenCalledWith('rtstop', { dayId: 2, assignmentId: 503 })
// The bar reads as a place; the header's list switch is the way back to the chain.
expect(shell.toggleRtView).not.toHaveBeenCalled()
})
it('FE-MOB-RTTAB-033: a stage drawn with nothing but its night marker names no stop and is not a button', () => {
const nightOnly = stage({
stops: [stage().stops[3]],
legs: [],
legVias: [],
spills: [],
dryPoints: [],
driveWarnings: [],
schedule: { entries: [{ arrival: '22:00', departure: null, anchored: false, dayOffset: 0 }], warnings: [] },
} as unknown as Partial<RoadtripDay>)
const { shell } = renderTab(planner({ roadtripRoutes: routes({ days: [nightOnly] }) }), mapShell())
const title = screen.getByText('roadtrip.stop.none')
// Nothing for a tap to open, so a plain block rather than a dead button, and the night
// marker's clock is not a stop's arrival either.
expect(title.closest('button')).toBeNull()
expect(screen.getByText('roadtrip.day.stopCount:0')).toBeInTheDocument()
expect(screen.queryByText(/22:00/)).toBeNull()
fireEvent.click(title)
expect(shell.openSheet).not.toHaveBeenCalled()
expect(shell.toggleRtView).not.toHaveBeenCalled()
})
it('FE-MOB-RTTAB-034: a stage that ends on a stop reached after midnight opens it on the day it is stored on', () => {
// Only the spilled stop is left on the card: it is drawn here and stored on day 1.
const spilledOnly = stage({
stops: [stage().stops[0]],
legs: [],
legVias: [],
dryPoints: [],
driveWarnings: [],
schedule: { entries: [{ arrival: '00:40', departure: '01:25', anchored: false, dayOffset: 0 }], warnings: [] },
} as unknown as Partial<RoadtripDay>)
const { shell } = renderTab(planner({ roadtripRoutes: routes({ days: [spilledOnly] }) }), mapShell())
const bar = screen.getByRole('button', { name: /Fuji Viewpoint/ })
expect(within(bar).getByText('roadtrip.day.stopCount:1 · 00:40')).toBeInTheDocument()
fireEvent.click(bar)
expect(shell.openSheet).toHaveBeenCalledWith('rtstop', { dayId: 1, assignmentId: 501 })
})
it('FE-MOB-RTTAB-026: falls back to the whole drive when no day is picked, and offers nothing to tap', () => {
@@ -8,7 +8,7 @@ import { useTripStore } from '../../../../src/store/tripStore'
import { resetAllStores, seedStore } from '../../../helpers/store'
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
// FE-MOB-RTSTOP-001 to FE-MOB-RTSTOP-034
// FE-MOB-RTSTOP-001 to FE-MOB-RTSTOP-035
//
// The sheet renders inside the real TranslationProvider, so the copy is asserted
// in English. `planner.t` from the fixture is never consulted here.
@@ -247,12 +247,16 @@ describe('MRtStopSheet', () => {
expect(screen.queryByText('End the day here')).not.toBeInTheDocument()
})
it('FE-MOB-RTSTOP-015: showing the stop on the map flips the stage over and selects it', () => {
const { planner, shell } = renderSheet({}, { rtView: 'list' })
it('FE-MOB-RTSTOP-015: showing the stop on the map flips the stage over and brings the stop into view', () => {
const { planner, shell } = renderSheet({ selectedDayId: 11 }, { rtView: 'list' })
fireEvent.click(screen.getByRole('button', { name: 'Show on map' }))
expect(shell.toggleRtView).toHaveBeenCalledTimes(1)
expect(planner.setSelectedPlaceId).toHaveBeenCalledWith(203)
expect(planner.focusRoadtripPoint).toHaveBeenCalledWith(BREMEN.lat, BREMEN.lng)
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
// Not the place selection: the place inspector opens off it and would cover the map.
expect(planner.setSelectedPlaceId).not.toHaveBeenCalled()
// Already the stage on screen, so the day stays put.
expect(planner.handleSelectDay).not.toHaveBeenCalled()
})
it('FE-MOB-RTSTOP-016: leaves the stage alone when the map is already the front layer', () => {
@@ -454,4 +458,24 @@ describe('MRtStopSheet', () => {
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Navigation' })).toBeInTheDocument()
})
it('FE-MOB-RTSTOP-035: a stop drawn on another card makes that card the stage before the camera moves', () => {
// Kassel is stored on day 11 and drawn on day 12 after a night drive, while day 11 is on screen.
const { planner, shell } = renderSheet(
{ selectedDayId: 11 },
{ rtView: 'map', sheet: { id: 'rtstop', payload: { dayId: 11, assignmentId: 104 } } },
)
fireEvent.click(screen.getByRole('button', { name: 'Show on map' }))
// The card, not the stored day, and without a refit of its own: the stop is the frame.
expect(planner.handleSelectDay).toHaveBeenCalledWith(12, true)
expect(planner.focusRoadtripPoint).toHaveBeenCalledWith(KASSEL.lat, KASSEL.lng)
// The day first, so the map holds the point to the day it arrives with.
const selectDay = vi.mocked(planner.handleSelectDay).mock.invocationCallOrder[0]
const focus = vi.mocked(planner.focusRoadtripPoint).mock.invocationCallOrder[0]
expect(selectDay).toBeLessThan(focus)
expect(shell.toggleRtView).not.toHaveBeenCalled()
expect(shell.closeSheet).toHaveBeenCalledTimes(1)
expect(planner.setSelectedPlaceId).not.toHaveBeenCalled()
})
})