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 ? ( - - ) : 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} +
+
+ ) +}