fix(roadtrip): the phone map loses the stage bar over the dock

It named the day's destination with its picture, its stop count, its arrival and the
stage distance, and it stood there for the whole of the road trip tab. The day and
the distance are in the shell's stage header already, and on a map that fills the
screen a permanent card over the bottom edge costs more than the three facts it
added. Tapping it opened the destination's stop sheet; the chain opens the same sheet
from the row that names it.

The slot stays for the one bar that earns it: while other ways of driving a leg are
on offer, the picker's bar stands there. That wrapper is now mounted with the bar
rather than lying empty over the map's own buttons the rest of the time, and the map
floor lifts only while it is up, so on the stage the round controls stand on the dock
like everywhere else.
This commit is contained in:
Maurice
2026-09-17 12:58:09 +02:00
parent 50f0641ad9
commit a250fa6339
6 changed files with 103 additions and 359 deletions
+42 -19
View File
@@ -202,26 +202,29 @@ describe('MMapArea', () => {
expect(compassBand(container)?.className).not.toContain('left-3')
})
it('FE-MOB-MAPAREA-003: the map layer floats those controls one credit row above the dock', () => {
it('FE-MOB-MAPAREA-003: the map layer stands those controls on the dock, with no credit row under them', () => {
const { container } = renderArea()
const layer = container.firstElementChild as HTMLElement
// The floor is the dock's top edge: 62px tall at safe-bottom + 12, raised by the stage lift.
expect(layer.className)
.toContain('[--m-map-floor:calc(env(safe-area-inset-bottom,0px)+74px+var(--m-stage-lift,0px))]')
// The band sits one credit row (a 30px button plus an 8px gap) above that floor, so the
// corner under it is the credit's; the controls add their own 12 on top.
expect(layer.className).toContain('[--bottom-nav-h:calc(var(--m-map-floor)+38px)]')
// The band sits on that floor and the controls add their own 12 on top. It used to float
// a further 38px up to leave the corner under it to the credit; the phone map shows none,
// so that row would only be a gap over the dock.
expect(layer.className).toContain('[--bottom-nav-h:var(--m-map-floor)]')
expect(layer.className).not.toContain('38px')
// No lift off the stage: the plan tab has no bar in that band.
expect(layer.style.getPropertyValue('--m-stage-lift')).toBe('0px')
})
it('FE-MOB-MAPAREA-013: on the stage the map floor clears the stage bar by one gap', () => {
it('FE-MOB-MAPAREA-013: on the stage nothing stands over the dock, so the floor stays on it', () => {
const { container } = renderArea({ trTab: 'roadtrip', mapFront: true })
// The bar is 61px tall and keeps a gap on both sides, so the floor rises above it rather
// than stopping on its top edge, and the credit and the round controls rise with it.
expect((container.firstElementChild as HTMLElement).style.getPropertyValue('--m-stage-lift')).toBe('76px')
// A 61px stage bar used to hold this slot whenever the map was up, and the floor rose
// 76px to clear it. It is gone, so the road trip tab lifts nothing by itself; only the
// picker's bar still raises the floor (FE-MOB-MAPAREA-030).
expect((container.firstElementChild as HTMLElement).style.getPropertyValue('--m-stage-lift')).toBe('0px')
})
it('FE-MOB-MAPAREA-014: behind the chain there is no bar to clear, so nothing lifts', () => {
@@ -535,19 +538,39 @@ describe('MMapArea', () => {
expect(mocks.props.focusPoints).toBe(shown)
})
it('FE-MOB-MAPAREA-026: the map layer is the credit corner on both tabs, and on the stage it rides the lift', () => {
it('FE-MOB-MAPAREA-036: the phone map draws no night pause, on either tab', () => {
// A night pause is the moon pill beside a day boundary. On the desktop it is a drag
// handle with a hint; a phone has neither hover nor room to drag, and the markers come
// from the whole drive rather than the stage, so day 1 showed day 2's pill too.
const nightPause = { day: 2, atPlace: false }
const vias = [
{ lat: 53.55, lng: 9.99, tone: 'default' as const, nightPause },
{ lat: 53.87, lng: 10.69, tone: 'default' as const, label: 'Rest stop' },
]
const stage = renderArea(
{ trTab: 'roadtrip', mapFront: true },
{ ...stagePlanner(3), roadtripMapVias: vias } as unknown as Partial<TripPlanner>,
)
expect(mocks.props.routeVias).toEqual([vias[1]])
stage.unmount()
// The plan tab's own vias carry night pauses too, so they go through the same filter.
renderArea({ trTab: 'plan' }, { routeVias: vias })
expect(mocks.props.routeVias).toEqual([vias[1]])
})
it('FE-MOB-MAPAREA-026: the map layer carries no credit corner on either tab', () => {
const plan = renderArea()
const planLayer = plan.container.firstElementChild as HTMLElement
// mobile.css places the credit off this class, in both GL engines and in Leaflet.
expect(planLayer.classList.contains('m-credit-corner')).toBe(true)
// The phone map shows no credit at all now (mobile.css), so the class that used to
// place one is gone rather than left behind pointing at nothing.
expect(planLayer.classList.contains('m-credit-corner')).toBe(false)
plan.unmount()
const { container } = renderArea({ trTab: 'roadtrip', mapFront: true })
const stageLayer = container.firstElementChild as HTMLElement
expect(stageLayer.classList.contains('m-credit-corner')).toBe(true)
// The floor takes the same 76px the band does, so the credit lands a gap above the
// stage bar rather than on it.
expect(stageLayer.style.getPropertyValue('--m-stage-lift')).toBe('76px')
expect(stageLayer.classList.contains('m-credit-corner')).toBe(false)
})
it('FE-MOB-MAPAREA-028: on the stage the map draws the offered roads, and a tap on one only lights it', () => {
@@ -605,8 +628,8 @@ describe('MMapArea', () => {
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 })
// dock's 74 and the bar's lift. The sides keep the phone's 20.
expect(mocks.props.fitPadding).toEqual({ top: 116, right: 20, bottom: 74 + RT_ALT_BAR_LIFT, left: 20 })
const first = mocks.props.fitPadding
// Kept between renders, for the reason the stage data is.
@@ -622,13 +645,13 @@ describe('MMapArea', () => {
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 })
expect(mocks.props.fitPadding).toEqual({ top: 59 + 116, right: 20, bottom: 34 + 74 + RT_ALT_BAR_LIFT, 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 })
expect(mocks.props.fitPadding).toEqual({ top: 116, right: 20, bottom: 21 + 74 + RT_ALT_BAR_LIFT, left: 20 })
})
it('FE-MOB-MAPAREA-027: the layer sets only the lift inline, so the compass stays the one inline --bottom-nav-h', () => {
@@ -498,7 +498,7 @@ describe('MRoadtripTab', () => {
describe('map half', () => {
const mapShell = () => buildShell({ rtView: 'map' })
it('FE-MOB-RTTAB-022: renders the stage bar and nothing that would cover the map', () => {
it('FE-MOB-RTTAB-022: leaves the map to itself, with no bar of its own over it', () => {
const { container } = renderTab(planner(), mapShell())
expect((container.firstChild as HTMLElement).className).toContain('pointer-events-none')
@@ -506,201 +506,21 @@ describe('MRoadtripTab', () => {
expect(screen.queryByText('Shell Ebina')).toBeNull()
expect(screen.queryByText('roadtrip.refuel.dry')).toBeNull()
expect(screen.queryByText('roadtrip.window.stop')).toBeNull()
// A stage bar used to stand over the dock here, naming the day's destination with its
// picture, stop count, arrival and distance. The day and the distance are in the
// shell's stage header already, and on a map that fills the screen a permanent card
// over the bottom edge cost more than the three facts it added. Nothing is left of it:
// not the destination, not its badges, and not an empty wrapper over the map's own
// buttons. The picker's bar still uses that slot (FE-MOB-RTTAB-048).
expect(screen.queryByText('Kyoto Station')).toBeNull()
expect(screen.queryByText('roadtrip.day.stopCount:2')).toBeNull()
expect(screen.queryByText('412 km')).toBeNull()
expect(screen.queryByRole('button', { name: /Kyoto Station/ })).toBeNull()
// The search bar is still up top; what is gone is the wrapper down at the dock.
expect(container.querySelector('[class*="bottom-[calc(var(--bottom-nav-h"]')).toBeNull()
})
it('FE-MOB-RTTAB-023: names the last stop of the day, and badges its count, that stop\'s own arrival and the distance', () => {
renderTab(planner(), mapShell())
// The badges run their texts together in a computed name, so the bar spells it out.
const bar = screen.getByRole('button', { name: 'Kyoto Station, roadtrip.day.stopCount:2, 12:40, 412 km' })
expect(within(bar).getByText('Kyoto Station')).toBeInTheDocument()
// One badge per figure, and no middle dot left between them.
const badges = ['roadtrip.day.stopCount:2', '12:40', '412 km'].map(text => within(bar).getByText(text).closest('.rounded-full'))
expect(badges).not.toContain(null)
expect(new Set(badges).size).toBe(3)
expect(bar.textContent).not.toContain('·')
// 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).queryByText(/22:00/)).toBeNull()
// Words in caps, the unit in its own case. And neutral: the whole bar is the button,
// and a filled pill inside it would read as a second one.
expect(within(bar).getByText('roadtrip.day.stopCount:2').className).toContain('uppercase')
const distance = within(bar).getByText('412 km')
expect(distance.className).not.toContain('uppercase')
expect(distance.className).not.toContain('bg-m-act')
})
it('FE-MOB-RTTAB-024: drops the arrival from the bar when the day has none, and the pending distance with it', () => {
const untimed = stage({
distance: 0,
schedule: { entries: [0, 1, 2, 3].map(() => ({ arrival: null, departure: null, anchored: false, dayOffset: 0 })), warnings: [] },
} as unknown as Partial<RoadtripDay>)
renderTab(planner({ roadtripRoutes: routes({ days: [untimed] }) }), mapShell())
// The name leaves the missing clock out too, rather than reading an empty part or a
// placeholder between the count and the distance.
const bar = screen.getByRole('button', { name: 'Kyoto Station, roadtrip.day.stopCount:2, roadtrip.leg.pending' })
expect(within(bar).getByText('roadtrip.day.stopCount:2')).toBeInTheDocument()
expect(within(bar).getByText('roadtrip.leg.pending')).toBeInTheDocument()
})
it('FE-MOB-RTTAB-025: 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.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')).toBeInTheDocument()
expect(within(bar).getByText('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', () => {
renderTab(planner({ selectedDayId: null }), mapShell())
expect(screen.getByText('roadtrip.title')).toBeInTheDocument()
expect(screen.getByText('980 km')).toBeInTheDocument()
// Not a dead button: with no stage there is nothing for a tap to open, so the
// whole-drive line is a plain block. The search bar above it stays a button.
expect(screen.getByText('roadtrip.title').closest('button')).toBeNull()
})
it('FE-MOB-RTTAB-035: pictures the place the day ends at, from the trip store and not the filtered day lists', () => {
seedStore(useTripStore, { places: [PLACES[0], { ...PLACES[1], image_url: 'https://img.test/kyoto.jpg' }] })
// planner.places is the list the phone takes hidden service stops out of, so the
// picture must not depend on it: a day can end at a hidden campsite.
renderTab(planner({ places: [] }), mapShell())
const bar = screen.getByRole('button', { name: /Kyoto Station/ })
const img = bar.querySelector('img') as HTMLImageElement
expect(img).toHaveAttribute('src', 'https://img.test/kyoto.jpg')
// Out of the name: the photo's alt text is the title a second time.
expect(img.closest('[aria-hidden="true"]')).not.toBeNull()
expect(bar).toHaveAccessibleName('Kyoto Station, roadtrip.day.stopCount:2, 12:40, 412 km')
expect(bar.querySelector('.lucide-map-pin')).toBeNull()
})
it('FE-MOB-RTTAB-036: shows a neutral pin when no place row stands behind the stop', () => {
seedStore(useTripStore, { places: [] })
renderTab(planner(), mapShell())
const bar = screen.getByRole('button', { name: /Kyoto Station/ })
expect(bar.querySelector('img')).toBeNull()
expect(bar.firstElementChild?.querySelector('.lucide-map-pin')).not.toBeNull()
})
it('FE-MOB-RTTAB-037: a place without a photo is pictured by its category', () => {
seedStore(useTripStore, { places: [{ ...PLACES[1], category_id: 5 }] })
const categories = [{ id: 5, name: 'Station', color: '#123456', icon: 'Train' }]
renderTab(planner({ categories } as unknown as Partial<TripPlanner>), mapShell())
const ring = screen.getByRole('button', { name: /Kyoto Station/ }).firstElementChild as HTMLElement
expect(ring.querySelector('img')).toBeNull()
expect(ring.firstElementChild).toHaveStyle({ backgroundColor: '#123456' })
})
it('FE-MOB-RTTAB-038: rings the picture in the day colour only once the day colours are on', () => {
const plain = renderTab(planner(), mapShell())
const plainRing = screen.getByRole('button', { name: /Kyoto Station/ }).firstElementChild as HTMLElement
expect(plainRing).toHaveAttribute('aria-hidden', 'true')
expect(plainRing.style.borderColor).toBe('')
plain.unmount()
mocks.prefs = { roadtrip_day_colors: true }
renderTab(planner(), mapShell())
const ring = screen.getByRole('button', { name: /Kyoto Station/ }).firstElementChild as HTMLElement
// dayColor(2).line, the second of the eight, see dayColors.ts.
expect(ring).toHaveStyle({ borderColor: '#ff9f0a' })
})
it('FE-MOB-RTTAB-039: a new stage never keeps the picture of the one before', () => {
seedStore(useTripStore, { places: [PLACES[0], { ...PLACES[1], image_url: 'https://img.test/kyoto.jpg' }] })
const shell = mapShell()
const view = renderTab(planner(), shell)
expect(screen.getByRole('button', { name: /Kyoto Station/ }).querySelector('img')).not.toBeNull()
// The next stage ends at Fuji Viewpoint, which has no photo of its own. The avatar
// keeps the photo it was first handed until it remounts, which is what its key is for.
const fujiOnly = 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>)
view.rerender(<MRoadtripTab planner={planner({ roadtripRoutes: routes({ days: [fujiOnly] }) })} shell={shell} tab="roadtrip" />)
const bar = screen.getByRole('button', { name: /Fuji Viewpoint/ })
expect(bar.querySelector('img')).toBeNull()
})
it('FE-MOB-RTTAB-040: the head card and the stage bar agree on when the day arrives', () => {
const list = renderTab()
const arrive = screen.getByText('roadtrip.stay.arrive').parentElement as HTMLElement
expect(within(arrive).getByText('12:40')).toBeInTheDocument()
list.unmount()
renderTab(planner(), mapShell())
const bar = screen.getByRole('button', { name: /Kyoto Station/ })
// Same clock, and it keeps its digit order in an RTL locale like the head card's.
expect(within(bar).getByText('12:40')).toHaveAttribute('dir', 'ltr')
})
it('FE-MOB-RTTAB-041: the whole-drive bar badges a measured total and leaves a total of nothing out', () => {
const measured = renderTab(planner({ selectedDayId: null }), mapShell())
const total = screen.getByText('980 km')
expect(total.className).toContain('rounded-full')
expect(total.className).not.toContain('uppercase')
measured.unmount()
renderTab(planner({ selectedDayId: null, roadtripRoutes: routes({ totalDistance: 0 }) }), mapShell())
// Nothing routed yet is not a drive of 0 m.
expect(screen.getByText('roadtrip.title')).toBeInTheDocument()
expect(screen.queryByText('0 m')).toBeNull()
expect(screen.getByText('roadtrip.title').parentElement?.querySelector('.rounded-full')).toBeNull()
})
})
describe('the corridor search', () => {
@@ -819,21 +639,22 @@ describe('MRoadtripTab', () => {
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', () => {
it('FE-MOB-RTTAB-048: on the map the picker stands over the dock 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.
// Just above the dock, in the slot the stage bar used to hold the whole time.
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)
// Closed, that slot is empty and the map has it back: the wrapper is mounted with the
// bar rather than left lying over the map's own buttons.
const { container } = renderTab(planner(), shell)
expect(screen.queryByRole('region', { name: 'roadtrip.alt.title' })).toBeNull()
expect(screen.getByRole('button', { name: /Kyoto Station/ })).toBeInTheDocument()
expect(container.querySelector('[class*="bottom-[calc(var(--bottom-nav-h"]')).toBeNull()
expect(screen.getByText('roadtrip.poi.title')).toBeInTheDocument()
})
@@ -195,8 +195,9 @@ describe('useMRtAlternatives', () => {
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.
it('FE-MOB-RTALTH-009: the map lifts its floor by the bar and the gap it keeps from the dock', () => {
// The same 15px a bar standing over the dock keeps from it. This is the only such bar
// left: the stage bar that used to hold the slot the rest of the time is gone.
expect(RT_ALT_BAR_HEIGHT).toBe(174)
expect(RT_ALT_BAR_LIFT).toBe(189)
})