mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
feat(roadtrip): the phone dry band gets the desk's mascot and reserve lamp
"Tank runs out here" on the phone was a pink card with a black pill. It now has the desktop band's design sized for a thumb: the mascot on its skateboard and not enjoying it, a small uppercase warning, and the reserve lamp as a 44px button. While a search runs or its offers show, the lamp steps aside for a close (nothing in the band could clear the offer pins before), and an empty answer leaves a retry. Which control shows, which offers and which empty sentence is decided in refuelBandState in refuelSuggestion.ts, next to the fuel and battery words. The desktop RefuelBand reads the same helper now, with no visual change, so the two shells hold markup only.
This commit is contained in:
@@ -8,7 +8,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import MDancingTrek from '../../mobile/components/MDancingTrek'
|
||||
import type { RefuelSearch } from './useRefuelSearch'
|
||||
import type { RefuelCandidate } from './refuelSuggestion'
|
||||
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'
|
||||
@@ -445,9 +445,10 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
|
||||
const { vehicleKind } = useVehicleRange()
|
||||
const electric = vehicleKind === 'electric'
|
||||
const DryIcon = electric ? Zap : Fuel
|
||||
const key = `${dayId}:${dry.legIndex}`
|
||||
const open = refuel.openFor === key
|
||||
const settled = open && !refuel.loading && refuel.outcome
|
||||
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 (
|
||||
<div className="min-w-0">
|
||||
@@ -490,7 +491,7 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
|
||||
className="truncate font-geist font-semibold uppercase tracking-[0.16em] text-danger"
|
||||
style={{ fontSize: FS.label }}
|
||||
>
|
||||
{t(electric ? 'roadtrip.refuel.dryElectric' : 'roadtrip.refuel.dry')}
|
||||
{t(words.dry)}
|
||||
</div>
|
||||
{/* 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
|
||||
@@ -513,7 +514,7 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
|
||||
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. */}
|
||||
{open && (refuel.loading || refuel.results.length) ? (
|
||||
{band.control === 'close' ? (
|
||||
<Tooltip label={t('common.close')}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -525,18 +526,14 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip label={settled && refuel.outcome !== 'found'
|
||||
? t('roadtrip.refuel.again')
|
||||
: t(electric ? 'roadtrip.refuel.findElectric' : 'roadtrip.refuel.find')}>
|
||||
<Tooltip label={t(band.control === 'again' ? 'roadtrip.refuel.again' : words.find)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAsk}
|
||||
aria-label={settled && refuel.outcome !== 'found'
|
||||
? t('roadtrip.refuel.again')
|
||||
: t(electric ? 'roadtrip.refuel.findElectric' : 'roadtrip.refuel.find')}
|
||||
aria-label={t(band.control === 'again' ? 'roadtrip.refuel.again' : words.find)}
|
||||
className="group/fuel grid h-[22px] w-[22px] shrink-0 place-items-center rounded-lg text-danger transition-colors hover:bg-danger-soft focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{settled && refuel.outcome !== 'found'
|
||||
{band.control === 'again'
|
||||
? <RotateCcw size={13} strokeWidth={2} aria-hidden />
|
||||
: (
|
||||
/* The glow stops under the pointer: a lamp that keeps blinking
|
||||
@@ -553,12 +550,11 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && refuel.loading ? (
|
||||
{band.loading ? (
|
||||
<span className="text-content-muted" style={{ fontSize: FS.meta }}>{t('roadtrip.refuel.looking')}</span>
|
||||
) : null}
|
||||
|
||||
{settled ? (
|
||||
refuel.results.length ? (
|
||||
{band.offers.length ? (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{/* 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.
|
||||
@@ -569,7 +565,7 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
|
||||
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. */}
|
||||
{refuel.results.slice(0, 3).map(poi => {
|
||||
{band.offers.map(poi => {
|
||||
const kind = STOP_KIND_BY_KEY[poi.category]
|
||||
const KindIcon = kind?.Icon ?? Fuel
|
||||
return (
|
||||
@@ -632,11 +628,11 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
|
||||
</span>
|
||||
</span>
|
||||
{onAccept ? (
|
||||
<Tooltip label={t(electric ? 'roadtrip.refuel.addElectric' : 'roadtrip.refuel.add', { name: poi.name })}>
|
||||
<Tooltip label={t(words.add, { name: poi.name })}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAccept(poi)}
|
||||
aria-label={t(electric ? 'roadtrip.refuel.addElectric' : 'roadtrip.refuel.add', { name: poi.name })}
|
||||
aria-label={t(words.add, { name: poi.name })}
|
||||
className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-lg bg-surface-secondary text-content-secondary transition-colors hover:bg-accent hover:text-accent-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<Plus size={13} strokeWidth={2.2} aria-hidden />
|
||||
@@ -648,18 +644,15 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
/* 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. */
|
||||
<span className="text-content-muted" style={{ fontSize: FS.meta }}>
|
||||
{refuel.outcome === 'none'
|
||||
? t('roadtrip.refuel.none')
|
||||
: refuel.outcome === 'incomplete'
|
||||
? t('roadtrip.refuel.incomplete')
|
||||
: t('roadtrip.refuel.failed')}
|
||||
</span>
|
||||
)
|
||||
) : 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 ? (
|
||||
<span className="text-content-muted" style={{ fontSize: FS.meta }}>
|
||||
{t(REFUEL_EMPTY_KEY[band.empty])}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { reachableRefuels, outcomeOf } from './refuelSuggestion'
|
||||
import {
|
||||
reachableRefuels, outcomeOf, refuelBandState, refuelKey, REFUEL_EMPTY_KEY, REFUEL_OFFER_LIMIT, REFUEL_WORDS,
|
||||
type RefuelCandidate, type RefuelOutcome, type RefuelSearchView,
|
||||
} from './refuelSuggestion'
|
||||
import type { LatLng } from './corridor'
|
||||
import type { Poi } from '../Map/poiCategories'
|
||||
|
||||
@@ -122,3 +125,67 @@ describe('reachableRefuels — one entry per station', () => {
|
||||
expect(out.map(p => p.name).sort()).toEqual(['Aral', 'Shell'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('refuelBandState', () => {
|
||||
const candidate = (name: string): RefuelCandidate =>
|
||||
({ ...poi(name, 52, 13.5), alongKm: 40, offRouteKm: 0.4, spareKm: 30 })
|
||||
const search = (over: Partial<RefuelSearchView> = {}): RefuelSearchView =>
|
||||
({ openFor: null, loading: false, outcome: null, results: [], ...over })
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-013: a search filed under another leg leaves this band alone', () => {
|
||||
// One search is open at a time and it names the dry point it belongs to, so a band
|
||||
// three legs down the chain must not light up for a question asked about this one.
|
||||
expect(refuelKey(7, 1)).toBe('7:1')
|
||||
const band = refuelBandState(search({ openFor: '7:4', outcome: 'found', results: [candidate('a')] }), 7, 1)
|
||||
expect(band).toEqual({ open: false, loading: false, offers: [], empty: null, control: 'find' })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-014: while the request runs the lamp steps aside for the way out', () => {
|
||||
const band = refuelBandState(search({ openFor: '7:1', loading: true }), 7, 1)
|
||||
expect(band).toEqual({ open: true, loading: true, offers: [], empty: null, control: 'close' })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-015: an answer lists three at most, in the order the search ranked them', () => {
|
||||
const results = ['a', 'b', 'c', 'd', 'e'].map(candidate)
|
||||
const band = refuelBandState(search({ openFor: '7:1', outcome: 'found', results }), 7, 1)
|
||||
expect(REFUEL_OFFER_LIMIT).toBe(3)
|
||||
expect(band.offers.map(o => o.name)).toEqual(['a', 'b', 'c'])
|
||||
expect(band.control).toBe('close')
|
||||
expect(band.empty).toBeNull()
|
||||
})
|
||||
|
||||
it.each<Exclude<RefuelOutcome, 'found'>>(['none', 'incomplete', 'failed'])(
|
||||
'FE-ROADTRIP-REFUEL-016: an empty answer (%s) says which nothing it was and leaves a retry',
|
||||
outcome => {
|
||||
// A retry rather than a dead end: the place search is a shared service that does
|
||||
// time out, and no answer with no way to ask again reads as broken.
|
||||
const band = refuelBandState(search({ openFor: '7:1', outcome }), 7, 1)
|
||||
expect(band).toEqual({ open: true, loading: false, offers: [], empty: outcome, control: 'again' })
|
||||
expect(REFUEL_EMPTY_KEY[outcome]).toBe(`roadtrip.refuel.${outcome}`)
|
||||
},
|
||||
)
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-017: asked but nothing back yet keeps the lamp and says nothing', () => {
|
||||
const band = refuelBandState(search({ openFor: '7:1' }), 7, 1)
|
||||
expect(band).toEqual({ open: true, loading: false, offers: [], empty: null, control: 'find' })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-018: the battery never borrows the tank\'s words', () => {
|
||||
expect(REFUEL_WORDS.fuel).toEqual({
|
||||
dry: 'roadtrip.refuel.dry', find: 'roadtrip.refuel.find', add: 'roadtrip.refuel.add',
|
||||
})
|
||||
expect(REFUEL_WORDS.electric).toEqual({
|
||||
dry: 'roadtrip.refuel.dryElectric', find: 'roadtrip.refuel.findElectric', add: 'roadtrip.refuel.addElectric',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-019: results that arrive before their outcome already offer the way out', () => {
|
||||
// The close control follows the results rather than the outcome, which is how the
|
||||
// desktop rail always decided it: once there is something on the map to dismiss,
|
||||
// the band offers to dismiss it.
|
||||
const band = refuelBandState(search({ openFor: '7:1', results: [candidate('a')] }), 7, 1)
|
||||
expect(band.control).toBe('close')
|
||||
expect(band.offers).toEqual([])
|
||||
expect(band.empty).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -121,3 +121,103 @@ function oneEach(sorted: RefuelCandidate[]): RefuelCandidate[] {
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
/**
|
||||
* How many answers a band lists, on the desktop rail and on the phone alike.
|
||||
*
|
||||
* Three at most. This is an offer beside a plan, not a list to browse; the corridor
|
||||
* search is where somebody goes to see all of them. Named once so the two bands can never
|
||||
* disagree about what "the offers" are.
|
||||
*/
|
||||
export const REFUEL_OFFER_LIMIT = 3
|
||||
|
||||
/**
|
||||
* The name a search is filed under: the card day the band is drawn on and the leg.
|
||||
*
|
||||
* One search is open at a time and it names the dry point it belongs to, so a band three
|
||||
* legs down the chain does not light up for a question asked about this one. The card
|
||||
* day rather than the day a borrowed stop is stored on, because the answer has to arrive
|
||||
* under a key the band that asked is watching.
|
||||
*/
|
||||
export function refuelKey(dayId: number, legIndex: number): string {
|
||||
return `${dayId}:${legIndex}`
|
||||
}
|
||||
|
||||
/** What sits in the band's trailing slot: the lamp that goes looking, a retry, or a close. */
|
||||
export type RefuelControl = 'find' | 'again' | 'close'
|
||||
|
||||
/**
|
||||
* The part of the search a band reads.
|
||||
*
|
||||
* Structural rather than the hook's own type, because useRefuelSearch.ts imports this
|
||||
* module and the pure half should not have to know about the half with the network in it.
|
||||
*/
|
||||
export interface RefuelSearchView {
|
||||
openFor: string | null
|
||||
loading: boolean
|
||||
outcome: RefuelOutcome | null
|
||||
results: readonly RefuelCandidate[]
|
||||
}
|
||||
|
||||
/** Everything a band shows that is not markup, decided once for both shells. */
|
||||
export interface RefuelBandState {
|
||||
/** The open search belongs to this band. */
|
||||
open: boolean
|
||||
/** This band's question is out and has not come back yet. */
|
||||
loading: boolean
|
||||
/** The answers to list, already capped at REFUEL_OFFER_LIMIT; empty until there are some. */
|
||||
offers: RefuelCandidate[]
|
||||
/** Which kind of nothing came back, or null while there is no empty answer to report. */
|
||||
empty: Exclude<RefuelOutcome, 'found'> | null
|
||||
/** What fills the trailing slot; `refuelBandState` says why each one wins when it does. */
|
||||
control: RefuelControl
|
||||
}
|
||||
|
||||
/**
|
||||
* The state of one dry band, given the one search that may be open.
|
||||
*
|
||||
* The lamp steps aside while a request runs or offers are showing, for the way to close
|
||||
* the answer: every press is a real request against a shared service, and a lamp still
|
||||
* standing there invites a second one nobody asked for. An answer that found nothing
|
||||
* leaves a retry rather than a dead end, because the place search is a public service
|
||||
* that does time out, and "it did not answer" with no way to ask again reads as broken
|
||||
* rather than as busy.
|
||||
*
|
||||
* The empty answer keeps 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, so anything that is not a plain
|
||||
* `none` or `incomplete` reads as `failed`.
|
||||
*/
|
||||
export function refuelBandState(search: RefuelSearchView, dayId: number, legIndex: number): RefuelBandState {
|
||||
const open = search.openFor === refuelKey(dayId, legIndex)
|
||||
const loading = open && search.loading
|
||||
const settled = open && !search.loading && search.outcome != null
|
||||
const offers = settled && search.results.length > 0 ? search.results.slice(0, REFUEL_OFFER_LIMIT) : []
|
||||
let empty: RefuelBandState['empty'] = null
|
||||
if (settled && offers.length === 0) {
|
||||
empty = search.outcome === 'none' || search.outcome === 'incomplete' ? search.outcome : 'failed'
|
||||
}
|
||||
let control: RefuelControl = 'find'
|
||||
if (loading || (open && search.results.length > 0)) control = 'close'
|
||||
else if (settled && search.outcome !== 'found') control = 'again'
|
||||
return { open, loading, offers, empty, control }
|
||||
}
|
||||
|
||||
/** Which sentence each kind of empty answer gets. */
|
||||
export const REFUEL_EMPTY_KEY: Record<Exclude<RefuelOutcome, 'found'>, string> = {
|
||||
none: 'roadtrip.refuel.none',
|
||||
incomplete: 'roadtrip.refuel.incomplete',
|
||||
failed: 'roadtrip.refuel.failed',
|
||||
}
|
||||
|
||||
/**
|
||||
* The band's words for a tank and for a battery.
|
||||
*
|
||||
* An electric car does not run out of TANK, and a battery band that talks about fuel is
|
||||
* the kind of detail that makes the rest look careless. Kept as one table so the title,
|
||||
* the lamp's label and the offer's label never mix the two vocabularies.
|
||||
*/
|
||||
export const REFUEL_WORDS = {
|
||||
fuel: { dry: 'roadtrip.refuel.dry', find: 'roadtrip.refuel.find', add: 'roadtrip.refuel.add' },
|
||||
electric: { dry: 'roadtrip.refuel.dryElectric', find: 'roadtrip.refuel.findElectric', add: 'roadtrip.refuel.addElectric' },
|
||||
} as const
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { AlertTriangle, Bike, CarFront, Footprints, Fuel, Hourglass, Moon, Pin, Plus, Search, Sunrise, Zap } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { AlertTriangle, Bike, CarFront, Footprints, Fuel, Hourglass, Moon, Pin, Plus, RotateCcw, Sunrise, X, Zap } from 'lucide-react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import MDancingTrek from '../../../components/MDancingTrek'
|
||||
import MIconBtn from '../../../components/MIconBtn'
|
||||
import { formatDurationShort, serviceColor } from '../../../../components/Roadtrip/roadtripModel'
|
||||
import { STOP_KIND_BY_KEY } from '../../../../components/Roadtrip/stopKinds'
|
||||
import { formatDistance } from '../../../../utils/units'
|
||||
import type { StopRow } from '../../../../components/Roadtrip/roadtripRowModel'
|
||||
import type { RefuelSearch } from '../../../../components/Roadtrip/useRefuelSearch'
|
||||
import type { RefuelCandidate } from '../../../../components/Roadtrip/refuelSuggestion'
|
||||
import { REFUEL_EMPTY_KEY, REFUEL_WORDS, refuelBandState, type RefuelCandidate } from '../../../../components/Roadtrip/refuelSuggestion'
|
||||
import type { DistanceUnit, RouteSegment, ScheduleWarning } from '@trek/shared/roadtrip'
|
||||
import type { TranslationFn } from '../../../../types'
|
||||
|
||||
@@ -169,11 +171,51 @@ export function RtLegRow({ seg, mode, chrome }: {
|
||||
}
|
||||
|
||||
/**
|
||||
* The one loud row of the chain: the tank runs out here.
|
||||
* The ground the dry band stands on, where the mascot is.
|
||||
*
|
||||
* Full width and a 44px button, where the desktop has a 22px reserve light. This is
|
||||
* the single situation on a drive that forces an action, so it gets the only shout
|
||||
* on the screen.
|
||||
* Opaque on purpose: the mascot cuts its eyes and wheel hubs out in `--m-bg`, and
|
||||
* `--m-card` is 55% alpha in both themes, so on the card's own ground the red body would
|
||||
* show through the eyes. `--m-sheetop` is the phone's opaque surface. Mixed against that
|
||||
* rather than against `--m-bg`, because the mascot's span renames `--m-bg` to this value
|
||||
* and a variable defined in terms of itself is invalid, which would drop the cutouts.
|
||||
*/
|
||||
const DRY_GROUND = 'color-mix(in srgb, var(--m-st-danger) 11%, var(--m-sheetop))'
|
||||
|
||||
/**
|
||||
* The lamp's circle: a soft wash of the band's own colour rather than the black action
|
||||
* fill. The lamp is a warning light that happens to be pressable, and the 44px circle is
|
||||
* what tells a thumb so without turning it into the loudest thing on the screen.
|
||||
*/
|
||||
const DANGER_SOFT: CSSProperties = {
|
||||
background: 'color-mix(in srgb, var(--m-st-danger) 14%, transparent)',
|
||||
color: 'var(--m-st-danger)',
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the tank runs out on this leg, and the reserve lamp that goes looking.
|
||||
*
|
||||
* The desktop band's design, sized for a thumb: the mascot on its skateboard and not
|
||||
* enjoying it, a small uppercase warning, and the lamp itself as the button. Not a pill
|
||||
* with the words on it, because a labelled pill beside a mascot and a title leaves the
|
||||
* title almost no room: the band's content box is only about
|
||||
* 299px wide at 375px and 284px at 360px, and a label like "Cerca un punt de recàrrega"
|
||||
* is a long way into that. So the lamp is the button, as on the desktop, and its words
|
||||
* live in the label a screen reader announces.
|
||||
*
|
||||
* 44px where the desktop lamp is 22px, because it is aimed with a thumb. The title clamps
|
||||
* to two lines instead of truncating, because a warning that loses its end says nothing
|
||||
* ("Qui il serbatoio si esaurisce" does not fit on one). Offline the lamp stops glowing,
|
||||
* because a lamp that glows invites a press that cannot work. Somebody who may not edit
|
||||
* the day still sees the lamp, as a plain mark: the empty tank is information, not an
|
||||
* action.
|
||||
*
|
||||
* While a search runs or its offers show, the lamp steps aside for a close, and an empty
|
||||
* answer leaves a retry in its place. The close matters because the map draws the offers,
|
||||
* and without it nothing in the band could clear those pins short of accepting one. It
|
||||
* stays while offline too, and the offline sentence stands down with it: closing is local,
|
||||
* so there is no dead control left to explain. Which control shows, which offers and which
|
||||
* empty sentence is decided in `refuelBandState`, the same place the desktop band reads
|
||||
* it from.
|
||||
*/
|
||||
export function RtDryRow({ intoLegKm, chrome, electric, onSearch, offline, refuel, dayId, legIndex, onAccept }: {
|
||||
intoLegKm: number
|
||||
@@ -188,56 +230,79 @@ export function RtDryRow({ intoLegKm, chrome, electric, onSearch, offline, refue
|
||||
onAccept?: (poi: RefuelCandidate) => void
|
||||
}) {
|
||||
const { t, unit } = chrome
|
||||
// One search is open at a time and it names the dry point it belongs to, so a band
|
||||
// three legs down the chain does not light up for a question asked about this one.
|
||||
const open = refuel.openFor === `${dayId}:${legIndex}`
|
||||
const settled = open && !refuel.loading && refuel.outcome
|
||||
const band = refuelBandState(refuel, dayId, legIndex)
|
||||
const words = REFUEL_WORDS[electric ? 'electric' : 'fuel']
|
||||
const Lamp = electric ? Zap : Fuel
|
||||
return (
|
||||
<div
|
||||
className="my-2 -mx-1 flex flex-col gap-[7px] rounded-[16px] border px-3 py-2.5"
|
||||
className="-mx-1 my-2 flex flex-col gap-2 rounded-[16px] border px-3 py-2.5"
|
||||
style={{
|
||||
borderColor: 'color-mix(in srgb, var(--m-st-danger) 30%, transparent)',
|
||||
background: 'linear-gradient(180deg, color-mix(in srgb, var(--m-st-danger) 12%, var(--m-card)) 0%, var(--m-card) 100%)',
|
||||
borderColor: 'color-mix(in srgb, var(--m-st-danger) 22%, transparent)',
|
||||
// Flat through the padding and the 44px header row, so the ground behind the
|
||||
// mascot's eyes is the one they are cut in, and only the answers below fade out.
|
||||
backgroundImage: `linear-gradient(180deg, ${DRY_GROUND} 0px, ${DRY_GROUND} 54px, var(--m-card) 100%)`,
|
||||
boxShadow: 'inset 0 1px 0 color-mix(in srgb, var(--m-st-danger) 20%, transparent)',
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-[7px]" style={{ color: 'var(--m-st-danger)' }}>
|
||||
{electric ? <Zap size={16} strokeWidth={2} aria-hidden="true" /> : <Fuel size={16} strokeWidth={2} aria-hidden="true" />}
|
||||
<span className="text-[0.8125rem] font-bold">
|
||||
{electric ? t('roadtrip.refuel.dryElectric') : t('roadtrip.refuel.dry')}
|
||||
<div className="flex min-h-[44px] items-center gap-2.5">
|
||||
{/* Renamed on this span only, never on the band: the offers below are drawn in
|
||||
--m-ink too, and they have to stay ink. Not a tap target either, because
|
||||
nothing in the chain but a destination row reacts to a tap. */}
|
||||
<span
|
||||
className="pointer-events-none flex flex-none"
|
||||
style={{ '--m-ink': 'var(--m-st-danger)', '--m-bg': DRY_GROUND } as CSSProperties}
|
||||
>
|
||||
{/* Out of fuel is a thing that happens to the drive, so it is the mascot with
|
||||
the vehicle, and it is not enjoying it. */}
|
||||
<MDancingTrek scene="transport" mood="sad" size={34} />
|
||||
</span>
|
||||
</span>
|
||||
<span className="font-geist text-[0.71875rem] text-m-muted">
|
||||
{t('roadtrip.refuel.after', { distance: formatDistance(Math.round(intoLegKm), unit) })}
|
||||
</span>
|
||||
{onSearch && (
|
||||
<>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="line-clamp-2 font-geist text-[0.625rem] font-bold uppercase leading-[1.35] tracking-[.09em] text-[color:var(--m-st-danger)]">
|
||||
{t(words.dry)}
|
||||
</span>
|
||||
<span className="mt-[2px] block font-geist text-[0.71875rem] tabular-nums text-m-muted">
|
||||
{t('roadtrip.refuel.after', { distance: formatDistance(Math.round(intoLegKm), unit) })}
|
||||
</span>
|
||||
</span>
|
||||
{band.control === 'close' ? (
|
||||
<MIconBtn variant="neutral" size={44} onClick={refuel.close} ariaLabel={t('common.close')}>
|
||||
<X size={16} strokeWidth={2.2} aria-hidden="true" />
|
||||
</MIconBtn>
|
||||
) : onSearch ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSearch}
|
||||
disabled={offline}
|
||||
className="flex h-11 w-full items-center justify-center gap-[7px] rounded-full bg-m-act text-[0.8125rem] font-semibold text-m-actfg disabled:bg-[color:var(--m-ic)] disabled:text-m-faint"
|
||||
aria-label={t(band.control === 'again' ? 'roadtrip.refuel.again' : words.find)}
|
||||
style={offline ? undefined : DANGER_SOFT}
|
||||
className={`grid h-11 w-11 flex-none place-items-center rounded-full ${offline ? 'bg-[color:var(--m-ic)] text-m-faint' : ''}`}
|
||||
>
|
||||
<Search size={15} strokeWidth={2.2} aria-hidden="true" />
|
||||
{electric ? t('roadtrip.refuel.findElectric') : t('roadtrip.refuel.find')}
|
||||
{band.control === 'again'
|
||||
? <RotateCcw size={17} strokeWidth={2.2} aria-hidden="true" />
|
||||
: <Lamp size={18} strokeWidth={2} className={offline ? undefined : 'trek-lowfuel'} aria-hidden="true" />}
|
||||
</button>
|
||||
{/* Blunt rather than greyed out: the button keeps its shape and says why. */}
|
||||
{offline && (
|
||||
<span className="font-geist text-[0.6875rem] leading-[1.4] text-m-faint">{t('mobileTrip.rtSearchOffline')}</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="grid h-11 w-11 flex-none place-items-center text-[color:var(--m-st-danger)]" aria-hidden="true">
|
||||
<Lamp size={18} strokeWidth={2} className="trek-lowfuel" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Blunt rather than silent: the lamp keeps its place and this says why it is out. */}
|
||||
{onSearch && offline && band.control !== 'close' && (
|
||||
<span className="font-geist text-[0.6875rem] leading-[1.4] text-m-faint">{t('mobileTrip.rtSearchOffline')}</span>
|
||||
)}
|
||||
|
||||
{open && refuel.loading && (
|
||||
{band.loading && (
|
||||
<span className="font-geist text-[0.71875rem] text-m-muted">{t('roadtrip.refuel.looking')}</span>
|
||||
)}
|
||||
|
||||
{/* The answer, in the band that asked. The desktop puts it in the rail row for the
|
||||
same reason: a station offered somewhere other than where the tank runs out is
|
||||
an offer somebody has to go and find. Three at most: this is an offer beside a
|
||||
plan, and the search sheet above is where all of them live. */}
|
||||
{settled && (refuel.results.length ? (
|
||||
an offer somebody has to go and find. */}
|
||||
{band.offers.length > 0 && (
|
||||
<ul className="flex flex-col gap-[6px]">
|
||||
{refuel.results.slice(0, 3).map(poi => (
|
||||
{band.offers.map(poi => (
|
||||
<RefuelOffer
|
||||
key={poi.osm_id}
|
||||
poi={poi}
|
||||
@@ -247,17 +312,13 @@ export function RtDryRow({ intoLegKm, chrome, electric, onSearch, offline, refue
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
/* Three sentences for three different facts. "Nothing on this stretch" after a
|
||||
request that failed states something that was never checked. */
|
||||
<span className="font-geist text-[0.71875rem] leading-[1.4] text-m-muted">
|
||||
{refuel.outcome === 'none'
|
||||
? t('roadtrip.refuel.none')
|
||||
: refuel.outcome === 'incomplete'
|
||||
? t('roadtrip.refuel.incomplete')
|
||||
: t('roadtrip.refuel.failed')}
|
||||
</span>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* Three sentences for three different facts. "Nothing on this stretch" after a
|
||||
request that failed states something that was never checked. */}
|
||||
{band.empty && (
|
||||
<span className="font-geist text-[0.71875rem] leading-[1.4] text-m-muted">{t(REFUEL_EMPTY_KEY[band.empty])}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -303,7 +364,7 @@ function RefuelOffer({ poi, chrome, electric, onAccept }: {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAccept}
|
||||
aria-label={t(electric ? 'roadtrip.refuel.addElectric' : 'roadtrip.refuel.add', { name: poi.name })}
|
||||
aria-label={t(REFUEL_WORDS[electric ? 'electric' : 'fuel'].add, { name: poi.name })}
|
||||
className="grid h-[38px] w-[38px] flex-none place-items-center rounded-full bg-[color:var(--m-ic)] text-m-ink"
|
||||
>
|
||||
<Plus size={16} strokeWidth={2.2} aria-hidden="true" />
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { TranslationFn } from '../../../../src/types'
|
||||
import type { RefuelSearch } from '../../../../src/components/Roadtrip/useRefuelSearch'
|
||||
import type { RefuelCandidate } from '../../../../src/components/Roadtrip/refuelSuggestion'
|
||||
|
||||
// FE-MOB-RTROW-001 to FE-MOB-RTROW-030
|
||||
// FE-MOB-RTROW-001 to FE-MOB-RTROW-037
|
||||
|
||||
// Same echo strategy as tests/helpers/mobileTrip: assertions stay on keys, not copy.
|
||||
const t: TranslationFn = (key, params) =>
|
||||
@@ -222,11 +222,12 @@ describe('RtDryRow', () => {
|
||||
const props = { intoLegKm: 82, chrome, electric: false, offline: false, refuel: idle, dayId: 7, legIndex: 1 }
|
||||
|
||||
it('FE-MOB-RTROW-018: names the fuel wording and the distance into the leg for a combustion car', () => {
|
||||
render(<RtDryRow {...props} onSearch={vi.fn()} />)
|
||||
const { container } = render(<RtDryRow {...props} onSearch={vi.fn()} />)
|
||||
|
||||
expect(screen.getByText('roadtrip.refuel.dry')).toBeInTheDocument()
|
||||
expect(screen.getByText('roadtrip.refuel.after:82 km')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'roadtrip.refuel.find' })).toBeInTheDocument()
|
||||
expect(container.querySelector('svg.trek--transport')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-019: swaps to the charging wording for an electric car', () => {
|
||||
@@ -252,18 +253,23 @@ describe('RtDryRow', () => {
|
||||
const button = screen.getByRole('button', { name: 'roadtrip.refuel.find' })
|
||||
expect(button).toBeDisabled()
|
||||
expect(screen.getByText('mobileTrip.rtSearchOffline')).toBeInTheDocument()
|
||||
// The lamp stops glowing: a lamp that glows invites a press that cannot work.
|
||||
expect(button.querySelector('svg.lucide-fuel')).not.toBeNull()
|
||||
expect(button.querySelector('.trek-lowfuel')).toBeNull()
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(onSearch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-022: drops the button entirely without write permission, offline line included', () => {
|
||||
render(<RtDryRow {...props} offline />)
|
||||
const { container } = render(<RtDryRow {...props} offline />)
|
||||
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(screen.queryByText('mobileTrip.rtSearchOffline')).toBeNull()
|
||||
// The warning itself stays: it is information, not an action.
|
||||
// The warning itself stays: it is information, not an action. The reserve lamp too,
|
||||
// as a mark rather than a control.
|
||||
expect(screen.getByText('roadtrip.refuel.dry')).toBeInTheDocument()
|
||||
expect(container.querySelector('.trek-lowfuel')).not.toBeNull()
|
||||
})
|
||||
const offer = (over: Record<string, unknown> = {}) => ({
|
||||
osm_id: 'n1', name: 'Shell Ebina', lat: 35.44, lng: 139.39, category: 'fuel',
|
||||
@@ -339,6 +345,110 @@ describe('RtDryRow', () => {
|
||||
expect(screen.getByText('roadtrip.refuel.failed')).toBeInTheDocument()
|
||||
expect(screen.queryByText('roadtrip.refuel.none')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-032: the mascot rides the skateboard, sad, in the band colour and on an opaque ground', () => {
|
||||
const { container } = render(<RtDryRow {...props} onSearch={vi.fn()} />)
|
||||
|
||||
const mascot = container.querySelector('svg.trek--transport') as SVGElement
|
||||
// Expression eyes, not the default open ones, bent down rather than up.
|
||||
expect(mascot.querySelectorAll('.trek-eye')).toHaveLength(0)
|
||||
const eyes = mascot.querySelectorAll('.trek-body g[stroke] path')
|
||||
expect(eyes).toHaveLength(2)
|
||||
const [, startY, controlY] = /^M[\d.]+ ([\d.]+) Q[\d.]+ ([\d.]+)/.exec(eyes[0].getAttribute('d') ?? '') ?? []
|
||||
expect(Number(controlY)).toBeGreaterThan(Number(startY))
|
||||
|
||||
// Renamed on the mascot's own span, against an opaque surface so the cut out eyes
|
||||
// do not show the body through them.
|
||||
const wrapper = mascot.parentElement as HTMLElement
|
||||
expect(wrapper.style.getPropertyValue('--m-ink')).toBe('var(--m-st-danger)')
|
||||
expect(wrapper.style.getPropertyValue('--m-bg')).toContain('--m-sheetop')
|
||||
// And never on the band, where the offers read --m-ink and have to stay ink.
|
||||
expect((container.firstElementChild as HTMLElement).style.getPropertyValue('--m-ink')).toBe('')
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-033: the reserve lamp is the button, with its words in the label only', () => {
|
||||
const fuel = render(<RtDryRow {...props} onSearch={vi.fn()} />)
|
||||
|
||||
const lamp = screen.getByRole('button', { name: 'roadtrip.refuel.find' })
|
||||
expect(lamp.querySelector('svg.lucide-fuel.trek-lowfuel')).not.toBeNull()
|
||||
// Not the black pill it replaced, and no visible label beside the mascot and title.
|
||||
expect(lamp.className).not.toContain('bg-m-act')
|
||||
expect(screen.queryByText('roadtrip.refuel.find')).toBeNull()
|
||||
fuel.unmount()
|
||||
|
||||
render(<RtDryRow {...props} electric onSearch={vi.fn()} />)
|
||||
expect(screen.getByRole('button', { name: 'roadtrip.refuel.findElectric' }).querySelector('svg.lucide-zap')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-034: while the search runs or offers show, the lamp steps aside for a close', () => {
|
||||
// The map draws the offers, and the band is where somebody lets those pins go.
|
||||
const close = vi.fn()
|
||||
const running = render(<RtDryRow {...props} refuel={answering({ loading: true, close })} onSearch={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'roadtrip.refuel.find' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
|
||||
expect(close).toHaveBeenCalledTimes(1)
|
||||
running.unmount()
|
||||
|
||||
const found = render(<RtDryRow {...props} refuel={answering({ outcome: 'found', results: [offer()], close })} onSearch={vi.fn()} />)
|
||||
expect(screen.getByText('Shell Ebina')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'roadtrip.refuel.find' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.close' }))
|
||||
expect(close).toHaveBeenCalledTimes(2)
|
||||
found.unmount()
|
||||
|
||||
// Offline the close still works, because closing is local, and with no dead control
|
||||
// in the slot the sentence explaining one stands down.
|
||||
render(<RtDryRow {...props} offline refuel={answering({ outcome: 'found', results: [offer()], close })} onSearch={vi.fn()} />)
|
||||
const closeOffline = screen.getByRole('button', { name: 'common.close' })
|
||||
expect(closeOffline).toBeEnabled()
|
||||
expect(screen.queryByText('mobileTrip.rtSearchOffline')).toBeNull()
|
||||
fireEvent.click(closeOffline)
|
||||
expect(close).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-035: an empty answer leaves a retry rather than a dead end, and offline it waits', () => {
|
||||
const onSearch = vi.fn()
|
||||
const failed = render(<RtDryRow {...props} refuel={answering({ outcome: 'failed' })} onSearch={onSearch} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'roadtrip.refuel.find' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'roadtrip.refuel.again' }))
|
||||
expect(onSearch).toHaveBeenCalledTimes(1)
|
||||
failed.unmount()
|
||||
|
||||
const none = render(<RtDryRow {...props} refuel={answering({ outcome: 'none' })} onSearch={onSearch} />)
|
||||
expect(screen.getByRole('button', { name: 'roadtrip.refuel.again' })).toBeInTheDocument()
|
||||
none.unmount()
|
||||
|
||||
render(<RtDryRow {...props} offline refuel={answering({ outcome: 'failed' })} onSearch={onSearch} />)
|
||||
const again = screen.getByRole('button', { name: 'roadtrip.refuel.again' })
|
||||
expect(again).toBeDisabled()
|
||||
fireEvent.click(again)
|
||||
expect(onSearch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-036: lists three offers at most, however many came back', () => {
|
||||
const results = ['n1', 'n2', 'n3', 'n4'].map((id, i) => offer({ osm_id: id, name: `Station ${i + 1}` }))
|
||||
const { container } = render(<RtDryRow {...props} refuel={answering({ outcome: 'found', results })} onSearch={vi.fn()} />)
|
||||
|
||||
expect(container.querySelectorAll('li')).toHaveLength(3)
|
||||
expect(screen.queryByText('Station 4')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MOB-RTROW-037: the battery wording carries through to taking an offer', () => {
|
||||
render(
|
||||
<RtDryRow
|
||||
{...props}
|
||||
electric
|
||||
refuel={answering({ outcome: 'found', results: [offer()] })}
|
||||
onSearch={vi.fn()}
|
||||
onAccept={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'roadtrip.refuel.addElectric:Shell Ebina' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'roadtrip.refuel.add:Shell Ebina' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RtAutoRow', () => {
|
||||
|
||||
Reference in New Issue
Block a user