diff --git a/client/scripts/eslint-baseline.json b/client/scripts/eslint-baseline.json
index d26e795eb..1788f48cd 100644
--- a/client/scripts/eslint-baseline.json
+++ b/client/scripts/eslint-baseline.json
@@ -1,7 +1,7 @@
{
"src": {
"@typescript-eslint/no-non-null-assertion": 407,
- "@typescript-eslint/no-misused-promises": 877,
+ "@typescript-eslint/no-misused-promises": 876,
"no-console": 88,
"complexity": 231,
"@typescript-eslint/no-explicit-any": 154,
diff --git a/client/scripts/strict-baseline.json b/client/scripts/strict-baseline.json
index 4ca9d0d28..a56a72d74 100644
--- a/client/scripts/strict-baseline.json
+++ b/client/scripts/strict-baseline.json
@@ -72,7 +72,7 @@
"src/components/Roadtrip/RoadtripCorridorPanel.tsx": 4,
"src/components/Roadtrip/RoadtripSidebarBadges.tsx": 4,
"src/components/Roadtrip/RoadtripSidebarDriveBands.tsx": 2,
- "src/components/Roadtrip/RoadtripSidebarHeaders.tsx": 2,
+ "src/components/Roadtrip/RoadtripSidebarHeaders.tsx": 1,
"src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx": 5,
"src/components/Roadtrip/RoadtripTrackModal.tsx": 4,
"src/components/Roadtrip/ServiceStopSection.tsx": 2,
diff --git a/client/src/components/Planner/DayPlanSidebar.dayHead.test.tsx b/client/src/components/Planner/DayPlanSidebar.dayHead.test.tsx
index d2b8cac7e..3b7fd7dc5 100644
--- a/client/src/components/Planner/DayPlanSidebar.dayHead.test.tsx
+++ b/client/src/components/Planner/DayPlanSidebar.dayHead.test.tsx
@@ -217,6 +217,8 @@ describe('DayPlanSidebar day header', () => {
render();
expect(dayHeader('Day 1').style.alignItems).toBe('center');
expect(dayHeader('Day 1').querySelector('[data-dp="day-pill"]')).toBeNull();
- expect(dayHeader('Day 2').style.alignItems).toBe('flex-start');
+ // The head is a grid now, so the pills can sit on a row of their own under the name.
+ expect(dayHeader('Day 2').style.display).toBe('grid');
+ expect(dayHeader('Day 2').style.alignItems).toBe('start');
});
});
diff --git a/client/src/components/Planner/planParts.tsx b/client/src/components/Planner/planParts.tsx
index 5036d8bad..a55d657c2 100644
--- a/client/src/components/Planner/planParts.tsx
+++ b/client/src/components/Planner/planParts.tsx
@@ -79,7 +79,7 @@ export function MoreButton({ label, items, size = 26, alwaysVisible = false, cla
/** A time or time range as a white badge, like the other facts on a card. */
/** A small white badge on a row's second line, for what the row says about when. */
-export function WhiteBadge({ children, icon, className = '', quiet = false }: {
+export function WhiteBadge({ children, icon, className = '' }: {
children: ReactNode
icon?: ReactNode
className?: string
diff --git a/client/src/components/Roadtrip/FigureBadge.test.tsx b/client/src/components/Roadtrip/FigureBadge.test.tsx
index 7f82dc6bd..b3e00d401 100644
--- a/client/src/components/Roadtrip/FigureBadge.test.tsx
+++ b/client/src/components/Roadtrip/FigureBadge.test.tsx
@@ -11,37 +11,58 @@ const classes = (el: Element | null): string[] => (el?.getAttribute('class') ??
const of = (list: string): string[] => list.split(' ').sort()
describe('FigureBadge', () => {
- it('FE-FIGURE-BADGE-001: the neutral shell is the one the stay, the walk and the fill-up wore before', () => {
- const { container } = render(} value="240 m" />)
- const shell = container.firstElementChild!
- expect(classes(shell)).toEqual(of('inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border border-edge'))
- expect(classes(shell.children[0])).toEqual(of('flex items-center gap-0.5 px-1 bg-surface-tertiary text-content-faint'))
- expect(classes(shell.children[1])).toEqual(
- of('flex items-center border-s bg-surface-card px-1.5 font-semibold tabular-nums border-edge text-content-secondary'),
- )
- expect(shell.children[1]).toHaveTextContent('240 m')
+ it('FE-FIGURE-BADGE-001: the neutral badge is the white pill of the day plan, the word and the figure split by a hairline', () => {
+ const { container } = render(} value="240 m" />)
+ const pill = container.firstElementChild!
+ expect(classes(pill)).toEqual(of(
+ 'inline-flex h-[20px] flex-none items-center gap-1 self-start whitespace-nowrap rounded-full bg-surface-card px-2 font-geist font-normal leading-none tabular-nums shadow-sm ring-1 ring-inset ring-edge-faint',
+ ))
+ expect(classes(pill.children[0])).toEqual(of('flex min-w-0 items-center gap-1 text-content-faint'))
+ expect(pill.children[1]).toHaveAttribute('aria-hidden')
+ expect(classes(pill.children[1])).toEqual(expect.arrayContaining(['w-px', 'bg-edge']))
+ expect(classes(pill.children[2])).toEqual(of('flex items-center text-content-secondary'))
+ expect(pill.children[2]).toHaveTextContent('240 m')
})
- it('FE-FIGURE-BADGE-002: the warning tone edges both halves in the warning colour itself', () => {
- const { container } = render()
- const shell = container.firstElementChild!
- expect(shell).toHaveAttribute('dir', 'ltr')
- expect(classes(shell)).toContain('border-warning')
- expect(classes(shell.children[0])).toEqual(expect.arrayContaining(['bg-warning-soft', 'text-warning']))
- expect(classes(shell.children[1])).toEqual(expect.arrayContaining(['border-warning', 'text-warning']))
+ it('FE-FIGURE-BADGE-002: the warning tone sets both halves in the warning colour, a booking tone only its word', () => {
+ const warning = render()
+ const pill = warning.container.firstElementChild!
+ expect(pill).toHaveAttribute('dir', 'ltr')
+ expect(classes(pill.children[0])).toContain('text-warning')
+ expect(classes(pill.children[2])).toContain('text-warning')
+ warning.unmount()
+
+ const booked = render()
+ const bookedPill = booked.container.firstElementChild as Element
+ expect(classes(bookedPill.children[0])).toContain('text-success')
+ expect(classes(bookedPill.children[2])).toContain('text-content-secondary')
+ booked.unmount()
+
+ const pending = render()
+ const pendingPill = pending.container.firstElementChild as Element
+ expect(classes(pendingPill.children[0])).toContain('text-warning')
+ expect(classes(pendingPill.children[2])).toContain('text-content-secondary')
})
it('FE-FIGURE-BADGE-003: a word in the lead is a caption, a figure can be quiet, and without one the sign stands alone', () => {
const caption = render()
- const shell = caption.container.firstElementChild!
- expect(classes(shell.children[0])).toEqual(expect.arrayContaining(['font-geist', 'font-semibold', 'uppercase', 'tracking-[0.12em]']))
- expect(classes(shell.children[1])).toEqual(expect.arrayContaining(['text-content-faint']))
- expect((shell.children[1] as HTMLElement).style.fontSize).toContain('8px')
+ const pill = caption.container.firstElementChild!
+ expect(classes(pill.children[0])).toEqual(expect.arrayContaining(['uppercase', 'tracking-[.05em]']))
+ expect(classes(pill.children[2])).toEqual(expect.arrayContaining(['text-content-faint']))
caption.unmount()
- const sign = render(} />)
- expect(sign.container.firstElementChild!.children).toHaveLength(1)
+ const sign = render(} />)
+ const signPill = sign.container.firstElementChild!
+ expect(signPill.children).toHaveLength(1)
+ // Alone, the sign takes the figure's ink rather than the quiet one of a word beside a figure.
+ expect(classes(signPill.children[0])).toContain('text-content-secondary')
expect(screen.getByLabelText('Walk')).toBeInTheDocument()
+ sign.unmount()
+
+ const shrinking = render()
+ const shrinkPill = shrinking.container.firstElementChild as Element
+ expect(classes(shrinkPill)).toEqual(expect.arrayContaining(['min-w-0', 'shrink', 'overflow-hidden']))
+ expect(classes(shrinkPill)).not.toContain('flex-none')
})
it('FE-FIGURE-BADGE-004: as a control it is a focusable button role that answers the keys meaning press and keeps them from the row', () => {
diff --git a/client/src/components/Roadtrip/RoadtripSidebar.constants.test.ts b/client/src/components/Roadtrip/RoadtripSidebar.constants.test.ts
index b9f02556e..d1b09dc24 100644
--- a/client/src/components/Roadtrip/RoadtripSidebar.constants.test.ts
+++ b/client/src/components/Roadtrip/RoadtripSidebar.constants.test.ts
@@ -17,9 +17,9 @@ describe('Road trip rail styles', () => {
expect(RAIL_DASH).not.toHaveProperty('borderStyle')
})
- it('FE-ROADTRIP-RAILSTYLE-003: captions and day badges are tracked capitals in a quiet tone, and a day badge stays at medium weight', () => {
- expect(classes(STAT_LABEL)).toEqual(expect.arrayContaining(['uppercase', 'tracking-[0.15em]', 'text-content-faint']))
- expect(classes(DAY_BADGE)).toEqual(expect.arrayContaining(['uppercase', 'tracking-[0.09em]', 'font-medium', 'text-content-muted']))
+ it('FE-ROADTRIP-RAILSTYLE-003: captions are the eyebrow of the dialogs, and a day badge is the round pill of the day plan in a quiet tone at normal weight', () => {
+ expect(classes(STAT_LABEL)).toEqual(expect.arrayContaining(['uppercase', 'tracking-[.08em]', 'font-bold', 'text-content-faint']))
+ expect(classes(DAY_BADGE)).toEqual(expect.arrayContaining(['rounded-full', 'uppercase', 'tracking-[.05em]', 'font-normal', 'text-content-secondary']))
expect(classes(DAY_BADGE)).not.toContain('font-semibold')
})
})
diff --git a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx
index 9a4638ba7..cfc5aa66a 100644
--- a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx
+++ b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx
@@ -135,20 +135,30 @@ describe('RoadtripSidebar', () => {
expect(total('Stops')).toBe('5')
})
- it('FE-ROADTRIP-SIDEBAR-083: pulled narrow, the rail stops counting stops, in its head and on every day', () => {
+ it('FE-ROADTRIP-SIDEBAR-083: pulled narrow, the head stops counting stops, and a day gives its count up once its own row runs out of room', () => {
const wide = wrap()
expect(screen.getByText('2 stops')).toBeInTheDocument()
wide.unmount()
railWidth.value = 300
- wrap()
+ const narrow = wrap()
expect(total('Distance')).toBe('250 km')
expect(total('Driving time')).toBe('2 h 30 min')
const head = screen.getByText('Distance').closest('header')!
- // The figure goes with its hairline, so the two left share the width.
+ // The tile goes, so the two left share the width.
expect(within(head).queryByText('Stops')).toBeNull()
- expect(head.querySelectorAll('span.w-px')).toHaveLength(1)
- expect(screen.queryByText('2 stops')).toBeNull()
+ expect(head.firstElementChild!.children).toHaveLength(2)
+ // The day measures its own pill row, and at this width its count still fits.
+ expect(screen.getByText('2 stops')).toBeInTheDocument()
+ narrow.unmount()
+
+ // Narrower still, the count keeps only its figure and the drive only its distance.
+ railWidth.value = 150
+ wrap()
+ const dayHead = screen.getByRole('heading', { name: 'Day 1' }).closest('header')!
+ expect(within(dayHead).queryByText('2 stops')).toBeNull()
+ expect(within(dayHead).getByLabelText('2 stops')).toHaveTextContent(/^2$/)
+ expect(within(dayHead).getByText('100 km')).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-002: chains the stops with the drive between them', () => {
@@ -669,14 +679,14 @@ describe('RoadtripSidebar', () => {
expect(onFollowTrack).toHaveBeenCalledWith(1)
// The rail draws no vias, so the tint is the only place a drive shaped by hand
// differs from one the router picked on its own.
- expect(badge.className).toContain('bg-accent-subtle')
+ expect(badge).toHaveClass('bg-accent')
rerender(
,
)
- expect(screen.getByRole('button', { name: 'Track' }).className).not.toContain('bg-accent-subtle')
+ expect(screen.getByRole('button', { name: 'Track' })).not.toHaveClass('bg-accent')
})
it('FE-ROADTRIP-SIDEBAR-019: a viewer is offered no track badge at all', () => {
@@ -1160,21 +1170,19 @@ describe('RoadtripSidebar with a ride (#2428)', () => {
})
}
- it('FE-ROADTRIP-SIDEBAR-049: a same-day ride is one block: the booking and its minutes, both codes with their timetable, the places and the check-in, and no number', () => {
+ it('FE-ROADTRIP-SIDEBAR-049: a same-day ride is one card: the booking, both timetable clocks, the codes as one route pill and the check-in, and no number', () => {
wrap()
expect(screen.getByText('LH 2020')).toBeInTheDocument()
- expect(screen.getByText('1 h 10 min')).toBeInTheDocument()
- expect(screen.queryByText('LH 2020 · 1 h 10 min')).toBeNull()
- // Each code, each timetable clock and each place once.
+ // The minutes the ride takes are left to the booking.
+ expect(screen.queryByText('1 h 10 min')).toBeNull()
+ // Each code once, in the route pill, and both timetable clocks once, in the arrival column.
expect(screen.getAllByText('HAM')).toHaveLength(1)
expect(screen.getAllByText('MUC')).toHaveLength(1)
- expect(screen.getAllByText('13:20')).toHaveLength(1)
- expect(screen.getAllByText('14:30')).toHaveLength(1)
- expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
- expect(screen.getByText('Munich Airport')).toBeInTheDocument()
- // What the clocks are is said to a screen reader, not printed a second time.
- expect(screen.getByText('Departure 13:20')).toHaveClass('sr-only')
- expect(screen.getByText('Arrival 14:30')).toHaveClass('sr-only')
+ expect(screen.getByText('HAM').parentElement).toContainElement(screen.getByText('MUC'))
+ expect(screen.getAllByText('13:20 \u2013 14:30')).toHaveLength(1)
+ // The places stand behind the route pill as its tooltip, not as lines of their own.
+ expect(screen.queryByText('Hamburg Airport')).toBeNull()
+ expect(screen.queryByText('Munich Airport')).toBeNull()
// The check-in, labelled, where an unlabelled 12:20 used to stand in the arrival column.
expect(screen.getByText('Check-in')).toBeInTheDocument()
expect(screen.getByText('12:20')).toBeInTheDocument()
@@ -1184,13 +1192,13 @@ describe('RoadtripSidebar with a ride (#2428)', () => {
expect(screen.getByText('2 stops')).toBeInTheDocument()
})
- it('FE-ROADTRIP-SIDEBAR-050: the ride block opens the booking; the road out of the arrival offers no other ways', () => {
+ it('FE-ROADTRIP-SIDEBAR-050: the ride card opens the booking; the road out of the arrival offers no other ways', () => {
const onOpenBooking = vi.fn()
const onAskAlternatives = vi.fn()
wrap()
// One control for the whole ride, not one per terminal and one for the band.
expect(screen.getAllByRole('button', { name: /Open booking$/ })).toHaveLength(1)
- fireEvent.click(screen.getByText('Hamburg Airport'))
+ fireEvent.click(screen.getByText('HAM'))
fireEvent.click(screen.getByText('LH 2020'))
expect(onOpenBooking).toHaveBeenCalledTimes(2)
expect(onOpenBooking).toHaveBeenCalledWith(70)
@@ -1329,7 +1337,8 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
const drop = screen.getByText('Sixt Airport').closest('button')!
expect(within(drop).getByText('Return')).toBeInTheDocument()
expect(within(drop).getAllByText('11:30')).toHaveLength(1)
- expect(within(drop).getByText('HAM')).toBeInTheDocument()
+ // The desk word takes the pill a terminal's code would, so the code is not said beside it.
+ expect(within(drop).queryByText('HAM')).toBeNull()
// A desk has no check-in.
expect(screen.queryByText('Check-in')).toBeNull()
expect(screen.queryByText('3')).not.toBeInTheDocument()
@@ -1350,7 +1359,7 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
expect(within(rows[0] as HTMLElement).getByText('Tank runs out here')).toBeInTheDocument()
})
- it('FE-ROADTRIP-SIDEBAR-053: a table pinned to a stop, or booked at its place on its day, hangs under the stop as a chip that opens it; one for no stop is listed under the day', () => {
+ it('FE-ROADTRIP-SIDEBAR-053: a table pinned to a stop, or booked at its place on its day, sits on the stop as a pill that opens it; one for no stop is listed under the day', () => {
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' })]
const reservations = [
booking({ id: 11, title: 'Tisch Bullerei', reservation_time: '2026-10-05T19:30', assignment_id: 1 }),
@@ -1361,21 +1370,22 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
]
const onOpenBooking = vi.fn()
wrap()
- expect(screen.getByText('Tisch Bullerei')).toBeInTheDocument()
- expect(screen.getByText('19:30')).toBeInTheDocument()
- expect(screen.getByText('Café Niederegger')).toBeInTheDocument()
+ // Each booking is a pill on its stop's own badge row, named by its title and status.
+ const table = within(screen.getByText('Hamburg').closest('li') as HTMLElement).getByRole('button', { name: 'Tisch Bullerei, Confirmed' })
+ expect(table).toHaveTextContent('19:30')
+ expect(within(screen.getByText('Lübeck').closest('li') as HTMLElement).getByRole('button', { name: 'Café Niederegger, Confirmed' })).toBeInTheDocument()
expect(screen.getByText('Also booked this day')).toBeInTheDocument()
- expect(screen.getByText('Elbphilharmonie')).toBeInTheDocument()
- // Another day's booking is not this day's, and the night is the stay, not a chip.
- expect(screen.queryByText('Tomorrow')).not.toBeInTheDocument()
- expect(screen.queryByText('Hotel Hafen')).not.toBeInTheDocument()
- fireEvent.click(screen.getByText('Tisch Bullerei'))
+ expect(screen.getByRole('button', { name: 'Elbphilharmonie, Confirmed' })).toBeInTheDocument()
+ // Another day's booking is not this day's, and the night is the stay, not a pill.
+ expect(screen.queryByRole('button', { name: /^Tomorrow/ })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: /^Hotel Hafen/ })).not.toBeInTheDocument()
+ fireEvent.click(table)
expect(onOpenBooking).toHaveBeenCalledWith(11)
- // The chips are the stop's, not stops: the count stays at two.
+ // The pills are the stop's, not stops: the count stays at two.
expect(screen.getByText('2 stops')).toBeInTheDocument()
})
- it('FE-ROADTRIP-SIDEBAR-058: for a reader who may not edit bookings, a table is a plain chip while a taxi still opens its detail view', () => {
+ it('FE-ROADTRIP-SIDEBAR-058: for a reader who may not edit bookings, a table is a plain pill while a taxi still opens its detail view', () => {
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Lübeck' })]
const reservations = [
booking({ id: 11, title: 'Tisch Bullerei', reservation_time: '2026-10-05T19:30', assignment_id: 1 }),
@@ -1384,11 +1394,12 @@ describe('RoadtripSidebar with a hire car and the day\'s bookings (#2428)', () =
const onOpenBooking = vi.fn()
wrap()
// The table is there to read, not a button that does nothing; the taxi is a button.
- expect(screen.getByText('Tisch Bullerei').closest('button')).toBeNull()
- expect(screen.getByText('Taxi zum Hafen').closest('button')).not.toBeNull()
- fireEvent.click(screen.getByText('Tisch Bullerei'))
+ expect(screen.queryByRole('button', { name: /Tisch Bullerei/ })).toBeNull()
+ const taxi = screen.getByRole('button', { name: 'Taxi zum Hafen, Confirmed' })
+ expect(taxi).toHaveTextContent('18:30')
+ fireEvent.click(screen.getByText('19:30'))
expect(onOpenBooking).not.toHaveBeenCalled()
- fireEvent.click(screen.getByText('Taxi zum Hafen'))
+ fireEvent.click(taxi)
expect(onOpenBooking).toHaveBeenCalledWith(12)
})
})
@@ -1739,78 +1750,78 @@ describe('RoadtripSidebar with a flight the drive gets to late (#2460)', () => {
},
})
}
- const block = () => screen.getByText('LH 2078 HAM-MUC (ohne Endpunkte)').closest('button')!.children[1] as HTMLElement
+ const TITLE = 'LH 2078 HAM-MUC (ohne Endpunkte)'
+ /** The ride is one card, and the card is the button that opens its booking. */
+ const card = () => screen.getByText(TITLE).closest('button')!
- it('FE-ROADTRIP-SIDEBAR-075: a missed flight says so on its check-in and on the day, with no delta against a clock nobody sees', () => {
+ it('FE-ROADTRIP-SIDEBAR-075: a flight the drive cannot catch takes no check-in pill and no missed pill, and the card keeps its title as typed and each code once', () => {
wrap()
- // The title as typed, the duration beside the check-in, each code once and the places without it.
- const title = screen.getByText('LH 2078 HAM-MUC (ohne Endpunkte)')
+ const title = screen.getByText(TITLE)
expect(title.className).not.toContain('uppercase')
- expect(within(block()).getByText('Missed').closest('.flex-wrap')).toHaveTextContent('2 h 5 min')
- expect(title.parentElement).not.toHaveTextContent('2 h 5 min')
+ expect(within(card()).getByText('15:15 \u2013 17:20')).toBeInTheDocument()
expect(screen.getAllByText('HAM')).toHaveLength(1)
- expect(screen.getByText('Hamburg')).toBeInTheDocument()
- expect(screen.getByText('Munich')).toBeInTheDocument()
+ expect(screen.getAllByText('MUC')).toHaveLength(1)
expect(screen.queryByText(/\(HAM\)/)).toBeNull()
- // The check-in carries the finding; the separate late pill is gone.
- expect(within(block()).getByText('Missed')).toBeInTheDocument()
- expect(within(block()).getByText('there at 23:54')).toBeInTheDocument()
+ // Neither the check-in nor a finding about it, and no delta against a clock nobody sees.
+ expect(screen.queryByText('Check-in')).toBeNull()
+ expect(screen.queryByText('Missed')).toBeNull()
+ expect(screen.queryByText('there at 23:54')).toBeNull()
expect(screen.queryByText('+9 h 39 min')).toBeNull()
- expect(block().className).toContain('border-warning')
+ expect(screen.queryByText('2 h 5 min')).toBeNull()
const header = screen.getByText('Day 3').closest('header')!
- expect(within(header).getByText('Flight missed')).toBeInTheDocument()
+ expect(within(header).queryByText('Flight missed')).toBeNull()
})
- it('FE-ROADTRIP-SIDEBAR-076: a folded day still says its flight is missed', () => {
+ it('FE-ROADTRIP-SIDEBAR-076: a folded day keeps its head and its drive, with no missed pill', () => {
wrap()
const header = screen.getByText('Day 3').closest('header')!
- expect(within(header).getByText('Flight missed')).toBeInTheDocument()
+ expect(header).toHaveAttribute('aria-expanded', 'false')
+ expect(within(header).getByText(/km in/)).toBeInTheDocument()
+ expect(within(header).queryByText('Flight missed')).toBeNull()
})
- it('FE-ROADTRIP-SIDEBAR-077: late for the check-in but in time for the flight says by how much, and leaves the day alone', () => {
+ it('FE-ROADTRIP-SIDEBAR-077: late for the check-in but in time for the flight leaves both the card and the day without a pill', () => {
wrap()
- expect(within(block()).getByText('Check-in')).toBeInTheDocument()
- expect(within(block()).getByText('25 min late')).toBeInTheDocument()
- expect(block().className).not.toContain('border-warning')
+ expect(within(card()).queryByText('Check-in')).toBeNull()
+ expect(within(card()).queryByText('25 min late')).toBeNull()
expect(screen.queryByText('Flight missed')).toBeNull()
})
- it('FE-ROADTRIP-SIDEBAR-078: a train is boarded, and a missed one is named as a train', () => {
+ it('FE-ROADTRIP-SIDEBAR-078: a train in time is boarded, and a missed one says nothing on the card', () => {
const onTime = wrap()
expect(screen.getByText('Boarding')).toBeInTheDocument()
expect(screen.getByText('14:15')).toBeInTheDocument()
onTime.unmount()
wrap()
- expect(within(screen.getByText('Day 3').closest('header')!).getByText('Train missed')).toBeInTheDocument()
+ expect(screen.queryByText('Boarding')).toBeNull()
+ expect(within(screen.getByText('Day 3').closest('header')!).queryByText('Train missed')).toBeNull()
})
- it('FE-ROADTRIP-SIDEBAR-079: the badge explains itself in a sentence, and a booking clock names what it is, not a time you set', () => {
+ it('FE-ROADTRIP-SIDEBAR-079: the check-in explains itself in a sentence, and the booking clocks name what they are, not a time you set', () => {
vi.useFakeTimers()
try {
- wrap()
+ wrap()
const hover = (el: HTMLElement) => {
fireEvent.mouseEnter(el)
act(() => { vi.advanceTimersByTime(300) })
}
- hover(screen.getByText('there at 23:54').parentElement!)
- expect(screen.getByRole('tooltip')).toHaveTextContent(
- 'The drive reaches Hamburg at 23:54. LH 2078 HAM-MUC (ohne Endpunkte) departs at 15:15, check-in closes at 14:15.',
- )
- fireEvent.mouseLeave(screen.getByText('there at 23:54').parentElement!)
+ hover(screen.getByText('14:15').parentElement!)
+ expect(screen.getByRole('tooltip')).toHaveTextContent(`${TITLE} departs at 15:15, check-in closes at 14:15.`)
+ fireEvent.mouseLeave(screen.getByText('14:15').parentElement!)
- hover(screen.getByText('15:15').parentElement!)
- expect(screen.getByRole('tooltip')).toHaveTextContent('Departure 15:15')
+ hover(screen.getByText('15:15 \u2013 17:20'))
+ expect(screen.getByRole('tooltip')).toHaveTextContent('Departure 15:15, Arrival 17:20')
expect(screen.queryByText('Time you set')).toBeNull()
} finally {
vi.useRealTimers()
}
})
- it('FE-ROADTRIP-SIDEBAR-080: a screen reader hears the ride, its clocks and that it is missed, then what pressing it does', () => {
+ it('FE-ROADTRIP-SIDEBAR-080: a screen reader hears the ride, its clocks and its route, then what pressing it does', () => {
wrap()
- const button = screen.getByText('LH 2078 HAM-MUC (ohne Endpunkte)').closest('button')!
+ const button = card()
expect(button).not.toHaveAttribute('aria-label')
- expect(button).toHaveAccessibleName(expect.stringMatching(/Departure 15:15\s*Arrival 17:20.*Missed\s*there at 23:54\s*2 h 5 min\s*Open booking$/))
+ expect(button).toHaveAccessibleName(expect.stringMatching(/^LH 2078 HAM-MUC \(ohne Endpunkte\)\s*15:15\s*\u2013\s*17:20\s*HAM\s*MUC\s*Open booking$/))
})
})
diff --git a/client/src/components/Roadtrip/RoadtripSidebarBookingRows.test.tsx b/client/src/components/Roadtrip/RoadtripSidebarBookingRows.test.tsx
index cbc6d9dee..1640420f1 100644
--- a/client/src/components/Roadtrip/RoadtripSidebarBookingRows.test.tsx
+++ b/client/src/components/Roadtrip/RoadtripSidebarBookingRows.test.tsx
@@ -49,8 +49,15 @@ const booking = (over: Partial & Pick row.firstElementChild!
const lines = (row: Element): number => railOf(row).querySelectorAll(':scope > .w-\\[1\\.5px\\]').length
+/**
+ * A booking card keeps its rail beside the button rather than inside it: both line
+ * stubs are always drawn, and only the ones that carry the chain take the edge colour.
+ */
+const cardLines = (card: Element): number =>
+ ((card.parentElement as Element).firstElementChild as Element).querySelectorAll(':scope > .w-\\[1\\.5px\\].bg-edge').length
+
describe('TerminalStop', () => {
- it('FE-ROADTRIP-BOOKINGROWS-001: a departure on a row of its own reads its code, its timetable clock and the check-in, and opens the booking', () => {
+ it('FE-ROADTRIP-BOOKINGROWS-001: a departure on a row of its own reads its place, its code as a pill, its timetable clock and the check-in, and opens the booking', () => {
const onOpen = vi.fn()
const { container } = wrap(
{
onOpen={onOpen}
/>,
)
- const row = screen.getByRole('button')
- expect(row).toBeEnabled()
+ const card = screen.getByRole('button')
+ expect(card).toBeEnabled()
expect(screen.getByText('HAM')).toBeInTheDocument()
expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
// The timetable's clock, its day carry and its words for a screen reader.
@@ -72,28 +79,56 @@ describe('TerminalStop', () => {
expect(screen.getByText('Check-in')).toBeInTheDocument()
expect(screen.getByText('13:15')).toBeInTheDocument()
expect(container.querySelector('svg.lucide-plane')).not.toBeNull()
- // What pressing it does comes after the row's own content.
- expect(row.lastElementChild).toHaveTextContent('Open booking')
- expect(row.lastElementChild).toHaveClass('sr-only')
- expect(lines(row)).toBe(2)
- expect(tooltipOf(screen.getByText('Hamburg Airport'))).toBe('Open booking')
- fireEvent.click(row)
+ // What pressing it does comes after the card's own content.
+ expect(card.lastElementChild).toHaveTextContent('Open booking')
+ expect(card.lastElementChild).toHaveClass('sr-only')
+ expect(cardLines(card)).toBe(2)
+ fireEvent.click(card)
expect(onOpen).toHaveBeenCalledTimes(1)
})
+ it('FE-ROADTRIP-BOOKINGROWS-001b: with its booking at hand the card reads the booking title, as the day plan does', () => {
+ wrap(
+ ,
+ )
+ expect(screen.getByText('Lufthansa to Munich')).toBeInTheDocument()
+ expect(screen.queryByText('Hamburg Airport')).toBeNull()
+ expect(screen.getByText('HAM')).toBeInTheDocument()
+ })
+
+ it('FE-ROADTRIP-BOOKINGROWS-001c: a check-in the drive cannot make takes no pill on the card, neither the check-in nor that it is missed', () => {
+ wrap(
+ ,
+ )
+ expect(screen.queryByText('Check-in')).toBeNull()
+ expect(screen.queryByText('Missed')).toBeNull()
+ expect(screen.getByText('HAM')).toBeInTheDocument()
+ })
+
it('FE-ROADTRIP-BOOKINGROWS-002: a hire desk says which one it is, falls back to the arrival clock and stays inert without a booking to open', () => {
const desk = carrier({ type: 'car', role: 'pickup', code: null, at: null, title: 'Sixt' })
const first = wrap(
,
)
- const row = screen.getByRole('button')
- expect(row).toBeDisabled()
+ const card = screen.getByRole('button')
+ expect(card).toBeDisabled()
expect(screen.getByText('Pickup')).toBeInTheDocument()
expect(screen.getByText('09:40')).toBeInTheDocument()
expect(screen.queryByText('Open booking')).toBeNull()
expect(first.container.querySelector('svg.lucide-car')).not.toBeNull()
// The first row of the day on the last stop of it: no line either way.
- expect(lines(row)).toBe(0)
+ expect(cardLines(card)).toBe(0)
first.unmount()
// Neither a timetable nor a drive arrival: the clock column stays empty.
@@ -158,7 +193,7 @@ describe('RideBlock', () => {
})
const seg = { distance: 600000, duration: 4500, durationText: '1 h 15 min', mode: 'flight' } as RouteSegment
- it('FE-ROADTRIP-BOOKINGROWS-005: a ride on one day is one block: its title, both ends with their clocks, the places, the check-in and the minutes', () => {
+ it('FE-ROADTRIP-BOOKINGROWS-005: a ride on one day is one card: its title, both clocks in the arrival column, the route as one pill and the check-in', () => {
const onOpen = vi.fn()
wrap(
{
onOpen={onOpen}
/>,
)
- const row = screen.getByRole('button')
- const block = row.children[1]
- expect(block).toHaveClass('border-edge-faint')
+ const card = screen.getByRole('button')
expect(screen.getByText('LH 2078')).toBeInTheDocument()
- expect(screen.getByText('HAM')).toBeInTheDocument()
- expect(screen.getByText('MUC')).toBeInTheDocument()
- expect(screen.getByText('Departure 15:15')).toHaveClass('sr-only')
- expect(screen.getByText('Arrival 16:30')).toHaveClass('sr-only')
- expect(screen.getByText('Hamburg Airport')).toBeInTheDocument()
- expect(screen.getByText('Munich Airport')).toHaveClass('text-end')
+ // Both ends in one route pill, the places behind it as its tooltip.
+ const route = screen.getByText('HAM').parentElement!
+ expect(route).toContainElement(screen.getByText('MUC'))
+ expect(tooltipOf(route.closest('.rounded-full') as Element)).toBe('Hamburg Airport \u2192 Munich Airport')
+ const clock = screen.getByText('15:15 \u2013 16:30')
+ expect(tooltipOf(clock)).toBe('Departure 15:15, Arrival 16:30')
expect(screen.getByText('Check-in')).toBeInTheDocument()
- expect(screen.getByText('1 h 15 min')).toBeInTheDocument()
- expect(tooltipOf(screen.getByText('1 h 15 min').parentElement!)).toBe('Duration per the booking')
- expect(tooltipOf(screen.getByText('LH 2078'))).toBe('Open booking')
- fireEvent.click(row)
+ expect(screen.getByText('13:15')).toBeInTheDocument()
+ // The minutes the ride takes are the booking's business, not the rail's.
+ expect(screen.queryByText('1 h 15 min')).toBeNull()
+ expect(card.lastElementChild).toHaveTextContent('Open booking')
+ expect(card.lastElementChild).toHaveClass('sr-only')
+ fireEvent.click(card)
expect(onOpen).toHaveBeenCalledTimes(1)
})
- it('FE-ROADTRIP-BOOKINGROWS-006: a ride the drive cannot catch edges the block in the warning colour, its check-in standing alone without minutes', () => {
+ it('FE-ROADTRIP-BOOKINGROWS-005b: with its booking at hand the card reads the booking title', () => {
+ wrap(
+ ,
+ )
+ expect(screen.getByText('Hamburg to Munich')).toBeInTheDocument()
+ expect(screen.queryByText('LH 2078')).toBeNull()
+ })
+
+ it('FE-ROADTRIP-BOOKINGROWS-006: a ride the drive cannot catch shows neither its check-in nor that it is missed on its card', () => {
wrap(
{
starts
/>,
)
- const row = screen.getByRole('button')
- expect(row).toBeDisabled()
- expect(row.children[1]).toHaveClass('border-warning')
- expect(screen.getByText('Missed')).toBeInTheDocument()
- expect(screen.getByText('there at 16:15')).toBeInTheDocument()
- // No minutes on the timetable: the check-in stands alone.
- expect(screen.getByText('Missed').closest('.flex-wrap')!.children).toHaveLength(1)
- expect(lines(row)).toBe(0)
+ const card = screen.getByRole('button')
+ expect(card).toBeDisabled()
+ expect(screen.queryByText('Missed')).toBeNull()
+ expect(screen.queryByText('Check-in')).toBeNull()
+ // The route pill stands alone on the card's second line.
+ expect(((screen.getByText('HAM').closest('.rounded-full') as Element).parentElement as Element).children).toHaveLength(1)
+ expect(cardLines(card)).toBe(0)
})
- it('FE-ROADTRIP-BOOKINGROWS-007: a booking with no codes, no clocks and no minutes keeps its strip and drops the lines it has nothing for', () => {
+ it('FE-ROADTRIP-BOOKINGROWS-007: a booking with no codes, no clocks and no minutes keeps its title and the route between the names', () => {
wrap(
{
continues
/>,
)
- const block = screen.getByRole('button').children[1]
- // Title and strip only: no place line under chips that already are the names, and no
- // check-in or minutes row.
- expect(block.children).toHaveLength(2)
- const strip = block.children[1]
- expect(strip.children).toHaveLength(5)
- expect(strip.children[0]).toHaveTextContent('Kiel')
- expect(strip.children[1]).toBeEmptyDOMElement()
- expect(strip.children[3]).toBeEmptyDOMElement()
- expect(strip.children[4]).toHaveTextContent('Oslo')
+ const card = screen.getByRole('button')
+ // The title line and the pill line, and no sentence for a screen reader without a booking to open.
+ expect(card.children).toHaveLength(2)
+ const [titleLine, pills] = Array.from(card.children)
+ // No clock beside the title.
+ expect(titleLine.children).toHaveLength(1)
+ expect(titleLine).toHaveTextContent('Color Line')
+ expect(pills.children).toHaveLength(1)
+ expect(screen.getByText('Kiel').parentElement).toContainElement(screen.getByText('Oslo'))
})
- it('FE-ROADTRIP-BOOKINGROWS-008: the minutes stand alone when the timetable gives no check-in', () => {
+ it('FE-ROADTRIP-BOOKINGROWS-008: a timetable with only a departure reads that clock alone, with no check-in and no minutes', () => {
wrap(
{
/>,
)
expect(screen.queryByText('Check-in')).toBeNull()
- expect(screen.getByText('1 h 15 min')).toBeInTheDocument()
+ expect(screen.queryByText('1 h 15 min')).toBeNull()
expect(screen.getByText('14:00')).toBeInTheDocument()
})
})
describe('BookingChips', () => {
const table = booking({ id: 1, type: 'restaurant', title: 'Fischereihafen', reservation_time: '2026-06-01T19:30' })
- const train = booking({ id: 2, type: 'train', title: 'ICE 1007' })
+ const train = booking({ id: 2, type: 'train', title: 'ICE 1007', status: 'confirmed' })
- it('FE-ROADTRIP-BOOKINGROWS-009: a chip opens when this reader may open it, and the rest stay plain chips', () => {
+ it('FE-ROADTRIP-BOOKINGROWS-009: a pill opens when this reader may open it, and the rest stay plain pills that name their booking in the tooltip', () => {
const onOpen = vi.fn()
const { container } = wrap()
+ // Each pill says it is a reservation and carries its time; the title is in its name.
+ expect(screen.getAllByText('Reservation')).toHaveLength(2)
expect(screen.getByText('19:30')).toBeInTheDocument()
// The table has only its editor, which this reader may not open; the train has a view.
- expect(screen.getByText('Fischereihafen').closest('button')).toBeNull()
- const ride = screen.getByRole('button')
- expect(ride).toHaveTextContent('ICE 1007')
+ expect(screen.queryByRole('button', { name: /Fischereihafen/ })).toBeNull()
+ expect(tooltipOf(screen.getByText('19:30').parentElement!)).toBe('Fischereihafen (Pending)')
+ const ride = screen.getByRole('button', { name: 'ICE 1007, Confirmed' })
fireEvent.click(ride)
expect(onOpen).toHaveBeenCalledWith(2)
expect(container.querySelector('.w-\\[1\\.5px\\]')).not.toBeNull()
})
- it('FE-ROADTRIP-BOOKINGROWS-010: an editor opens every chip, and without a way to open any they are all plain', () => {
+ it('FE-ROADTRIP-BOOKINGROWS-009b: the word takes the status colour, green once confirmed and amber while pending', () => {
+ wrap()
+ const [pending, confirmed] = screen.getAllByText('Reservation')
+ expect(pending).toHaveClass('text-warning')
+ expect(confirmed).toHaveClass('text-success')
+ })
+
+ it('FE-ROADTRIP-BOOKINGROWS-010: an editor opens every pill, and without a way to open any they are all plain', () => {
const onOpen = vi.fn()
const editor = wrap()
expect(screen.getAllByRole('button')).toHaveLength(2)
- fireEvent.click(screen.getByText('Fischereihafen'))
+ fireEvent.click(screen.getByRole('button', { name: 'Fischereihafen, Pending' }))
expect(onOpen).toHaveBeenCalledWith(1)
expect(editor.container.querySelector('.w-\\[1\\.5px\\]')).toBeNull()
editor.unmount()
wrap()
expect(screen.queryByRole('button')).toBeNull()
- expect(screen.getByText('ICE 1007')).toBeInTheDocument()
+ expect(screen.getAllByText('Reservation')).toHaveLength(2)
})
it('FE-ROADTRIP-BOOKINGROWS-011: the clock follows the traveller\'s format', () => {
diff --git a/client/src/components/Roadtrip/RoadtripSidebarBookingRows.tsx b/client/src/components/Roadtrip/RoadtripSidebarBookingRows.tsx
index 276619ef4..70e1793f9 100644
--- a/client/src/components/Roadtrip/RoadtripSidebarBookingRows.tsx
+++ b/client/src/components/Roadtrip/RoadtripSidebarBookingRows.tsx
@@ -345,7 +345,7 @@ export function ReservationPill({ booking, arrival, canEdit, onOpen }: {
}): React.ReactElement {
const { t } = useTranslation()
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
- const Icon = bookingIcon(booking.type)
+ const icon = React.createElement(bookingIcon(booking.type), { size: 10, strokeWidth: 2.2, 'aria-hidden': true })
const confirmed = booking.status === 'confirmed'
const status = confirmed ? t('reservations.confirmed') : t('reservations.pending')
const clock = bookingClock(booking)
@@ -357,7 +357,7 @@ export function ReservationPill({ booking, arrival, canEdit, onOpen }: {
{t('places.formReservation')}>}
+ lead={<>{icon}{t('places.formReservation')}>}
value={shownTime ?? undefined}
tooltip={`${booking.title} (${status})`}
onActivate={open ? () => open() : undefined}
diff --git a/client/src/components/Roadtrip/RoadtripSidebarHeaders.test.tsx b/client/src/components/Roadtrip/RoadtripSidebarHeaders.test.tsx
index 045de6dd9..f925c4d4a 100644
--- a/client/src/components/Roadtrip/RoadtripSidebarHeaders.test.tsx
+++ b/client/src/components/Roadtrip/RoadtripSidebarHeaders.test.tsx
@@ -4,6 +4,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
import { TranslationProvider } from '../../i18n'
import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
import type { RouteSegment } from '../../types'
+import { useSettingsStore } from '../../store/settingsStore'
import { DayHeader, TripSummary } from './RoadtripSidebarHeaders'
import { preferences, resetRailStores, seedRailSettings, tooltipOf } from '../../../tests/helpers/roadtripRail'
@@ -44,7 +45,7 @@ function routes(over: Partial = {}): RoadtripRoutes {
}
describe('TripSummary', () => {
- it('FE-ROADTRIP-HEADERS-001: three figures under their captions, the whole numbers set apart from the units and decimals', () => {
+ it('FE-ROADTRIP-HEADERS-001: three figures on tiles under their captions, the whole numbers set apart from the units and decimals', () => {
const { container } = wrap()
const value = (caption: string): Element => screen.getByText(caption).parentElement!.lastElementChild!
expect(value('Distance')).toHaveTextContent('691.6 km')
@@ -54,50 +55,97 @@ describe('TripSummary', () => {
expect([...value('Distance').querySelectorAll('.text-content-muted')].map(u => u.textContent)).toEqual(['.6', ' km'])
expect([...value('Driving time').querySelectorAll('.text-content-muted')].map(u => u.textContent)).toEqual([' h ', ' min'])
expect(value('Stops').children).toHaveLength(0)
- expect(container.querySelectorAll('span.w-px')).toHaveLength(2)
+ // One tile per figure, in as many equal columns.
+ const grid = (container.querySelector('header') as HTMLElement).firstElementChild as HTMLElement
+ expect(grid.children).toHaveLength(3)
+ expect(grid.style.gridTemplateColumns).toBe('repeat(3, minmax(0, 1fr))')
expect(screen.queryByText('Still working out the rest of the drive')).toBeNull()
})
it('FE-ROADTRIP-HEADERS-002: pulled narrow the stops give way, and while legs still land the figures say they are partial', () => {
const { container } = wrap()
expect(screen.queryByText('Stops')).toBeNull()
- expect(container.querySelectorAll('span.w-px')).toHaveLength(1)
+ expect(((container.querySelector('header') as HTMLElement).firstElementChild as HTMLElement).children).toHaveLength(2)
expect(container.querySelector('header')!.lastElementChild).toHaveTextContent('Still working out the rest of the drive')
})
})
describe('DayHeader', () => {
- it('FE-ROADTRIP-HEADERS-003: the day reads its number, its title, its date, its drive and its stops', () => {
+ /** The width the pill row measures, so a case can pull the rail narrow. */
+ const rowWidth = (px: number) => vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(px)
+ afterEach(() => { vi.restoreAllMocks() })
+
+ it('FE-ROADTRIP-HEADERS-003: the day reads its number and short date on the tile, its title, its drive and its stops', () => {
const { container } = wrap()
const header = container.querySelector('header')!
expect(container.firstElementChild).toBe(header)
- expect(screen.getByRole('heading', { name: 'Day 2' }).parentElement).toBe(header)
- expect(screen.getByText('Along the coast')).toBeInTheDocument()
- expect(container.querySelector('time')).toHaveAttribute('dateTime', '2026-06-02')
+ // A day with a title of its own reads the title as its heading; the number is on the tile.
+ expect(screen.getByRole('heading', { name: 'Along the coast' })).toBeInTheDocument()
+ const tile = header.firstElementChild as HTMLElement
+ expect(tile.firstElementChild).toHaveTextContent('2')
+ const time = container.querySelector('time') as HTMLElement
+ expect(time).toHaveAttribute('dateTime', '2026-06-02')
+ // The tile holds the short form; the full date is what a screen reader hears.
+ expect(time).toHaveTextContent('2.6.')
+ expect(time).toHaveAttribute('aria-label')
+ expect(time.getAttribute('aria-label')).not.toBe('2.6.')
expect(screen.getByText('152 km in 1 h 38 min')).toBeInTheDocument()
expect(screen.getByText('2 stops')).toBeInTheDocument()
- // Read-only: no control, and the bottom rule over the list below.
+ // Read-only: no control, no chevron, and the head band's neutral wash.
expect(header).not.toHaveAttribute('role')
- expect(header).toHaveClass('mb-1', 'border-b')
- expect(header).not.toHaveAttribute('style')
+ expect(container.querySelector('svg.lucide-chevron-down, svg.lucide-chevron-right')).toBeNull()
+ expect(header.style.background).toBe('color-mix(in srgb, var(--accent) 7%, transparent)')
})
- it('FE-ROADTRIP-HEADERS-004: pulled narrow the count gives way, and a day of only its night has no drive and no count', () => {
- const narrow = wrap()
- expect(screen.queryByText('2 stops')).toBeNull()
+ it('FE-ROADTRIP-HEADERS-003b: without a title the heading is the day itself, and a 12-hour reader gets the month first on the tile', () => {
+ useSettingsStore.setState({ settings: { distance_unit: 'metric', time_format: '12h' } as never })
+ const { container } = wrap()
+ expect(screen.getByRole('heading', { name: 'Day 2' })).toBeInTheDocument()
+ expect(container.querySelector('time')).toHaveTextContent('6/2')
+ })
+
+ it('FE-ROADTRIP-HEADERS-004: the pills never wrap but give way in steps as the row narrows, and a day of only its night has no drive and no count', () => {
+ // Room for everything: the drive with its time, the count with its word. The old
+ // narrow flag no longer decides it; the row measures its own room.
+ const wide = wrap()
expect(screen.getByText('152 km in 1 h 38 min')).toBeInTheDocument()
- narrow.unmount()
+ expect(screen.getByText('2 stops')).toBeInTheDocument()
+ wide.unmount()
+
+ // First the count drops its word, keeping it for a screen reader.
+ rowWidth(220)
+ const step1 = wrap()
+ expect(screen.getByText('152 km in 1 h 38 min')).toBeInTheDocument()
+ expect(screen.getByLabelText('2 stops')).toHaveTextContent(/^2$/)
+ step1.unmount()
+
+ // Then the drive drops its time.
+ rowWidth(150)
+ const step2 = wrap()
+ expect(screen.getByText('152 km')).toBeInTheDocument()
+ expect(screen.queryByText(/km in/)).toBeNull()
+ expect(screen.getByLabelText('2 stops')).toBeInTheDocument()
+ step2.unmount()
+
+ // Last the count goes.
+ rowWidth(80)
+ const step3 = wrap()
+ expect(screen.getByText('152 km')).toBeInTheDocument()
+ expect(screen.queryByLabelText('2 stops')).toBeNull()
+ step3.unmount()
+ vi.restoreAllMocks()
wrap()
expect(screen.queryByText(/km in/)).toBeNull()
expect(screen.queryByText(/stop/)).toBeNull()
})
- it('FE-ROADTRIP-HEADERS-005: the whole header folds the day on a click, Enter or Space, and says whether it is open', () => {
+ it('FE-ROADTRIP-HEADERS-005: the whole header folds the day on a click, Enter or Space, says whether it is open, and its chevron says the same', () => {
const onToggle = vi.fn()
const { container, rerender } = wrap()
- const header = screen.getByRole('button', { expanded: true })
+ const header = screen.getByRole('button', { name: 'Day 2', expanded: true })
expect(header.tagName).toBe('HEADER')
+ expect(container.querySelector('svg.lucide-chevron-down')).not.toBeNull()
fireEvent.click(header)
fireEvent.keyDown(header, { key: 'Enter' })
fireEvent.keyDown(header, { key: ' ' })
@@ -106,7 +154,8 @@ describe('DayHeader', () => {
rerender()
expect(container.querySelector('header')).toHaveAttribute('aria-expanded', 'false')
- expect(container.querySelector('header')).not.toHaveClass('border-b')
+ expect(container.querySelector('svg.lucide-chevron-right')).not.toBeNull()
+ expect(container.querySelector('svg.lucide-chevron-down')).toBeNull()
})
it('FE-ROADTRIP-HEADERS-006: a day over the driving limit says by how much, and what the limit counts', () => {
@@ -123,7 +172,7 @@ describe('DayHeader', () => {
expect(tooltipOf(badge)).toBe('This day has no route around it, so the drive uses it. Every other day still avoids what it can.')
})
- it('FE-ROADTRIP-HEADERS-008: a ride the drive reaches too late is named in the header', () => {
+ it('FE-ROADTRIP-HEADERS-008: a ride the drive reaches too late takes no pill in the head, which keeps to the facts of the day', () => {
const departure = stop({
assignmentId: 3, name: 'Hamburg Airport', time: '13:15',
carrier: { reservationId: 9, type: 'flight', role: 'departure', title: 'LH 2078', code: 'HAM', at: '15:15' },
@@ -136,31 +185,37 @@ describe('DayHeader', () => {
})}
/>,
)
- expect(screen.getByText('Flight missed')).toHaveClass('text-warning')
+ expect(screen.queryByText('Flight missed')).toBeNull()
+ expect(screen.getByText('152 km in 1 h 38 min')).toBeInTheDocument()
})
- it('FE-ROADTRIP-HEADERS-009: the track badge follows a track, names the one it follows, and is tinted once the day carries vias', () => {
+ it('FE-ROADTRIP-HEADERS-009: the track pill follows a track without folding the day, names the one it follows, and is filled once the day carries vias', () => {
const onFollowTrack = vi.fn()
- const plain = wrap()
+ const onToggle = vi.fn()
+ const plain = wrap()
const badge = screen.getByRole('button', { name: 'Track' })
expect(badge).toHaveClass('bg-surface-card')
expect(tooltipOf(badge)).toBe('Make this day follow an imported track')
fireEvent.click(badge)
expect(onFollowTrack).toHaveBeenCalledWith(1)
+ expect(onToggle).not.toHaveBeenCalled()
+ // A key pressed on the pill belongs to the pill, not to the band around it.
+ fireEvent.keyDown(badge, { key: 'Enter' })
+ expect(onToggle).not.toHaveBeenCalled()
plain.unmount()
wrap()
const shaped = screen.getByRole('button', { name: 'Track' })
- expect(shaped).toHaveClass('bg-accent-subtle')
+ expect(shaped).toHaveClass('bg-accent', 'text-accent-text')
expect(tooltipOf(shaped)).toBe('Currently following Küstenweg')
})
- it('FE-ROADTRIP-HEADERS-010: while the map draws days in colour, the header is washed with its own', () => {
+ it('FE-ROADTRIP-HEADERS-010: while the map draws days in colour, the header band and the day tile are washed with its own', () => {
preferences({ roadtrip_day_colors: true })
const { container } = wrap()
const header = container.querySelector('header')!
- expect(header.style.backgroundImage).toContain('linear-gradient(180deg, color-mix(in srgb, #ff9f0a 13%, var(--bg-secondary))')
// The DOM spells the hue its own way here, so only the mix is pinned.
- expect(header.style.borderBottomColor).toMatch(/^color-mix\(in srgb, .+ 30%, transparent\)$/)
+ expect(header.style.background).toMatch(/^color-mix\(in srgb, .+ 11%, transparent\)$/)
+ expect((header.firstElementChild as HTMLElement).style.background).toMatch(/^color-mix\(in srgb, .+ 22%, var\(--bg-card\)\)$/)
})
})
diff --git a/client/src/components/Roadtrip/RoadtripSidebarHeaders.tsx b/client/src/components/Roadtrip/RoadtripSidebarHeaders.tsx
index 8a50322e3..3744534f5 100644
--- a/client/src/components/Roadtrip/RoadtripSidebarHeaders.tsx
+++ b/client/src/components/Roadtrip/RoadtripSidebarHeaders.tsx
@@ -54,7 +54,7 @@ function splitValue(value: string): React.ReactNode {
*/
export function TripSummary({ routes, narrow }: { routes: RoadtripRoutes; narrow: boolean }): React.ReactElement {
const { t } = useTranslation()
- const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
+ const distanceUnit = useSettingsStore(s => s.settings.distance_unit) || 'metric'
const cells: [string, string][] = [
// `totalStops` already leaves the service stops out, so the head and the day pills
// below count the same thing without either of them recounting the other's stops.
@@ -107,6 +107,116 @@ function pillWidth(text: string): number {
return 16 + 11 + 4 + text.length * 6.6
}
+/**
+ * How far the pill row gives way for its width: 0 shows everything, 1 the stop count
+ * without its word, 2 the drive without its time as well, 3 no stop count at all. A null
+ * drive or count is a pill the day does not show; an unmeasured row (0) fits everything.
+ */
+function pillStep(width: number, drive: { full: string; short: string } | null, count: { full: string; short: string } | null, others: string[]): number {
+ const needed = (driveText: string | undefined, countText: string | undefined) =>
+ [driveText, countText, ...others]
+ .filter((x): x is string => x !== undefined)
+ .reduce((sum, text, i) => sum + pillWidth(text) + (i ? 4 : 0), 0)
+ const fits = (w: number) => width === 0 || w <= width
+ if (fits(needed(drive?.full, count?.full))) return 0
+ if (fits(needed(drive?.full, count?.short))) return 1
+ return fits(needed(drive?.short, count?.short)) ? 2 : 3
+}
+
+/** The texts of the pills after the drive and the count, for reckoning the row's width. */
+function flagTexts(day: RoadtripDay, followable: boolean, t: ReturnType['t']): string[] {
+ return [
+ day.dayWarning ? t('roadtrip.limit.dayOver', { time: formatDurationShort((day.dayWarning.minutes - day.dayWarning.limitMinutes) * 60) }) : null,
+ day.avoidMissed?.length ? t('roadtrip.avoid.missed', { classes: day.avoidMissed.map(cls => t(`roadtrip.avoid.${cls}`)).join(', ') }) : null,
+ followable ? t('roadtrip.track.badge') : null,
+ ].filter((x): x is string => !!x)
+}
+
+/**
+ * The day's number on a raised tile, the way the day plan badges a day, with its date
+ * folded in under it where the day plan keeps the weather.
+ */
+function DayTile({ day, tint }: { day: RoadtripDay; tint: string | null }): React.ReactElement {
+ const { language } = useTranslation()
+ const is12h = useSettingsStore(s => s.settings.time_format) === "12h"
+ return (
+
+
+ {day.dayNumber}
+
+ {day.date ? (
+ <>
+
+
+ >
+ ) : null}
+
+ )
+}
+
+/** The pills after the drive and the count: the limit, the missed avoidance and the track. */
+function DayFlags({ day, pill, warn, onFollowTrack, viaCount, trackName }: {
+ day: RoadtripDay
+ pill: string
+ warn: string
+ onFollowTrack?: (dayId: number) => void
+ viaCount?: number
+ trackName?: string
+}): React.ReactElement {
+ const { t } = useTranslation()
+ return (
+ <>
+ {day.dayWarning ? (
+
+
+
+ {t('roadtrip.limit.dayOver', {
+ time: formatDurationShort((day.dayWarning.minutes - day.dayWarning.limitMinutes) * 60),
+ })}
+
+
+ ) : null}
+ {/* The other half of "where possible": a day with no untolled crossing comes
+ back on the toll road, and saying nothing reads as the switch being broken. */}
+ {day.avoidMissed?.length ? (
+
+
+
+ {t('roadtrip.avoid.missed', {
+ classes: day.avoidMissed.map(cls => t(`roadtrip.avoid.${cls}`)).join(', '),
+ })}
+
+
+ ) : null}
+ {/* Which road this day takes is one of its facts. Filled once the day carries
+ vias: the rail draws none of them, so this pill is the only place a drive
+ shaped by hand differs from one the router picked on its own. */}
+ {onFollowTrack ? (
+
+
+
+ ) : null}
+ >
+ )
+}
+
/** The head band's resting and hover wash, the day plan's own. */
const HEAD_REST = NEUTRAL_TINT
const HEAD_HOVER = 'color-mix(in srgb, var(--accent) 10%, transparent)'
@@ -130,9 +240,8 @@ export function DayHeader({ day, collapsed, onToggle, onFollowTrack, viaCount, t
viaCount?: number
trackName?: string
}>): React.ReactElement {
- const { t, language } = useTranslation()
- const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
- const is12h = useSettingsStore(s => s.settings.time_format) === "12h"
+ const { t } = useTranslation()
+ const distanceUnit = useSettingsStore(s => s.settings.distance_unit) || 'metric'
// The colour the map draws this day in, or none while the map draws one blue line for
// the whole trip. With it on, the tile wears the colour and the band a tenth of it, so
// a green line on the map finds its card.
@@ -148,29 +257,20 @@ export function DayHeader({ day, collapsed, onToggle, onFollowTrack, viaCount, t
// 1 the stop count drops its word, 2 the drive drops its time, 3 the stop count goes.
// The row's width is measured; each pill's is reckoned from its text, which at this
// size is close enough to choose a step without rendering every step to try it.
- const row = useElementSize()
+ const { ref: rowRef, width: rowWidth } = useElementSize()
const driveFull = t('roadtrip.leg.driveText', {
distance: formatDistance(day.distance / 1000, distanceUnit),
time: formatDurationShort(day.duration),
})
const driveShort = formatDistance(day.distance / 1000, distanceUnit)
const stopsFull = t('roadtrip.day.stopCount', { count: stops })
- const others = [
- day.dayWarning ? t('roadtrip.limit.dayOver', { time: formatDurationShort((day.dayWarning.minutes - day.dayWarning.limitMinutes) * 60) }) : null,
- day.avoidMissed?.length ? t('roadtrip.avoid.missed', { classes: day.avoidMissed.map(cls => t(`roadtrip.avoid.${cls}`)).join(', ') }) : null,
- onFollowTrack ? t('roadtrip.track.badge') : null,
- ].filter((x): x is string => !!x)
const hasDrive = day.legs.length > 0
- const hasStops = hasDrive && stops > 0
- const needed = (drive: string, stopText: string | null) =>
- [hasDrive ? drive : null, hasStops ? stopText : null, ...others]
- .filter((x): x is string => x !== null)
- .reduce((sum, text, i) => sum + pillWidth(text) + (i ? 4 : 0), 0)
- const fits = (w: number) => row.width === 0 || w <= row.width
- const compact = fits(needed(driveFull, stopsFull)) ? 0
- : fits(needed(driveFull, String(stops))) ? 1
- : fits(needed(driveShort, String(stops))) ? 2
- : 3
+ const compact = pillStep(
+ rowWidth,
+ hasDrive ? { full: driveFull, short: driveShort } : null,
+ hasDrive && stops > 0 ? { full: stopsFull, short: String(stops) } : null,
+ flagTexts(day, !!onFollowTrack, t),
+ )
return (
- {/* The day's number on a raised tile, the way the day plan badges a day, with its
- date folded in under it where the day plan keeps the weather. */}
-
-
- {day.dayNumber}
-
- {day.date ? (
- <>
-
-
- >
- ) : null}
-
+
{day.title || dayName}
-
+
{/* A day that is only the night booked for it has no drive to sum up and no
stop to count; the hotel row below says everything it has to say. */}
{day.legs.length > 0 ? (
@@ -238,45 +318,7 @@ export function DayHeader({ day, collapsed, onToggle, onFollowTrack, viaCount, t
{compact >= 1 ? stops : stopsFull}
) : null}
- {day.dayWarning ? (
-
-
-
- {t('roadtrip.limit.dayOver', {
- time: formatDurationShort((day.dayWarning.minutes - day.dayWarning.limitMinutes) * 60),
- })}
-
-
- ) : null}
- {/* The other half of "where possible": a day with no untolled crossing comes
- back on the toll road, and saying nothing reads as the switch being broken. */}
- {day.avoidMissed?.length ? (
-
-
-
- {t('roadtrip.avoid.missed', {
- classes: day.avoidMissed.map(cls => t(`roadtrip.avoid.${cls}`)).join(', '),
- })}
-
-
- ) : null}
- {/* Which road this day takes is one of its facts. Filled once the day carries
- vias: the rail draws none of them, so this pill is the only place a drive
- shaped by hand differs from one the router picked on its own. */}
- {onFollowTrack ? (
-
-
-
- ) : null}
+
diff --git a/client/src/components/Roadtrip/VisitControls.test.tsx b/client/src/components/Roadtrip/VisitControls.test.tsx
index 205f846ef..927cf4b2d 100644
--- a/client/src/components/Roadtrip/VisitControls.test.tsx
+++ b/client/src/components/Roadtrip/VisitControls.test.tsx
@@ -7,24 +7,26 @@ vi.mock('../../i18n/TranslationContext', () => ({ useTranslation: () => ({ t: (k
it('edits the shared stay without changing the day ending', () => {
const edit = vi.fn(), toggle = vi.fn(async () => {})
render()
- fireEvent.click(screen.getByRole('button', { name: 'roadtrip.stop.stayShort 1 h 30 min' }))
+ // The caption is the section's eyebrow now, so the button reads the figure alone.
+ expect(screen.getByText('roadtrip.stop.stayShort')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: '1 h 30 min' }))
expect(edit).toHaveBeenCalledOnce()
expect(toggle).not.toHaveBeenCalled()
})
it('keeps stay editing when daily travel times are off', () => {
render()
- expect(screen.getByRole('button', { name: 'roadtrip.stop.stayShort +' })).toBeEnabled()
+ expect(screen.getByRole('button', { name: '+' })).toBeEnabled()
expect(screen.queryByRole('button', { name: 'roadtrip.window.endHere' })).not.toBeInTheDocument()
})
it('reads the stay a set leave time makes, and says until when', () => {
render()
- expect(screen.getByRole('button', { name: 'roadtrip.stop.stayShort 4 h roadtrip.stay.until' })).toBeEnabled()
+ expect(screen.getByRole('button', { name: /^4 h\s*roadtrip\.stay\.until$/ })).toBeEnabled()
})
it('says only until when while the drive has not timed the arrival', () => {
render()
- expect(screen.getByRole('button', { name: 'roadtrip.stop.stayShort roadtrip.stay.until' })).toBeDisabled()
+ expect(screen.getByRole('button', { name: 'roadtrip.stay.until' })).toBeDisabled()
expect(screen.queryByText('+')).not.toBeInTheDocument()
})
diff --git a/client/src/components/Roadtrip/VisitControls.tsx b/client/src/components/Roadtrip/VisitControls.tsx
index 057374fcf..698e01fdb 100644
--- a/client/src/components/Roadtrip/VisitControls.tsx
+++ b/client/src/components/Roadtrip/VisitControls.tsx
@@ -14,7 +14,7 @@ export interface RoadtripStayControl {
}
/** The white field box the inspector's other sections (rating, hours, files) use. */
-export const VISIT_BOX = 'flex min-h-[38px] min-w-0 items-center justify-between gap-2 rounded-[10px] border border-edge-faint bg-surface-card px-3 py-2'
+const VISIT_BOX = 'flex min-h-[38px] min-w-0 items-center justify-between gap-2 rounded-[10px] border border-edge-faint bg-surface-card px-3 py-2'
/**
* A stop's road trip controls in the place inspector: how long it takes and whether the