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.
*