diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index 1cf950c49..5898cfbb9 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -4,7 +4,7 @@ import { useElementSize } from '../../hooks/useElementSize' import React, { useMemo, useState } from 'react' import { CarFront, Footprints, Bike, Zap, AlertTriangle, - ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, RotateCcw, X, BatteryCharging, Moon, Milestone, + ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, RotateCcw, X, Moon, Milestone, type LucideIcon, } from 'lucide-react' import MDancingTrek from '../../mobile/components/MDancingTrek' @@ -40,8 +40,9 @@ import FigureBadge from './FigureBadge' import { bookingClock, bookingIcon, dayBookings } from './stopBookings' import type { Reservation } from '../../types' import type { StayDraft } from './RoadtripStayModal' -import { missedLeaveOf, readStay, shownStay, stayDraftOf, type StayReading } from './stayReading' +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' interface RoadtripSidebarProps { onFocusPoint?: (lat: number, lng: number) => void @@ -155,50 +156,6 @@ const RIDE_DASH: React.CSSProperties = { */ const RAIL_NARROW_PX = 330 -/** - * How long the traveller stays here, and the way to change it. - * - * On every stop, not only the ones that already carry a time: the value has never been - * editable anywhere in TREK, so a stop that has none needs somewhere to say so before it - * can get one. Unset it reads as a plus in the slot the number will occupy, which keeps - * the two states the same shape and the same width — a row does not jump when a stay is - * added to it. - * - * Read-only for someone who cannot edit the trip: then it is a label, and a stop without - * a stay shows nothing at all rather than an invitation that leads nowhere. - */ -/** - * How far the road stops short of the place, in the same two-part shell the stay wears. - * - * Beside the stay rather than under it: both answer "what does this stop cost you", one - * in time and one in a walk, and two badges on one line read as one fact about the stop - * instead of two unrelated notes stacked up. - */ -function OffRoadBadge({ meters }: { meters: number }): React.ReactElement { - const { t } = useTranslation() - const distanceUnit = useSettingsStore(s => s.settings.distance_unit) - return ( - } - value={formatDistance(meters / 1000, distanceUnit)} - tooltip={t('roadtrip.stop.offRoad', { distance: formatDistance(meters / 1000, distanceUnit) })} - /> - ) -} - -/** - * What a fill-up here actually puts in, beside the stay it takes. - * - * Only on a stop that refuels, because it is the only stop where the answer changes - * anything. It reads as the second half of the stay badge because that is what it is — - * how long you stand here, and what you get for it. - * - * The figure is the stop's own when it has one and the traveller's default otherwise, so - * what the badge says is always what the range budget will actually use. With neither it - * shows a "+", the same invitation the stay badge makes when nothing is planned yet: this - * badge is the only way into the per-stop figure, and a badge that hid itself until a - * value existed could never be used to create one. - */ /** * What a stop will actually fill to: its own figure, else the traveller's default. * @@ -212,70 +169,6 @@ function effectiveFill(own: number | null | undefined, setting: number | undefin return setting && setting > 0 && setting < 100 ? setting : null } -function FillBadge({ percent, own, onEdit }: { - /** What this stop will actually fill to, inherited or not. Null when nothing says. */ - percent: number | null - /** Whether that figure is the stop's own rather than the traveller's default. */ - own: boolean - onEdit?: (anchor: HTMLElement) => void -}): React.ReactElement | null { - const { t } = useTranslation() - if (percent === null && !onEdit) return null - return ( - } - value={percent === null ? '+' : `${percent} %`} - faint={percent === null || !own} - valueSize="micro" - tooltip={onEdit ? (percent === null ? t('roadtrip.stop.fillSet') : t('roadtrip.limit.fillBadge', { percent })) : undefined} - onActivate={onEdit} - /> - ) -} - -/** - * How long the stop takes. A stop the traveller leaves at a set time is stood at until - * then, so it reads the stay the schedule made of that and says until when. - */ -/** - * The check-in of the night a stay's own stop begins, on the day it does: the hour the - * room is ready, which is also what holds the arrival clock beside it when the drive gets - * there first. - */ -function NightCheckIn({ stop }: { stop: RoadtripStop }): React.ReactElement | null { - const { t } = useTranslation() - const is12h = useSettingsStore(s => s.settings.time_format) === '12h' - if (!stop.night || !stop.checkInTime) return null - return -} - -function StayBadge({ stay, onEdit }: { stay: StayReading; onEdit?: () => void }): React.ReactElement | null { - const { t } = useTranslation() - const is12h = useSettingsStore(s => s.settings.time_format) === '12h' - const shown = shownStay(stay) - const text = shown === null ? null : formatDurationShort(shown * 60) - const until = stay.until ? t('roadtrip.stay.until', { time: formatClockTime(stay.until, is12h) }) : null - if (!text && !until && !onEdit) return null - return ( - - {text ?? (until ? null : '+')} - {until ? {until} : null} - - )} - faint={!text} - tooltip={onEdit ? t('roadtrip.stop.stay') : undefined} - onActivate={onEdit} - ariaLabel={onEdit - ? text || until ? `${t('roadtrip.stop.stay')}: ${[text, until].filter(Boolean).join(' ')}` : t('roadtrip.stay.add') - : undefined} - /> - ) -} - /** The quiet half of a measurement — the unit, and anything after the decimal point. */ function Unit({ children }: { children: React.ReactNode }): React.ReactElement { return {children} @@ -1159,84 +1052,6 @@ function RouteViaStop({ via }: { via: RouteVia }): React.ReactElement { ) } -/** - * A finding about the drive leaving this stop: too long at the wheel in one go, or the - * tank running out before anywhere to fill it. - * - * Wears the same two-part shell as the stay and the walk, in warning colours, and sits in - * the same row: all of them say what this stop costs, and one row of badges reads as one - * answer rather than as notes stacked under each other. The index is the stop the leg - * LEAVES, so the badge means "the drive from here". - * - * Neither finding names a time of day. With no stop pinned to a clock the cascade - * produces no times at all, so both are durations and distances, which exist as soon as - * the leg has routed. - */ -function DriveFindingBadge({ warning }: { warning: ScheduleWarning }): React.ReactElement | null { - const { t } = useTranslation() - const distanceUnit = useSettingsStore(s => s.settings.distance_unit) - const text = warning.code === 'leg' - ? t('roadtrip.limit.legOver', { time: formatDurationShort((warning.overMinutes ?? 0) * 60) }) - : warning.code === 'range' - ? t('roadtrip.limit.range', { distance: formatDistance(warning.sinceKm ?? 0, distanceUnit) }) - : null - if (!text) return null - const Icon = warning.code === 'range' ? Fuel : Clock - return ( - } - value={warning.code === 'range' - ? formatDistance(warning.sinceKm ?? 0, distanceUnit) - : `+${formatDurationShort((warning.overMinutes ?? 0) * 60)}`} - tooltip={text} - /> - ) -} - -/** - * The timetable convention: past midnight the clock keeps reading small numbers, so the - * day it belongs to travels with it instead of sitting a line away as a separate note. - */ -function DayCarry({ days }: { days: number }): React.ReactElement | null { - const { t } = useTranslation() - if (days <= 0) return null - return ( - - {`+${days}`} - {` ${t('roadtrip.warn.overnight')}`} - - ) -} - -function Arrival({ entry }: { entry: ScheduleEntry }): React.ReactElement { - const { t } = useTranslation() - const is12h = useSettingsStore(s => s.settings.time_format) === '12h' - const text = formatClockTime(entry.arrival, is12h) - const carry = - - // Every arrival is plain text at the row's right edge, pinned or not. The pinned one - // used to be a filled pill with a clock, which made a column of quiet clock readings - // look like it had a button in it. What is left of the distinction is weight and ink — - // enough to see which time somebody chose, without a second shape in the rail. - return ( - - - {text} - {carry} - - - ) -} - function Stop({ stop, number, entry, late, driveFindings, selected, continues, starts, onSelect, onMove, canMove, onEditStay, onPickKind, onPickFill }: { stop: RoadtripStop /** Position within the day — the same count the map badges its markers with. */ @@ -1367,32 +1182,6 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s ) } -/** - * How far past the time you set this stop is reached. Its own component because a pause - * runs late exactly like a numbered stop does: the schedule computes the finding for both - * (roadtripModel restarts the chain at any anchor, whatever kind of stop carries it), and - * drawing it in only one of them threw the other one's away. - * - * It sits in the badge row beside the stay and the drive findings rather than on a line of - * its own: they are all answers to "what does this stop cost", and a warning on its own row - * pushed every following stop down for a finding that fits in a pill. - */ -function LateBadge({ late }: { late: ScheduleWarning }): React.ReactElement { - const { t } = useTranslation() - const label = t(late.code === 'missedLeave' ? 'roadtrip.warn.missedLeave' : 'roadtrip.warn.late', { minutes: late.minutes ?? 0 }) - // The same two-part shell the stay and the drive findings wear, so a row of badges reads - // as one set instead of a pill among boxes. - return ( - } - value={`+${formatDurationShort((late.minutes ?? 0) * 60)}`} - tooltip={label} - /> - ) -} - /** * What a night drive leaves on the next morning's card. * diff --git a/client/src/components/Roadtrip/RoadtripSidebarBadges.test.tsx b/client/src/components/Roadtrip/RoadtripSidebarBadges.test.tsx new file mode 100644 index 000000000..c751c2292 --- /dev/null +++ b/client/src/components/Roadtrip/RoadtripSidebarBadges.test.tsx @@ -0,0 +1,171 @@ +import React from 'react' +import { afterEach, 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 { RoadtripStop } from './useRoadtripRoutes' +import type { ScheduleEntry, ScheduleWarning } from './roadtripModel' +import { Arrival, DayCarry, DriveFindingBadge, FillBadge, LateBadge, NightCheckIn, OffRoadBadge, StayBadge } from './RoadtripSidebarBadges' +import { tooltipOf } from '../../../tests/helpers/roadtripRail' + +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()) +afterEach(() => { vi.useRealTimers() }) + +/** The badge shell a figure sits in: the element its value text lives two levels inside. */ +const shellOf = (text: string): Element => screen.getByText(text).parentElement! + +function stop(over: Partial = {}): RoadtripStop { + return { + assignmentId: 1, ownerDayId: 1, ownerIndex: 0, placeId: 10, name: 'Lake', lat: 47, lng: 11, + time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null, + ...over, + } +} + +const entry = (over: Partial = {}): ScheduleEntry => + ({ arrival: '18:30', departure: null, anchored: false, dayOffset: 0, ...over }) + +const warning = (over: Partial & Pick): ScheduleWarning => ({ index: 0, ...over }) + +describe('Road trip rail badges', () => { + it('FE-ROADTRIP-BADGES-001: the walk from the road reads in the traveller\'s own unit, in the figure and in the tooltip', () => { + const metric = wrap() + expect(screen.getByText('240 m')).toBeInTheDocument() + expect(metric.container.querySelector('svg.lucide-footprints')).not.toBeNull() + expect(tooltipOf(shellOf('240 m'))).toBe('240 m from the road') + metric.unmount() + + settings({ distance_unit: 'imperial' }) + wrap() + expect(screen.getByText('1 mi')).toBeInTheDocument() + }) + + it('FE-ROADTRIP-BADGES-002: a too long drive and a tank running dry each wear their own sign and figure', () => { + const leg = wrap() + expect(screen.getByText('+30 min')).toBeInTheDocument() + expect(leg.container.querySelector('svg.lucide-clock')).not.toBeNull() + expect(tooltipOf(shellOf('+30 min'))).toBe('30 min over your longest drive') + leg.unmount() + + // The rail never draws this one, it hands range findings to the refuel band, so only a + // direct render reaches it. + const range = wrap() + expect(screen.getByText('612 km')).toBeInTheDocument() + expect(range.container.querySelector('svg.lucide-fuel')).not.toBeNull() + expect(range.container.querySelector('svg.lucide-clock')).toBeNull() + expect(tooltipOf(shellOf('612 km'))).toBe('612 km since the last fill-up') + }) + + it('FE-ROADTRIP-BADGES-003: a finding that is not about the drive draws nothing, and missing figures count as zero', () => { + const late = wrap() + expect(late.container).toBeEmptyDOMElement() + late.unmount() + + wrap() + expect(screen.getByText('0 m')).toBeInTheDocument() + }) + + it('FE-ROADTRIP-BADGES-004: the fill-up badge invites a figure, says whose it is, and hides when it can say nothing', () => { + const silent = wrap() + expect(silent.container).toBeEmptyDOMElement() + silent.unmount() + + const onEdit = vi.fn() + const invite = wrap() + fireEvent.click(screen.getByRole('button')) + expect(onEdit).toHaveBeenCalledWith(screen.getByRole('button')) + expect(screen.getByText('+')).toHaveClass('text-content-faint') + expect(tooltipOf(screen.getByRole('button'))).toBe('Set how full this stop fills') + invite.unmount() + + const inherited = wrap() + expect(screen.getByText('80 %')).toHaveClass('text-content-faint') + expect(screen.queryByRole('button')).toBeNull() + inherited.unmount() + + wrap() + expect(screen.getByText('90 %')).not.toHaveClass('text-content-faint') + expect(tooltipOf(screen.getByRole('button'))).toBe('fills to 90 %') + }) + + it('FE-ROADTRIP-BADGES-005: the check-in shows only on the stop a night begins at, in the traveller\'s clock', () => { + const day = wrap() + expect(day.container).toBeEmptyDOMElement() + day.unmount() + + const noTime = wrap() + expect(noTime.container).toBeEmptyDOMElement() + noTime.unmount() + + settings({ time_format: '12h' }) + wrap() + expect(screen.getByText('Check-in')).toBeInTheDocument() + expect(screen.getByText('3:00 PM')).toBeInTheDocument() + }) + + it('FE-ROADTRIP-BADGES-006: the stay reads its length and the time it ends, and only an editor sees the plus', () => { + const readOnly = wrap() + expect(readOnly.container).toBeEmptyDOMElement() + readOnly.unmount() + + const onEdit = vi.fn() + const add = wrap() + fireEvent.click(screen.getByRole('button', { name: 'Add a stay' })) + expect(onEdit).toHaveBeenCalledTimes(1) + expect(screen.getByText('+')).toBeInTheDocument() + add.unmount() + + const both = wrap() + expect(screen.getByRole('button', { name: 'Time at this stop: 1 h 30 min until 14:00' })).toBeInTheDocument() + expect(screen.getByText('until 14:00')).toHaveClass('ms-1') + both.unmount() + + // Left at a set time with no length worked out yet: the end alone, with no gap before it. + wrap() + expect(screen.getByText('until 14:00')).not.toHaveClass('ms-1') + expect(screen.queryByText('+')).toBeNull() + expect(screen.queryByRole('button')).toBeNull() + }) + + it('FE-ROADTRIP-BADGES-007: an arrival you pinned stands out from a computed one, and past midnight it carries the day', () => { + const computed = wrap() + expect(screen.getByText('18:30')).toHaveClass('font-medium', 'text-content-faint') + expect(screen.getByText('18:30')).toHaveAttribute('dir', 'ltr') + expect(tooltipOf(screen.getByText('18:30'))).toBe('Calculated from the drive') + computed.unmount() + + settings({ time_format: '12h' }) + wrap() + const clock = screen.getByText('1:15 AM') + expect(clock).toHaveClass('font-semibold', 'text-content-secondary') + expect(clock).toHaveTextContent('1:15 AM+1 Next day') + expect(screen.getByText('Next day')).toHaveClass('sr-only') + expect(tooltipOf(clock)).toBe('Time you set') + }) + + it('FE-ROADTRIP-BADGES-008: the day carry stays silent on the same day', () => { + const same = wrap() + expect(same.container).toBeEmptyDOMElement() + same.unmount() + + wrap() + expect(screen.getByText('+2')).toHaveClass('ms-0.5') + }) + + it('FE-ROADTRIP-BADGES-009: arriving late and missing the time to leave are told apart in words', () => { + const late = wrap() + expect(screen.getByLabelText('Arrives 25 min after the time you set')).toBeInTheDocument() + expect(screen.getByText('+25 min').parentElement).toHaveAttribute('dir', 'ltr') + late.unmount() + + wrap() + expect(screen.getByLabelText('Arrives 0 min after the time you set to leave')).toBeInTheDocument() + expect(screen.getByText('+0 min')).toBeInTheDocument() + }) +}) diff --git a/client/src/components/Roadtrip/RoadtripSidebarBadges.tsx b/client/src/components/Roadtrip/RoadtripSidebarBadges.tsx new file mode 100644 index 000000000..b4610c8fe --- /dev/null +++ b/client/src/components/Roadtrip/RoadtripSidebarBadges.tsx @@ -0,0 +1,223 @@ +import React from 'react' +import { AlertTriangle, BatteryCharging, Clock, Footprints, Fuel } from 'lucide-react' +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, type ScheduleEntry, type ScheduleWarning } from './roadtripModel' +import type { RoadtripStop } from './useRoadtripRoutes' +import { FS } from './typeScale' +import FigureBadge from './FigureBadge' +import { shownStay, type StayReading } from './stayReading' + +/** + * How far the road stops short of the place, in the same two-part shell the stay wears. + * + * Beside the stay rather than under it: both answer "what does this stop cost you", one + * in time and one in a walk, and two badges on one line read as one fact about the stop + * instead of two unrelated notes stacked up. + */ +export function OffRoadBadge({ meters }: { meters: number }): React.ReactElement { + const { t } = useTranslation() + const distanceUnit = useSettingsStore(s => s.settings.distance_unit) + return ( + } + value={formatDistance(meters / 1000, distanceUnit)} + tooltip={t('roadtrip.stop.offRoad', { distance: formatDistance(meters / 1000, distanceUnit) })} + /> + ) +} + +/** + * What a fill-up here actually puts in, beside the stay it takes. + * + * Only on a stop that refuels, because it is the only stop where the answer changes + * anything. It reads as the second half of the stay badge because that is what it is: + * how long you stand here, and what you get for it. + * + * The figure is the stop's own when it has one and the traveller's default otherwise, so + * what the badge says is always what the range budget will actually use. With neither it + * shows a "+", the same invitation the stay badge makes when nothing is planned yet: this + * badge is the only way into the per-stop figure, and a badge that hid itself until a + * value existed could never be used to create one. + */ +export function FillBadge({ percent, own, onEdit }: { + /** What this stop will actually fill to, inherited or not. Null when nothing says. */ + percent: number | null + /** Whether that figure is the stop's own rather than the traveller's default. */ + own: boolean + onEdit?: (anchor: HTMLElement) => void +}): React.ReactElement | null { + const { t } = useTranslation() + if (percent === null && !onEdit) return null + return ( + } + value={percent === null ? '+' : `${percent} %`} + faint={percent === null || !own} + valueSize="micro" + tooltip={onEdit ? (percent === null ? t('roadtrip.stop.fillSet') : t('roadtrip.limit.fillBadge', { percent })) : undefined} + onActivate={onEdit} + /> + ) +} + +/** + * The check-in of the night a stay's own stop begins, on the day it does: the hour the + * room is ready, which is also what holds the arrival clock beside it when the drive gets + * there first. + */ +export function NightCheckIn({ stop }: { stop: RoadtripStop }): React.ReactElement | null { + const { t } = useTranslation() + const is12h = useSettingsStore(s => s.settings.time_format) === '12h' + if (!stop.night || !stop.checkInTime) return null + return +} + +/** + * How long the traveller stays here, and the way to change it. + * + * On every stop, not only the ones that already carry a time: the value has never been + * editable anywhere in TREK, so a stop that has none needs somewhere to say so before it + * can get one. Unset it reads as a plus in the slot the number will occupy, which keeps + * the two states the same shape and the same width: a row does not jump when a stay is + * added to it. + * + * Read-only for someone who cannot edit the trip: then it is a label, and a stop without + * a stay shows nothing at all rather than an invitation that leads nowhere. + * + * A stop the traveller leaves at a set time is stood at until then, so the badge reads the + * stay the schedule made of that and says until when. + */ +export function StayBadge({ stay, onEdit }: { stay: StayReading; onEdit?: () => void }): React.ReactElement | null { + const { t } = useTranslation() + const is12h = useSettingsStore(s => s.settings.time_format) === '12h' + const shown = shownStay(stay) + const text = shown === null ? null : formatDurationShort(shown * 60) + const until = stay.until ? t('roadtrip.stay.until', { time: formatClockTime(stay.until, is12h) }) : null + if (!text && !until && !onEdit) return null + return ( + + {text ?? (until ? null : '+')} + {until ? {until} : null} + + )} + faint={!text} + tooltip={onEdit ? t('roadtrip.stop.stay') : undefined} + onActivate={onEdit} + ariaLabel={onEdit + ? text || until ? `${t('roadtrip.stop.stay')}: ${[text, until].filter(Boolean).join(' ')}` : t('roadtrip.stay.add') + : undefined} + /> + ) +} + +/** + * A finding about the drive leaving this stop: too long at the wheel in one go, or the + * tank running out before anywhere to fill it. + * + * Wears the same two-part shell as the stay and the walk, in warning colours, and sits in + * the same row: all of them say what this stop costs, and one row of badges reads as one + * answer rather than as notes stacked under each other. The index is the stop the leg + * LEAVES, so the badge means "the drive from here". + * + * Neither finding names a time of day. With no stop pinned to a clock the cascade + * produces no times at all, so both are durations and distances, which exist as soon as + * the leg has routed. + */ +export function DriveFindingBadge({ warning }: { warning: ScheduleWarning }): React.ReactElement | null { + const { t } = useTranslation() + const distanceUnit = useSettingsStore(s => s.settings.distance_unit) + const text = warning.code === 'leg' + ? t('roadtrip.limit.legOver', { time: formatDurationShort((warning.overMinutes ?? 0) * 60) }) + : warning.code === 'range' + ? t('roadtrip.limit.range', { distance: formatDistance(warning.sinceKm ?? 0, distanceUnit) }) + : null + if (!text) return null + const Icon = warning.code === 'range' ? Fuel : Clock + return ( + } + value={warning.code === 'range' + ? formatDistance(warning.sinceKm ?? 0, distanceUnit) + : `+${formatDurationShort((warning.overMinutes ?? 0) * 60)}`} + tooltip={text} + /> + ) +} + +/** + * The timetable convention: past midnight the clock keeps reading small numbers, so the + * day it belongs to travels with it instead of sitting a line away as a separate note. + */ +export function DayCarry({ days }: { days: number }): React.ReactElement | null { + const { t } = useTranslation() + if (days <= 0) return null + return ( + + {`+${days}`} + {` ${t('roadtrip.warn.overnight')}`} + + ) +} + +export function Arrival({ entry }: { entry: ScheduleEntry }): React.ReactElement { + const { t } = useTranslation() + const is12h = useSettingsStore(s => s.settings.time_format) === '12h' + const text = formatClockTime(entry.arrival, is12h) + const carry = + + // Every arrival is plain text at the row's right edge, pinned or not. The pinned one + // used to be a filled pill with a clock, which made a column of quiet clock readings + // look like it had a button in it. What is left of the distinction is weight and ink: + // enough to see which time somebody chose, without a second shape in the rail. + return ( + + + {text} + {carry} + + + ) +} + +/** + * How far past the time you set this stop is reached. Its own component because a pause + * runs late exactly like a numbered stop does: the schedule computes the finding for both + * (roadtripModel restarts the chain at any anchor, whatever kind of stop carries it), and + * drawing it in only one of them threw the other one's away. + * + * It sits in the badge row beside the stay and the drive findings rather than on a line of + * its own: they are all answers to "what does this stop cost", and a warning on its own row + * pushed every following stop down for a finding that fits in a pill. + */ +export function LateBadge({ late }: { late: ScheduleWarning }): React.ReactElement { + const { t } = useTranslation() + const label = t(late.code === 'missedLeave' ? 'roadtrip.warn.missedLeave' : 'roadtrip.warn.late', { minutes: late.minutes ?? 0 }) + // The same two-part shell the stay and the drive findings wear, so a row of badges reads + // as one set instead of a pill among boxes. + return ( + } + value={`+${formatDurationShort((late.minutes ?? 0) * 60)}`} + tooltip={label} + /> + ) +} diff --git a/client/tests/helpers/roadtripRail.ts b/client/tests/helpers/roadtripRail.ts new file mode 100644 index 000000000..2b394e653 --- /dev/null +++ b/client/tests/helpers/roadtripRail.ts @@ -0,0 +1,20 @@ +import { act, fireEvent, screen } from '@testing-library/react' +import { vi } from 'vitest' + +/** Scaffolding for the tests of the road trip rail's parts, each rendered on its own. */ + +/** + * What the tooltip says once the pointer has rested on the element long enough. + * + * The pointer leaves again and the real clock is back before it returns, so a case can + * read one tooltip after another and render on with real timers. + */ +export function tooltipOf(el: Element): string { + vi.useFakeTimers() + fireEvent.mouseEnter(el) + act(() => { vi.advanceTimersByTime(300) }) + const text = screen.getByRole('tooltip').textContent ?? '' + fireEvent.mouseLeave(el) + vi.useRealTimers() + return text +}