mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(client): the stop badges and the arrival clock move to RoadtripSidebarBadges
OffRoadBadge, FillBadge, NightCheckIn, StayBadge, DriveFindingBadge, DayCarry, Arrival and LateBadge leave RoadtripSidebar.tsx for their own module and are imported back by name. The code moves as it was. The doc blocks that sat stranded above other functions now stand above the badge they describe, StayBadge's two notes as one, and the dashes in the moved comments become colons and commas. Nothing renders differently. New tests render each badge on its own, including the walk from the road and the range and unknown arms of the drive finding, which the rail never reaches.
This commit is contained in:
@@ -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 (
|
||||
<FigureBadge
|
||||
lead={<Footprints size={9} aria-hidden />}
|
||||
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 (
|
||||
<FigureBadge
|
||||
lead={<BatteryCharging size={9} aria-hidden />}
|
||||
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 <FigureBadge caption lead={t('roadtrip.bookend.checkIn')} value={formatClockTime(stop.checkInTime, is12h)} />
|
||||
}
|
||||
|
||||
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 (
|
||||
<FigureBadge
|
||||
caption
|
||||
lead={t('roadtrip.stop.stayShort')}
|
||||
value={(
|
||||
<>
|
||||
{text ?? (until ? null : '+')}
|
||||
{until ? <span className={`font-medium text-content-faint ${text ? 'ms-1' : ''}`}>{until}</span> : 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 <span className="font-medium text-content-muted" style={{ fontSize: FS.totalUnit }}>{children}</span>
|
||||
@@ -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 (
|
||||
<FigureBadge
|
||||
tone="warning"
|
||||
lead={<Icon size={9} aria-hidden />}
|
||||
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 (
|
||||
<span className="ms-0.5">
|
||||
{`+${days}`}
|
||||
<span className="sr-only">{` ${t('roadtrip.warn.overnight')}`}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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 = <DayCarry days={entry.dayOffset} />
|
||||
|
||||
// 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 (
|
||||
<Tooltip label={entry.anchored ? t('roadtrip.stop.pinned') : t('roadtrip.stop.computed')}>
|
||||
<span
|
||||
dir="ltr"
|
||||
// The same line box as the stop name beside it, so the two sit on one line however
|
||||
// far apart their sizes are — the row reads across, not in two staggered halves.
|
||||
className={`shrink-0 whitespace-nowrap leading-6 tabular-nums ${
|
||||
entry.anchored ? 'font-semibold text-content-secondary' : 'font-medium text-content-faint'
|
||||
}`}
|
||||
style={{ fontSize: FS.time }}
|
||||
>
|
||||
{text}
|
||||
{carry}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<FigureBadge
|
||||
tone="warning"
|
||||
dir="ltr"
|
||||
lead={<AlertTriangle size={9} aria-label={label} />}
|
||||
value={`+${formatDurationShort((late.minutes ?? 0) * 60)}`}
|
||||
tooltip={label}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What a night drive leaves on the next morning's card.
|
||||
*
|
||||
|
||||
@@ -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(<TranslationProvider>{ui}</TranslationProvider>)
|
||||
|
||||
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> = {}): 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> = {}): ScheduleEntry =>
|
||||
({ arrival: '18:30', departure: null, anchored: false, dayOffset: 0, ...over })
|
||||
|
||||
const warning = (over: Partial<ScheduleWarning> & Pick<ScheduleWarning, 'code'>): 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(<OffRoadBadge meters={240} />)
|
||||
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(<OffRoadBadge meters={1609.344} />)
|
||||
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(<DriveFindingBadge warning={warning({ code: 'leg', overMinutes: 30 })} />)
|
||||
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(<DriveFindingBadge warning={warning({ code: 'range', sinceKm: 612 })} />)
|
||||
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(<DriveFindingBadge warning={warning({ code: 'late', minutes: 20 })} />)
|
||||
expect(late.container).toBeEmptyDOMElement()
|
||||
late.unmount()
|
||||
|
||||
wrap(<DriveFindingBadge warning={warning({ code: 'range' })} />)
|
||||
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(<FillBadge percent={null} own={false} />)
|
||||
expect(silent.container).toBeEmptyDOMElement()
|
||||
silent.unmount()
|
||||
|
||||
const onEdit = vi.fn()
|
||||
const invite = wrap(<FillBadge percent={null} own={false} onEdit={onEdit} />)
|
||||
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(<FillBadge percent={80} own={false} />)
|
||||
expect(screen.getByText('80 %')).toHaveClass('text-content-faint')
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
inherited.unmount()
|
||||
|
||||
wrap(<FillBadge percent={90} own onEdit={onEdit} />)
|
||||
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(<NightCheckIn stop={stop({ checkInTime: '15:00' })} />)
|
||||
expect(day.container).toBeEmptyDOMElement()
|
||||
day.unmount()
|
||||
|
||||
const noTime = wrap(<NightCheckIn stop={stop({ night: true })} />)
|
||||
expect(noTime.container).toBeEmptyDOMElement()
|
||||
noTime.unmount()
|
||||
|
||||
settings({ time_format: '12h' })
|
||||
wrap(<NightCheckIn stop={stop({ night: true, checkInTime: '15:00' })} />)
|
||||
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(<StayBadge stay={{ minutes: null, until: null }} />)
|
||||
expect(readOnly.container).toBeEmptyDOMElement()
|
||||
readOnly.unmount()
|
||||
|
||||
const onEdit = vi.fn()
|
||||
const add = wrap(<StayBadge stay={{ minutes: null, until: null }} onEdit={onEdit} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add a stay' }))
|
||||
expect(onEdit).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByText('+')).toBeInTheDocument()
|
||||
add.unmount()
|
||||
|
||||
const both = wrap(<StayBadge stay={{ minutes: 90, until: '14:00' }} onEdit={onEdit} />)
|
||||
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(<StayBadge stay={{ minutes: null, until: '14:00' }} />)
|
||||
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(<Arrival entry={entry()} />)
|
||||
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(<Arrival entry={entry({ arrival: '01:15', anchored: true, dayOffset: 1 })} />)
|
||||
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(<DayCarry days={0} />)
|
||||
expect(same.container).toBeEmptyDOMElement()
|
||||
same.unmount()
|
||||
|
||||
wrap(<DayCarry days={2} />)
|
||||
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(<LateBadge late={warning({ code: 'late', minutes: 25 })} />)
|
||||
expect(screen.getByLabelText('Arrives 25 min after the time you set')).toBeInTheDocument()
|
||||
expect(screen.getByText('+25 min').parentElement).toHaveAttribute('dir', 'ltr')
|
||||
late.unmount()
|
||||
|
||||
wrap(<LateBadge late={warning({ code: 'missedLeave' })} />)
|
||||
expect(screen.getByLabelText('Arrives 0 min after the time you set to leave')).toBeInTheDocument()
|
||||
expect(screen.getByText('+0 min')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<FigureBadge
|
||||
lead={<Footprints size={9} aria-hidden />}
|
||||
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 (
|
||||
<FigureBadge
|
||||
lead={<BatteryCharging size={9} aria-hidden />}
|
||||
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 <FigureBadge caption lead={t('roadtrip.bookend.checkIn')} value={formatClockTime(stop.checkInTime, is12h)} />
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<FigureBadge
|
||||
caption
|
||||
lead={t('roadtrip.stop.stayShort')}
|
||||
value={(
|
||||
<>
|
||||
{text ?? (until ? null : '+')}
|
||||
{until ? <span className={`font-medium text-content-faint ${text ? 'ms-1' : ''}`}>{until}</span> : 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 (
|
||||
<FigureBadge
|
||||
tone="warning"
|
||||
lead={<Icon size={9} aria-hidden />}
|
||||
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 (
|
||||
<span className="ms-0.5">
|
||||
{`+${days}`}
|
||||
<span className="sr-only">{` ${t('roadtrip.warn.overnight')}`}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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 = <DayCarry days={entry.dayOffset} />
|
||||
|
||||
// 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 (
|
||||
<Tooltip label={entry.anchored ? t('roadtrip.stop.pinned') : t('roadtrip.stop.computed')}>
|
||||
<span
|
||||
dir="ltr"
|
||||
// The same line box as the stop name beside it, so the two sit on one line however
|
||||
// far apart their sizes are, and the row reads across, not in two staggered halves.
|
||||
className={`shrink-0 whitespace-nowrap leading-6 tabular-nums ${
|
||||
entry.anchored ? 'font-semibold text-content-secondary' : 'font-medium text-content-faint'
|
||||
}`}
|
||||
style={{ fontSize: FS.time }}
|
||||
>
|
||||
{text}
|
||||
{carry}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<FigureBadge
|
||||
tone="warning"
|
||||
dir="ltr"
|
||||
lead={<AlertTriangle size={9} aria-label={label} />}
|
||||
value={`+${formatDurationShort((late.minutes ?? 0) * 60)}`}
|
||||
tooltip={label}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user