diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx
index 5898cfbb9..8eaf8bb81 100644
--- a/client/src/components/Roadtrip/RoadtripSidebar.tsx
+++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx
@@ -4,12 +4,12 @@ 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, Moon, Milestone,
+ ParkingSquare, Shuffle, Clock, Spline, Ban, Moon,
type LucideIcon,
} from 'lucide-react'
import MDancingTrek from '../../mobile/components/MDancingTrek'
import type { RefuelSearch } from './useRefuelSearch'
-import { REFUEL_EMPTY_KEY, REFUEL_WORDS, refuelBandState, type RefuelCandidate } from './refuelSuggestion'
+import type { RefuelCandidate } from './refuelSuggestion'
import type { DryPoint } from './roadtripModel'
import { useTranslation } from '../../i18n/TranslationContext'
import { Tooltip } from '../shared/Tooltip'
@@ -43,6 +43,7 @@ import type { StayDraft } from './RoadtripStayModal'
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'
interface RoadtripSidebarProps {
onFocusPoint?: (lat: number, lng: number) => void
@@ -259,231 +260,6 @@ function TripSummary({ routes, narrow }: { routes: RoadtripRoutes; narrow: boole
* distance and the time belong together, and it leaves the row's right edge for the
* button instead of a second number.
*/
-
-/**
- * Where the tank runs out on this leg, and somewhere to do something about it.
- *
- * Sits under the drive band rather than on the stop that carries the range warning,
- * because those are two different places: the warning is filed where somebody finds out,
- * this is where the fuel actually ends. A filling station offered at the warning is one
- * the car cannot reach.
- *
- * Nothing is searched until it is asked for. Every press is a real request against a
- * shared service, which is the same reason the corridor search next door is manual.
- */
-function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
- dry: DryPoint & { lat: number; lng: number }
- refuel: RefuelSearch
- dayId: number
- onAsk: () => void
- onAccept?: (poi: RefuelCandidate) => void
-}): React.ReactElement {
- const { t } = useTranslation()
- const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
- // What the traveller drives, so the band says the right word and offers the right
- // thing. An electric car does not run out of TANK, and a lamp shaped like a pump on a
- // band about a battery is the kind of detail that makes the rest look careless.
- const { vehicleKind } = useVehicleRange()
- const electric = vehicleKind === 'electric'
- const DryIcon = electric ? Zap : Fuel
- const words = REFUEL_WORDS[electric ? 'electric' : 'fuel']
- // Which control, which sentence and which offers: decided in refuelSuggestion so the
- // phone band reads the same answer instead of keeping its own copy of the conditions.
- const band = refuelBandState(refuel, dayId, dry.legIndex)
-
- return (
-
- {/* Across the whole rail, marker column included, the way the day change used to
- be drawn. It belongs to no stop: the tank runs out between two of them, and
- hanging it off the one after would put it where somebody finds out rather than
- where it happens. Full width is also what makes it the ONLY thing saying this —
- the per-stop "so far on this tank" badge stands down wherever a search can be
- run, because after the band has said where the fuel ends, every later stop
- repeating it with a bigger number is the same message again. */}
-
-
- {/* Out of fuel is a thing that happens to the DRIVE, so the mascot is the one
- with the vehicle, and it is not enjoying it. */}
-
-
-
- {t(words.dry)}
-
- {/* How far INTO this leg, where a drive band keeps its figures. Not the
- range that was crossed: that is the traveller's own setting, says
- nothing about where, and made every band on a day read the same
- number. */}
-
- {/* The low-fuel lamp IS the button.
- It was a lamp beside a magnifier, which is the same thing said twice: the
- lamp says the tank is empty and the magnifier offers to do something
- about it, and nobody reads a dashboard warning as decoration. So the lamp
- glows, and pressing it goes looking. Larger than the 18px marks a drive
- band carries, because unlike those it is the one thing in the band worth
- pressing. What it does is in the tooltip and in the screen reader label.
-
- Once a search is open the lamp steps aside for the state that matters:
- the way to close the answer, or the way to ask again. An answer that
- found nothing leaves a button rather than a dead end — the place search
- is a shared public service that does time out, and "it did not answer"
- with no way to retry reads as broken rather than as busy. */}
- {band.control === 'close' ? (
-
-
-
- ) : (
-
-
-
- )}
-
- {/* Three at most. This is an offer beside a plan, not a list to browse;
- the corridor panel is where somebody goes to see all of them.
-
- Each one on its own surface rather than as bare text in the band: the
- band is the problem and these are the answers, and a row somebody is
- meant to press has to look like it can be. Two lines, because one was
- not enough to tell them apart — measured on a real day the top three
- came back as "Vattenfall InCharge" three times over, identical but for
- a number nobody could see the meaning of. */}
- {band.offers.map(poi => {
- const kind = STOP_KIND_BY_KEY[poi.category]
- const KindIcon = kind?.Icon ?? Fuel
- return (
-
- {/* Logical padding, not left/right: the rail runs the other way in Arabic and
- the disc has to keep its distance from the reading edge either way. */}
-
-
-
-
-
-
- {poi.name}
-
- {/* The two figures that decide it, in the order they decide it,
- and as two badges rather than one line with a middot in it.
- No value in this rail is ever joined to another that way:
- they are separate facts, and a separator invites them to be
- read as one number about one thing.
-
- The detour is what the list is SORTED by — everything here
- is reachable already, so what separates them is what the
- stop costs — and it is also the only thing telling three
- branches of one chain apart. What is left in the tank when
- the car draws level comes second, with the detour already
- counted into it. The badge carries the figure and the
- tooltip says which one it is, which is how two numbers stay
- legible in a column this narrow. */}
-
- }
- value={formatDistance(poi.offRouteKm, distanceUnit)}
- tooltip={t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })}
- />
- }
- value={formatDistance(Math.round(poi.spareKm), distanceUnit)}
- tooltip={t('roadtrip.refuel.spare', { distance: formatDistance(Math.round(poi.spareKm), distanceUnit) })}
- />
-
-
- {onAccept ? (
-
-
-
- ) : null}
-
-
- )
- })}
-
- ) : null}
-
- {/* Three different sentences for three different facts. "Nothing on this
- stretch" after a request that failed or was cut short states something
- that was never checked, which is worse than saying nothing. */}
- {band.empty ? (
-
- {t(REFUEL_EMPTY_KEY[band.empty])}
-
- ) : null}
-
-
- )
-}
-
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. */
diff --git a/client/src/components/Roadtrip/RoadtripSidebarRefuelBand.test.tsx b/client/src/components/Roadtrip/RoadtripSidebarRefuelBand.test.tsx
new file mode 100644
index 000000000..9936506cd
--- /dev/null
+++ b/client/src/components/Roadtrip/RoadtripSidebarRefuelBand.test.tsx
@@ -0,0 +1,137 @@
+import React from 'react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { fireEvent, render, screen, within } from '@testing-library/react'
+import { TranslationProvider } from '../../i18n'
+import { useSettingsStore } from '../../store/settingsStore'
+import type { DryPoint } from './roadtripModel'
+import type { RefuelSearch } from './useRefuelSearch'
+import type { RefuelCandidate } from './refuelSuggestion'
+import { RefuelBand } from './RoadtripSidebarRefuelBand'
+
+// The vehicle comes from the trip's road trip preferences. Read here from the settings
+// store, the way the limits card and the vehicle range tests read it, so a case can say
+// which vehicle it is standing in without a signed-in user and an open trip.
+vi.mock('../../hooks/useRoadtripSettings', () => ({
+ useRoadtripSettings: (select: (preferences: import('@trek/shared').RoadtripPreferences) => unknown) =>
+ useSettingsStore(state => select(state.settings as import('@trek/shared').RoadtripPreferences)),
+}))
+
+function settings(over: Record = {}): void {
+ useSettingsStore.setState({ settings: { distance_unit: 'metric', ...over } as never })
+}
+
+beforeEach(() => settings())
+
+const dry: DryPoint & { lat: number; lng: number } = {
+ legIndex: 2, intoLegKm: 182.4, drivenMeters: 182400, sinceKm: 600, lat: 52.4, lng: 10.2,
+}
+
+const search = (over: Partial = {}): RefuelSearch => ({
+ openFor: null, loading: false, outcome: null, results: [], offered: [], ask: vi.fn(), close: vi.fn(),
+ ...over,
+})
+
+const station = (name: string, category: string, over: Partial = {}): RefuelCandidate => ({
+ osm_id: `osm-${name}`, name, lat: 52.4, lng: 10.1, category, poi_type: category,
+ address: null, website: null, phone: null, opening_hours: null, cuisine: null, source: 'openstreetmap',
+ alongKm: 170, offRouteKm: 1.4, spareKm: 48.6,
+ ...over,
+})
+
+function band(refuel: RefuelSearch, handlers: { onAsk?: () => void; onAccept?: (poi: RefuelCandidate) => void } = {}) {
+ return render(
+
+
+ ,
+ )
+}
+
+describe('RefuelBand', () => {
+ it('FE-ROADTRIP-REFUELBAND-001: idle, it says how far into the leg the tank runs out and the lamp goes looking', () => {
+ const onAsk = vi.fn()
+ const { container } = band(search(), { onAsk })
+
+ expect(screen.getByText('Tank runs out here')).toBeInTheDocument()
+ expect(screen.getByText('after 182 km')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Find fuel' }))
+ expect(onAsk).toHaveBeenCalledTimes(1)
+ expect(container.querySelector('svg.trek-lowfuel.lucide-fuel')).not.toBeNull()
+ expect(screen.queryByText('Looking along the route…')).toBeNull()
+ })
+
+ it('FE-ROADTRIP-REFUELBAND-002: for an electric car the band talks about the battery and its lamp is a bolt', () => {
+ settings({ roadtrip_vehicle: 'electric' })
+ const { container } = band(search())
+
+ expect(screen.getByText('Battery runs out here')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Find charging' })).toBeInTheDocument()
+ expect(container.querySelector('svg.trek-lowfuel.lucide-zap')).not.toBeNull()
+ expect(container.querySelector('svg.lucide-fuel')).toBeNull()
+ })
+
+ it('FE-ROADTRIP-REFUELBAND-003: while the search runs for this leg the lamp gives way to closing it', () => {
+ const refuel = search({ openFor: '4:2', loading: true })
+ band(refuel)
+
+ expect(screen.getByText('Looking along the route…')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Find fuel' })).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: 'Close' }))
+ expect(refuel.close).toHaveBeenCalledTimes(1)
+ })
+
+ it('FE-ROADTRIP-REFUELBAND-004: a search open for another leg leaves this band idle', () => {
+ band(search({ openFor: '4:1', loading: true }))
+ expect(screen.queryByText('Looking along the route…')).toBeNull()
+ expect(screen.getByRole('button', { name: 'Find fuel' })).toBeInTheDocument()
+ })
+
+ it('FE-ROADTRIP-REFUELBAND-005: offers show their kind, the detour and the spare range, and pressing one accepts it', () => {
+ settings({ roadtrip_vehicle: 'electric' })
+ const onAccept = vi.fn()
+ const charger = station('Ionity Hannover', 'charging', { offRouteKm: 0.8, spareKm: 31 })
+ const odd = station('Mystery Pump', 'mystery', { offRouteKm: 2.5, spareKm: 12 })
+ const { container } = band(search({ openFor: '4:2', outcome: 'found', results: [charger, odd] }), { onAccept })
+
+ const offers = within(container.querySelector('ul')!).getAllByRole('listitem')
+ expect(offers).toHaveLength(2)
+ expect(within(offers[0]).getByText('800 m')).toBeInTheDocument()
+ expect(within(offers[0]).getByText('31 km')).toBeInTheDocument()
+ // The kind's own icon on a known kind; a kind the table does not know keeps the pump.
+ expect(offers[0].querySelector('span[aria-hidden] svg.lucide-zap')).not.toBeNull()
+ expect(offers[1].querySelector('span[aria-hidden] svg.lucide-fuel')).not.toBeNull()
+
+ fireEvent.click(screen.getByRole('button', { name: 'Add Ionity Hannover as a charging stop' }))
+ expect(onAccept).toHaveBeenCalledWith(charger)
+ // Results on show, so the lamp is the way to close them.
+ expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument()
+ })
+
+ it('FE-ROADTRIP-REFUELBAND-006: without the right to add a stop the offers are there to read, with nothing to press', () => {
+ band(search({ openFor: '4:2', outcome: 'found', results: [station('Shell Hannover', 'fuel')] }))
+ expect(screen.getByText('Shell Hannover')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: /Add Shell Hannover/ })).toBeNull()
+ })
+
+ it('FE-ROADTRIP-REFUELBAND-007: an empty answer names which nothing it was and offers to try again', () => {
+ const onAsk = vi.fn()
+ const { rerender } = band(search({ openFor: '4:2', outcome: 'none' }), { onAsk })
+ expect(screen.getByText('Nothing found on the reachable stretch.')).toBeInTheDocument()
+
+ rerender(
+
+
+ ,
+ )
+ expect(screen.getByText('The search was cut short, so this stretch was not fully checked.')).toBeInTheDocument()
+
+ rerender(
+
+
+ ,
+ )
+ expect(screen.getByText('The place search did not answer.')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
+ expect(onAsk).toHaveBeenCalledTimes(1)
+ expect(screen.queryByRole('list')).toBeNull()
+ })
+})
diff --git a/client/src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx b/client/src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx
new file mode 100644
index 000000000..e4e231d88
--- /dev/null
+++ b/client/src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx
@@ -0,0 +1,239 @@
+import React from 'react'
+import { Fuel, Milestone, Plus, RotateCcw, X, Zap } from 'lucide-react'
+import MDancingTrek from '../../mobile/components/MDancingTrek'
+import type { RefuelSearch } from './useRefuelSearch'
+import { REFUEL_EMPTY_KEY, REFUEL_WORDS, refuelBandState, type RefuelCandidate } from './refuelSuggestion'
+import type { DryPoint } from './roadtripModel'
+import { useTranslation } from '../../i18n/TranslationContext'
+import { Tooltip } from '../shared/Tooltip'
+import { useSettingsStore } from '../../store/settingsStore'
+import { formatDistance } from '../../utils/units'
+import { STOP_KIND_BY_KEY } from './stopKinds'
+import { useVehicleRange } from './useVehicleRange'
+import FigureBadge from './FigureBadge'
+import { FS } from './typeScale'
+import { DISC } from './RoadtripSidebar.constants'
+
+/**
+ * Where the tank runs out on this leg, and somewhere to do something about it.
+ *
+ * Sits under the drive band rather than on the stop that carries the range warning,
+ * because those are two different places: the warning is filed where somebody finds out,
+ * this is where the fuel actually ends. A filling station offered at the warning is one
+ * the car cannot reach.
+ *
+ * Nothing is searched until it is asked for. Every press is a real request against a
+ * shared service, which is the same reason the corridor search next door is manual.
+ */
+export function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
+ dry: DryPoint & { lat: number; lng: number }
+ refuel: RefuelSearch
+ dayId: number
+ onAsk: () => void
+ onAccept?: (poi: RefuelCandidate) => void
+}): React.ReactElement {
+ const { t } = useTranslation()
+ const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
+ // What the traveller drives, so the band says the right word and offers the right
+ // thing. An electric car does not run out of TANK, and a lamp shaped like a pump on a
+ // band about a battery is the kind of detail that makes the rest look careless.
+ const { vehicleKind } = useVehicleRange()
+ const electric = vehicleKind === 'electric'
+ const DryIcon = electric ? Zap : Fuel
+ const words = REFUEL_WORDS[electric ? 'electric' : 'fuel']
+ // Which control, which sentence and which offers: decided in refuelSuggestion so the
+ // phone band reads the same answer instead of keeping its own copy of the conditions.
+ const band = refuelBandState(refuel, dayId, dry.legIndex)
+
+ return (
+
+ {/* Across the whole rail, marker column included, the way the day change used to
+ be drawn. It belongs to no stop: the tank runs out between two of them, and
+ hanging it off the one after would put it where somebody finds out rather than
+ where it happens. Full width is also what makes it the ONLY thing saying this:
+ the per-stop "so far on this tank" badge stands down wherever a search can be
+ run, because after the band has said where the fuel ends, every later stop
+ repeating it with a bigger number is the same message again. */}
+
+
+ {/* Out of fuel is a thing that happens to the DRIVE, so the mascot is the one
+ with the vehicle, and it is not enjoying it. */}
+
+
+
+ {t(words.dry)}
+
+ {/* How far INTO this leg, where a drive band keeps its figures. Not the
+ range that was crossed: that is the traveller's own setting, says
+ nothing about where, and made every band on a day read the same
+ number. */}
+
+ {/* The low-fuel lamp IS the button.
+ It was a lamp beside a magnifier, which is the same thing said twice: the
+ lamp says the tank is empty and the magnifier offers to do something
+ about it, and nobody reads a dashboard warning as decoration. So the lamp
+ glows, and pressing it goes looking. Larger than the 18px marks a drive
+ band carries, because unlike those it is the one thing in the band worth
+ pressing. What it does is in the tooltip and in the screen reader label.
+
+ Once a search is open the lamp steps aside for the state that matters:
+ the way to close the answer, or the way to ask again. An answer that
+ found nothing leaves a button rather than a dead end: the place search
+ is a shared public service that does time out, and "it did not answer"
+ with no way to retry reads as broken rather than as busy. */}
+ {band.control === 'close' ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ {/* Three at most. This is an offer beside a plan, not a list to browse;
+ the corridor panel is where somebody goes to see all of them.
+
+ Each one on its own surface rather than as bare text in the band: the
+ band is the problem and these are the answers, and a row somebody is
+ meant to press has to look like it can be. Two lines, because one was
+ not enough to tell them apart: measured on a real day the top three
+ came back as "Vattenfall InCharge" three times over, identical but for
+ a number nobody could see the meaning of. */}
+ {band.offers.map(poi => {
+ const kind = STOP_KIND_BY_KEY[poi.category]
+ const KindIcon = kind?.Icon ?? Fuel
+ return (
+
+ {/* Logical padding, not left/right: the rail runs the other way in Arabic and
+ the disc has to keep its distance from the reading edge either way. */}
+
+
+
+
+
+
+ {poi.name}
+
+ {/* The two figures that decide it, in the order they decide it,
+ and as two badges rather than one line with a middot in it.
+ No value in this rail is ever joined to another that way:
+ they are separate facts, and a separator invites them to be
+ read as one number about one thing.
+
+ The detour is what the list is SORTED by (everything here
+ is reachable already, so what separates them is what the
+ stop costs), and it is also the only thing telling three
+ branches of one chain apart. What is left in the tank when
+ the car draws level comes second, with the detour already
+ counted into it. The badge carries the figure and the
+ tooltip says which one it is, which is how two numbers stay
+ legible in a column this narrow. */}
+
+ }
+ value={formatDistance(poi.offRouteKm, distanceUnit)}
+ tooltip={t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })}
+ />
+ }
+ value={formatDistance(Math.round(poi.spareKm), distanceUnit)}
+ tooltip={t('roadtrip.refuel.spare', { distance: formatDistance(Math.round(poi.spareKm), distanceUnit) })}
+ />
+
+
+ {onAccept ? (
+
+
+
+ ) : null}
+
+
+ )
+ })}
+
+ ) : null}
+
+ {/* Three different sentences for three different facts. "Nothing on this
+ stretch" after a request that failed or was cut short states something
+ that was never checked, which is worse than saying nothing. */}
+ {band.empty ? (
+
+ {t(REFUEL_EMPTY_KEY[band.empty])}
+
+ ) : null}
+