From 8e4874841f30a860d65951d3d60ef68ab793faea Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 01:21:16 +0000 Subject: [PATCH] refactor(client): the fill-up band moves to RoadtripSidebarRefuelBand RefuelBand leaves RoadtripSidebar.tsx for its own module and is imported back by name; the imports only it used go with it. The code moves as it was, with the dashes in its comments rewritten as colons and parentheses. DriveBand's note, which the band used to separate from its function, now sits directly above DriveBand. Nothing renders differently. New tests render the band on its own: idle, searching, offers with and without the right to add one, each empty answer, and the battery words for an electric car, which no existing test reached. --- .../components/Roadtrip/RoadtripSidebar.tsx | 230 +---------------- .../RoadtripSidebarRefuelBand.test.tsx | 137 ++++++++++ .../Roadtrip/RoadtripSidebarRefuelBand.tsx | 239 ++++++++++++++++++ 3 files changed, 379 insertions(+), 227 deletions(-) create mode 100644 client/src/components/Roadtrip/RoadtripSidebarRefuelBand.test.tsx create mode 100644 client/src/components/Roadtrip/RoadtripSidebarRefuelBand.tsx 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. */} -
- {t('roadtrip.refuel.after', { distance: formatDistance(Math.round(dry.intoLegKm), distanceUnit) })} -
-
- {/* 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' ? ( - - - - ) : ( - - - - )} -
- - {band.loading ? ( - {t('roadtrip.refuel.looking')} - ) : null} - - {band.offers.length ? ( -
    - {/* 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. */} +
+ {t('roadtrip.refuel.after', { distance: formatDistance(Math.round(dry.intoLegKm), distanceUnit) })} +
+
+ {/* 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' ? ( + + + + ) : ( + + + + )} +
+ + {band.loading ? ( + {t('roadtrip.refuel.looking')} + ) : null} + + {band.offers.length ? ( +
    + {/* 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} +
+
+ ) +}