diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index 8eaf8bb81..ecd00f8d9 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -3,11 +3,10 @@ import { useRoadtripSettings } from '../../hooks/useRoadtripSettings' import { useElementSize } from '../../hooks/useElementSize' import React, { useMemo, useState } from 'react' import { - CarFront, Footprints, Bike, Zap, AlertTriangle, - ParkingSquare, Shuffle, Clock, Spline, Ban, Moon, + Zap, AlertTriangle, + ParkingSquare, Clock, Spline, Ban, type LucideIcon, } from 'lucide-react' -import MDancingTrek from '../../mobile/components/MDancingTrek' import type { RefuelSearch } from './useRefuelSearch' import type { RefuelCandidate } from './refuelSuggestion' import type { DryPoint } from './roadtripModel' @@ -18,14 +17,14 @@ import { formatDistance } from '../../utils/units' import { formatDate, formatClockTime } from '../../utils/formatters' import { formatDurationShort, isServiceStopType, serviceColor, type ScheduleEntry, type ScheduleWarning, refuelsRange } from './roadtripModel' import { STOP_KIND_BY_KEY } from './stopKinds' -import { arrivingReroutable, bookendReading, destinationCount, isHop, legReroutable, movableWithin, resumeFoldsIntoBookend, type BookendReading } from './roadtripRowModel' +import { arrivingReroutable, bookendReading, destinationCount, legReroutable, movableWithin, resumeFoldsIntoBookend, type BookendReading } from './roadtripRowModel' import { BOOKEND_DISC, BOOKEND_ICON, bookendBadge, bookendBooking } from './nightBookend' import { spurWorthLabelling } from './accessSpur' import StopKindPicker from './StopKindPicker' import StopFillPicker from './StopFillPicker' import { useVehicleRange } from './useVehicleRange' import { MAX_TRIP_DAYS, type RoadtripStopType } from '@trek/shared' -import { isCarrierMode, isStoredStop, undatedRides, type CarrierTerminal } from '@trek/shared/roadtrip' +import { isStoredStop, undatedRides } from '@trek/shared/roadtrip' import type { QuietDay, RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes' import type { SpillMark } from './nightSpill' import { ARRIVING_DRIVE, openOn, type LegAlternatives, type RailDrive } from './useRouteAlternatives' @@ -35,7 +34,7 @@ import { FS } from './typeScale' import type { RouteSegment } from '../../types' import EmptyState from '../shared/EmptyState' import AutomaticDayStop from './AutomaticDayStop' -import { bookingOpens, carrierIcon, missedRide, rideDuration, rideReading, terminalReading, type CheckInReading, type RideEnd } from './carrierRide' +import { bookingOpens, carrierIcon, missedRide, rideReading, terminalReading, type CheckInReading, type RideEnd } from './carrierRide' import FigureBadge from './FigureBadge' import { bookingClock, bookingIcon, dayBookings } from './stopBookings' import type { Reservation } from '../../types' @@ -44,6 +43,7 @@ import { missedLeaveOf, readStay, stayDraftOf } from './stayReading' import { DAY_BADGE, DISC, RAIL_DASH, RAIL_GRID, STAT_LABEL } from './RoadtripSidebar.constants' import { Arrival, DayCarry, DriveFindingBadge, FillBadge, LateBadge, NightCheckIn, OffRoadBadge, StayBadge } from './RoadtripSidebarBadges' import { RefuelBand } from './RoadtripSidebarRefuelBand' +import { DriveBand, SpillBlock } from './RoadtripSidebarDriveBands' interface RoadtripSidebarProps { onFocusPoint?: (lat: number, lng: number) => void @@ -135,12 +135,6 @@ interface RoadtripSidebarProps { canEditBookings?: boolean } -const MODE_ICON: Record = { - driving: CarFront, - walking: Footprints, - cycling: Bike, -} - // Icon and name both come from the one stop-kind table now. This file used to keep its // own copy of each, and its rest_area icon had drifted away from the popup's. @@ -250,122 +244,6 @@ function TripSummary({ routes, narrow }: { routes: RoadtripRoutes; narrow: boole ) } -/** - * The drive between two stops — or between a stop and the charger halfway along it. - * - * Both numbers are rebuilt from the raw metres and seconds instead of the router's - * pre-formatted strings: those spell a full hour "1 h 0 min", and they carry whichever - * unit the leg was fetched with, so a km/mi switch showed stale text until the refetch - * landed. One sentence rather than two values — "152 km in 1 h 38 min" is how the - * distance and the time belong together, and it leaves the row's right edge for the - * button instead of a second number. - */ -function DriveBand({ leg, carrier, onAskAlternatives, alternativesOpen }: { - leg: RouteSegment | undefined - /** The booking, when the leg is a ride and not a road: the join into the day it lands on. */ - carrier?: CarrierTerminal - /** Asks for other ways of driving this leg. Absent means the route is not editable. */ - onAskAlternatives?: () => void - alternativesOpen?: boolean -}): React.ReactElement { - const { t } = useTranslation() - const distanceUnit = useSettingsStore(s => s.settings.distance_unit) - const mode = leg?.mode ?? 'driving' - // A ride reads as the ride block's head does: the booking and its minutes under the - // booking's icon. Through the road branches it was a car driving no distance for nine - // hours, and a ride without a timetable was short enough to pass for a hop. - const ride = isCarrierMode(mode) - const duration = ride ? rideDuration(leg) : null - const Icon = ride ? carrierIcon(mode) : mode.startsWith('plugin:') ? Zap : MODE_ICON[mode] ?? CarFront - // A hop (the hire desk beside the terminal) keeps the line and drops the pill: there is - // nothing to say about it and no other way to drive it. - if (!ride && isHop(leg)) { - return ( -
- - - - -
- ) - } - // The band's contents, shared by the clickable and the read-only shape so the two can - // never drift apart in what they say. - // A ride's minutes stand apart from its title and never truncate: a long booking title - // would otherwise cut off the one figure the band exists for. - const band = ( - <> - - {leg && ride ? ( - <> - {carrier ? {carrier.title} : null} - {duration ? {duration} : null} - - ) : ( - - {!leg - ? t('roadtrip.leg.pending') - : t('roadtrip.leg.driveText', { - distance: formatDistance(leg.distance / 1000, distanceUnit), - time: formatDurationShort(leg.duration), - })} - - )} - - ) - return ( -
- - {/* Dashed between stops, solid at them: the eye reads the gap as travel. */} - - -
- {/* The whole band is the target, not the 18px square at its end. Asking for other - ways of driving a leg is what the band is for, and a click anywhere on it is - the gesture people try first — the shuffle mark stays as the sign that it can - be clicked, and as where the open state shows. */} - {onAskAlternatives && leg ? ( - - - - ) : ( -
- {band} -
- )} - {/* What a plugin route attached to this leg ("25 min charge"): free text, so it - takes a line of its own rather than being forced into the row above. */} - {leg?.noteText ? ( -

- {leg.noteText} -

- ) : null} -
-
- ) -} - /** * One end of a ride on a row of its own: a terminal whose ride lands on another day, or a * hire car's desk. @@ -958,126 +836,6 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s ) } -/** - * What a night drive leaves on the next morning's card. - * - * The one thing in the rail that is not the day it sits under. A drive leaving at 21:00 - * and arriving at 01:23 arrives tomorrow, and the rail draws it there — under tomorrow's - * date, with the night it came through kept in front of it so the kilometres reach the - * stops rather than being left on a day nobody drives them on. - * - * Nothing has been written to say so. The stops still belong to the day they were planned - * on and the day plan still shows them there; this is the arrangement the arrival times - * imply, worked out again every time they change. Shorten the stay before the night drive - * and the first stop crosses back on its own. - * - * The ground fades from night at the head to the card's own surface at the foot, because - * that is what the block is: the end of one day handed to the beginning of the next. - */ -function SpillBlock({ spill, children }: { - spill: SpillMark - children: React.ReactNode -}): React.ReactElement { - const { t } = useTranslation() - const distanceUnit = useSettingsStore(s => s.settings.distance_unit) - const is12h = useSettingsStore(s => s.settings.time_format) === '12h' - const leg = spill.leg - const departure = spill.departure - // Mixed against the card rather than laid over it with alpha: the mascot cuts its eyes - // out in the colour behind it, and a translucent ground would show its body through - // them. The same reason the old overnight band mixed instead of tinting. - const night = 'color-mix(in srgb, var(--info) 11%, var(--bg-card))' - const dawn = 'color-mix(in srgb, var(--info) 4%, var(--bg-card))' - const edge = 'color-mix(in srgb, var(--info) 22%, transparent)' - return ( -
  • -
    -
    - - - {t('roadtrip.spill.title', { number: spill.fromDayNumber })} - - {/* Three z's on one baseline, each starting a third of the loop after the last, - so one is always on its way up. Decorative: the line beside it already says - what the block is. */} - - z - z - z - -
    - {/* The drive itself, in the band the rail uses everywhere else — this is the - reason the block exists, and the kilometres on it are the ones the card's - header now counts. Not clickable: alternatives are asked for on the day the - leg is stored on, and offering the same leg twice would be two answers. */} -
    - - - -
    - {/* Shaped like every other drive in the rail: what it cost on the left, the - clock on the right, in the column every arrival in this card already - reads down. The time used to sit inside the sentence, which put a - reading in the one place the eye does not look for one — and gave the - row two competing figures with no order between them. */} -
    - - - {leg - ? t('roadtrip.leg.driveText', { - distance: formatDistance((leg.distance ?? 0) / 1000, distanceUnit), - time: formatDurationShort(leg.duration ?? 0), - }) - : t('roadtrip.leg.pending')} - - {/* Departure, not arrival, so it says so in words: everything else in this - column is a time of arrival, and a bare 23:57 there would read as one. - The stop it leaves from is not named — it is drawn on yesterday's card - directly above, and a second row for it would read as a stop made - twice. */} - {departure ? ( - - {t('roadtrip.spill.departs', { time: formatClockTime(departure, is12h) })} - - ) : null} -
    -
    -
    -
    - {/* Seven, not eight: the block is pulled out by 8 and draws a 1px border, so the - inside edge lands one pixel in unless the padding gives that pixel back. It is - the difference between the numbers in here standing on the same line as the ones - below and standing almost on it. */} -
      {children}
    -
  • - ) -} - /** * One day, one card: its own numbers in its own head, its stops chained below. * diff --git a/client/src/components/Roadtrip/RoadtripSidebarDriveBands.test.tsx b/client/src/components/Roadtrip/RoadtripSidebarDriveBands.test.tsx new file mode 100644 index 000000000..783e582cc --- /dev/null +++ b/client/src/components/Roadtrip/RoadtripSidebarDriveBands.test.tsx @@ -0,0 +1,141 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import { TranslationProvider } from '../../i18n' +import { useSettingsStore } from '../../store/settingsStore' +import type { CarrierTerminal } from '@trek/shared/roadtrip' +import type { SpillMark } from './nightSpill' +import type { RouteSegment } from '../../types' +import { DriveBand, SpillBlock } from './RoadtripSidebarDriveBands' + +const wrap = (ui: React.ReactElement) => render({ui}) + +function settings(over: { distance_unit?: 'metric' | 'imperial'; time_format?: '12h' | '24h' } = {}): void { + useSettingsStore.setState({ settings: { distance_unit: 'metric', time_format: '24h', ...over } as never }) +} + +beforeEach(() => settings()) + +const leg = (over: Partial = {}): RouteSegment => + ({ distance: 152000, duration: 5880, distanceText: '152 km', durationText: '1 h 38 min', mode: 'driving', ...over }) as RouteSegment + +const flight: CarrierTerminal = { reservationId: 9, type: 'flight', role: 'departure', title: 'LH 2078', code: 'HAM', at: '15:15' } + +const spill = (over: Partial = {}): SpillMark => ({ + at: 0, count: 1, fromDayNumber: 3, departure: '21:40', leg: leg({ distance: 248000, duration: 13500 }), + fromStop: undefined, line: [], + ...over, +}) + +/** The lucide icon a band leads with, by its class name. */ +const iconOf = (container: HTMLElement): string => + [...(container.querySelector('svg.lucide')?.classList ?? [])].find(c => c.startsWith('lucide-')) ?? '' + +describe('DriveBand', () => { + it('FE-ROADTRIP-DRIVEBAND-001: a road leg reads distance and time as one sentence, under the sign of how it is travelled', () => { + const cases: [string, string][] = [ + ['driving', 'lucide-car-front'], + ['walking', 'lucide-footprints'], + ['cycling', 'lucide-bike'], + ['plugin:ev-router', 'lucide-zap'], + ['hovercraft', 'lucide-car-front'], + ] + for (const [mode, icon] of cases) { + const { container, unmount } = wrap() + expect(screen.getByText('152 km in 1 h 38 min')).toBeInTheDocument() + expect(iconOf(container)).toBe(icon) + unmount() + } + + // No mode at all is a drive. + const { container } = wrap() + expect(iconOf(container)).toBe('lucide-car-front') + expect(screen.queryByRole('button')).toBeNull() + }) + + it('FE-ROADTRIP-DRIVEBAND-002: the band is the button that asks for other ways, and shows when they are open', () => { + const onAsk = vi.fn() + const { rerender } = wrap() + const band = screen.getByRole('button') + expect(band).toHaveAttribute('aria-pressed', 'false') + expect(band).toHaveClass('bg-surface-tertiary') + expect(screen.getByLabelText('Other ways')).toBeInTheDocument() + fireEvent.click(band) + expect(onAsk).toHaveBeenCalledTimes(1) + + rerender() + expect(screen.getByRole('button')).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByRole('button')).toHaveClass('bg-surface-selected') + }) + + it('FE-ROADTRIP-DRIVEBAND-003: a leg still coming says so, and offers nothing to change', () => { + wrap() + expect(screen.getByText('No route')).toBeInTheDocument() + expect(screen.queryByRole('button')).toBeNull() + }) + + it('FE-ROADTRIP-DRIVEBAND-004: a hop keeps the line and drops the pill, unless a plugin wrote something on it', () => { + const hop = wrap() + expect(hop.container.querySelector('svg')).toBeNull() + expect(hop.container.textContent).toBe('') + expect(hop.container.querySelector('span.flex-1')).not.toBeNull() + hop.unmount() + + wrap() + expect(screen.getByText('Ferry ramp')).toBeInTheDocument() + expect(screen.getByText('80 m in 1 min')).toBeInTheDocument() + }) + + it('FE-ROADTRIP-DRIVEBAND-005: a ride reads as its booking and its minutes, however short it is', () => { + const ride = wrap() + expect(iconOf(ride.container)).toBe('lucide-plane') + expect(screen.getByText('LH 2078')).toHaveClass('truncate') + expect(screen.getByText('1 h 20 min')).toHaveClass('shrink-0') + expect(screen.queryByText(/ in /)).toBeNull() + ride.unmount() + + // Without a booking to name and without a timetable there is only the sign. + const bare = wrap() + expect(iconOf(bare.container)).toBe('lucide-tram-front') + expect(bare.container.textContent).toBe('') + }) +}) + +describe('SpillBlock', () => { + /** The rail row that holds the night's drive: the moon's band, its column, then the row. */ + const driveRow = (container: HTMLElement): HTMLElement => + container.querySelector('svg.lucide-moon')!.parentElement!.parentElement!.parentElement! + + it('FE-ROADTRIP-DRIVEBAND-006: the night drive carries yesterday\'s number, its own drive and when it leaves', () => { + settings({ time_format: '12h' }) + const { container } = wrap( +
      + +
    1. Ghent
    2. +
      +
    , + ) + expect(screen.getByText('From day 3')).toBeInTheDocument() + expect(screen.getByText('248 km in 3 h 45 min')).toBeInTheDocument() + expect(screen.getByText('leaves 9:40 PM')).toHaveAttribute('dir', 'ltr') + expect(driveRow(container)).toHaveClass('grid') + expect(driveRow(container)).not.toHaveClass('hidden') + // The stops it brings along sit in a list of their own inside the block. + expect(screen.getByText('Ghent').closest('ol')).not.toBe(container.querySelector('ol')) + }) + + it('FE-ROADTRIP-DRIVEBAND-007: an automatic night hides its drive, and missing figures fall back instead of breaking', () => { + const automatic = wrap(
      {null}
    ) + expect(driveRow(automatic.container)).toHaveClass('hidden') + expect(driveRow(automatic.container)).not.toHaveClass('grid') + expect(screen.queryByText(/^leaves /)).toBeNull() + automatic.unmount() + + const pending = wrap(
      {null}
    ) + expect(screen.getByText('No route')).toBeInTheDocument() + pending.unmount() + + wrap(
      {null}
    ) + expect(screen.getByText('0 m in 0 min')).toBeInTheDocument() + }) +}) diff --git a/client/src/components/Roadtrip/RoadtripSidebarDriveBands.tsx b/client/src/components/Roadtrip/RoadtripSidebarDriveBands.tsx new file mode 100644 index 000000000..e7cb06bd3 --- /dev/null +++ b/client/src/components/Roadtrip/RoadtripSidebarDriveBands.tsx @@ -0,0 +1,258 @@ +import React from 'react' +import { Bike, CarFront, Footprints, Moon, Shuffle, Zap, type LucideIcon } from 'lucide-react' +import MDancingTrek from '../../mobile/components/MDancingTrek' +import { useTranslation } from '../../i18n/TranslationContext' +import { Tooltip } from '../shared/Tooltip' +import { useSettingsStore } from '../../store/settingsStore' +import { formatDistance } from '../../utils/units' +import { formatClockTime } from '../../utils/formatters' +import { formatDurationShort } from './roadtripModel' +import { isHop } from './roadtripRowModel' +import { isCarrierMode, type CarrierTerminal } from '@trek/shared/roadtrip' +import type { SpillMark } from './nightSpill' +import type { RouteSegment } from '../../types' +import { carrierIcon, rideDuration } from './carrierRide' +import { FS } from './typeScale' +import { RAIL_DASH, RAIL_GRID } from './RoadtripSidebar.constants' + +const MODE_ICON: Record = { + driving: CarFront, + walking: Footprints, + cycling: Bike, +} + +/** + * The drive between two stops, or between a stop and the charger halfway along it. + * + * Both numbers are rebuilt from the raw metres and seconds instead of the router's + * pre-formatted strings: those spell a full hour "1 h 0 min", and they carry whichever + * unit the leg was fetched with, so a km/mi switch showed stale text until the refetch + * landed. One sentence rather than two values: "152 km in 1 h 38 min" is how the + * distance and the time belong together, and it leaves the row's right edge for the + * button instead of a second number. + */ +export function DriveBand({ leg, carrier, onAskAlternatives, alternativesOpen }: { + leg: RouteSegment | undefined + /** The booking, when the leg is a ride and not a road: the join into the day it lands on. */ + carrier?: CarrierTerminal + /** Asks for other ways of driving this leg. Absent means the route is not editable. */ + onAskAlternatives?: () => void + alternativesOpen?: boolean +}): React.ReactElement { + const { t } = useTranslation() + const distanceUnit = useSettingsStore(s => s.settings.distance_unit) + const mode = leg?.mode ?? 'driving' + // A ride reads as the ride block's head does: the booking and its minutes under the + // booking's icon. Through the road branches it was a car driving no distance for nine + // hours, and a ride without a timetable was short enough to pass for a hop. + const ride = isCarrierMode(mode) + const duration = ride ? rideDuration(leg) : null + const Icon = ride ? carrierIcon(mode) : mode.startsWith('plugin:') ? Zap : MODE_ICON[mode] ?? CarFront + // A hop (the hire desk beside the terminal) keeps the line and drops the pill: there is + // nothing to say about it and no other way to drive it. + if (!ride && isHop(leg)) { + return ( +
    + + + + +
    + ) + } + // The band's contents, shared by the clickable and the read-only shape so the two can + // never drift apart in what they say. + // A ride's minutes stand apart from its title and never truncate: a long booking title + // would otherwise cut off the one figure the band exists for. + const band = ( + <> + + {leg && ride ? ( + <> + {carrier ? {carrier.title} : null} + {duration ? {duration} : null} + + ) : ( + + {!leg + ? t('roadtrip.leg.pending') + : t('roadtrip.leg.driveText', { + distance: formatDistance(leg.distance / 1000, distanceUnit), + time: formatDurationShort(leg.duration), + })} + + )} + + ) + return ( +
    + + {/* Dashed between stops, solid at them: the eye reads the gap as travel. */} + + +
    + {/* The whole band is the target, not the 18px square at its end. Asking for other + ways of driving a leg is what the band is for, and a click anywhere on it is + the gesture people try first. The shuffle mark stays as the sign that it can + be clicked, and as where the open state shows. */} + {onAskAlternatives && leg ? ( + + + + ) : ( +
    + {band} +
    + )} + {/* What a plugin route attached to this leg ("25 min charge"): free text, so it + takes a line of its own rather than being forced into the row above. */} + {leg?.noteText ? ( +

    + {leg.noteText} +

    + ) : null} +
    +
    + ) +} + +/** + * What a night drive leaves on the next morning's card. + * + * The one thing in the rail that is not the day it sits under. A drive leaving at 21:00 + * and arriving at 01:23 arrives tomorrow, and the rail draws it there: under tomorrow's + * date, with the night it came through kept in front of it so the kilometres reach the + * stops rather than being left on a day nobody drives them on. + * + * Nothing has been written to say so. The stops still belong to the day they were planned + * on and the day plan still shows them there; this is the arrangement the arrival times + * imply, worked out again every time they change. Shorten the stay before the night drive + * and the first stop crosses back on its own. + * + * The ground fades from night at the head to the card's own surface at the foot, because + * that is what the block is: the end of one day handed to the beginning of the next. + */ +export function SpillBlock({ spill, children }: { + spill: SpillMark + children: React.ReactNode +}): React.ReactElement { + const { t } = useTranslation() + const distanceUnit = useSettingsStore(s => s.settings.distance_unit) + const is12h = useSettingsStore(s => s.settings.time_format) === '12h' + const leg = spill.leg + const departure = spill.departure + // Mixed against the card rather than laid over it with alpha: the mascot cuts its eyes + // out in the colour behind it, and a translucent ground would show its body through + // them. The same reason the old overnight band mixed instead of tinting. + const night = 'color-mix(in srgb, var(--info) 11%, var(--bg-card))' + const dawn = 'color-mix(in srgb, var(--info) 4%, var(--bg-card))' + const edge = 'color-mix(in srgb, var(--info) 22%, transparent)' + return ( +
  • +
    +
    + + + {t('roadtrip.spill.title', { number: spill.fromDayNumber })} + + {/* Three z's on one baseline, each starting a third of the loop after the last, + so one is always on its way up. Decorative: the line beside it already says + what the block is. */} + + z + z + z + +
    + {/* The drive itself, in the band the rail uses everywhere else: this is the + reason the block exists, and the kilometres on it are the ones the card's + header now counts. Not clickable: alternatives are asked for on the day the + leg is stored on, and offering the same leg twice would be two answers. */} +
    + + + +
    + {/* Shaped like every other drive in the rail: what it cost on the left, the + clock on the right, in the column every arrival in this card already + reads down. The time used to sit inside the sentence, which put a + reading in the one place the eye does not look for one, and gave the + row two competing figures with no order between them. */} +
    + + + {leg + ? t('roadtrip.leg.driveText', { + distance: formatDistance((leg.distance ?? 0) / 1000, distanceUnit), + time: formatDurationShort(leg.duration ?? 0), + }) + : t('roadtrip.leg.pending')} + + {/* Departure, not arrival, so it says so in words: everything else in this + column is a time of arrival, and a bare 23:57 there would read as one. + The stop it leaves from is not named: it is drawn on yesterday's card + directly above, and a second row for it would read as a stop made + twice. */} + {departure ? ( + + {t('roadtrip.spill.departs', { time: formatClockTime(departure, is12h) })} + + ) : null} +
    +
    +
    +
    + {/* Seven, not eight: the block is pulled out by 8 and draws a 1px border, so the + inside edge lands one pixel in unless the padding gives that pixel back. It is + the difference between the numbers in here standing on the same line as the ones + below and standing almost on it. */} +
      {children}
    +
  • + ) +}