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)