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