mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(roadtrip): tell petrol from electric, and shape the refuel band like a drive band
Four things that only showed up once it was on screen. The range budget treated a petrol station and a charger as the same thing, so an electric car pausing for coffee at a filling station had its battery refilled on paper: the warnings went quiet for the rest of the day and the driver was told nothing. A vehicle can now be named, and then only what actually refills it counts — and the search asks for that kind alone instead of offering pumps to a car that cannot use them. Not saying keeps the old behaviour, which is what everybody who never opens the dialog is on. The band was its own shape in a rail made of drive bands. It is one now: the same rail, the same height, the same type size, a warning tint instead of a ring, and the figures where a drive band keeps them. The buttons are icons of the same 18px square the shuffle mark uses, so the band stays one line beside its neighbours. The old range warning stood down where the band shows. Both at once said the same thing twice in the wrong order — the answer above the problem — because the warning marks the stop somebody finds out at while the band sits on the leg the fuel ends on. Two measured corrections to the search itself. The server turns a box into a circle of half its diagonal and silently narrows anything past 20 km, so a box of ±25 asked for 35 and came back flagged as narrowed EVERY time, which read on screen as "we could not check"; ±14 asks for 19.8 and is answered in full. And the circle is centred 12 km before the dry point rather than on it, because only road already driven is any use — centred on the point, half the circle was spent on road nobody can reach. An answer that finds nothing now leaves the button rather than only a close. The place search is a shared public service that does time out, and "it did not answer" with no way to ask again reads as broken rather than as busy.
This commit is contained in:
@@ -57,3 +57,30 @@ describe('useMergedMapPois', () => {
|
||||
expect(result.current).toBe(first)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMergedMapPois — the refuel offers', () => {
|
||||
const poi = (id: string): Poi => ({ osm_id: id, name: id, lat: 0, lng: 0 } as Poi)
|
||||
|
||||
it('FE-MAP-MERGEPOI-007: an offered station is drawn even with no search running', () => {
|
||||
// In road trip mode this is the only channel a POI reaches the map through, so
|
||||
// without it somebody is asked to accept a stop they cannot see.
|
||||
const { result } = renderHook(() => useMergedMapPois([], [], [poi('offer')]))
|
||||
expect(result.current.map(p => p.osm_id)).toEqual(['offer'])
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-008: an offer already on the route is one pin, not two', () => {
|
||||
const { result } = renderHook(() => useMergedMapPois([poi('a')], [], [poi('a'), poi('b')]))
|
||||
expect(result.current.map(p => p.osm_id)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-009: nothing offered keeps the same array, so no pin is rebuilt', () => {
|
||||
const corridor = [poi('a')]
|
||||
const { result, rerender } = renderHook(({ o }: { o: Poi[] }) => useMergedMapPois(corridor, [], o), {
|
||||
initialProps: { o: [] as Poi[] },
|
||||
})
|
||||
const first = result.current
|
||||
rerender({ o: [] })
|
||||
expect(result.current).toBe(first)
|
||||
expect(result.current).toBe(corridor)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,16 +13,30 @@ import type { Poi } from './poiCategories'
|
||||
* on: a hotel found both along the route and in the viewport is one pin, and the corridor's
|
||||
* copy wins because it carries how far along the drive it sits.
|
||||
*
|
||||
* `offered` is the third case: the handful of filling stations proposed where a tank runs
|
||||
* out. They belong on the map for a plain reason — somebody is being asked to accept a
|
||||
* stop, and a stop you cannot see is not one you can judge — but they are not a search
|
||||
* result and must not survive the panel being closed, which is why they arrive separately
|
||||
* rather than being folded into the corridor's own list.
|
||||
*
|
||||
* Returns the input array itself whenever there is nothing to merge. The map redraws off
|
||||
* this reference, so handing it a fresh array on every render would rebuild every pin.
|
||||
*/
|
||||
export function useMergedMapPois(corridor: Poi[] | null, explore: Poi[]): Poi[] {
|
||||
export function useMergedMapPois(corridor: Poi[] | null, explore: Poi[], offered: Poi[] = NONE): Poi[] {
|
||||
return useMemo(() => {
|
||||
if (!corridor) return explore
|
||||
if (!explore.length) return corridor
|
||||
if (!corridor.length) return explore
|
||||
const seen = new Set(corridor.map(p => p.osm_id))
|
||||
const extra = explore.filter(p => !seen.has(p.osm_id))
|
||||
return extra.length ? [...corridor, ...extra] : corridor
|
||||
}, [corridor, explore])
|
||||
const base = corridor === null ? explore : merge(corridor, explore)
|
||||
return merge(base, offered)
|
||||
}, [corridor, explore, offered])
|
||||
}
|
||||
|
||||
/** Nothing offered, as one stable array, so the default never changes identity. */
|
||||
const NONE: Poi[] = []
|
||||
|
||||
/** `a` with everything from `b` that it does not already carry, by `osm_id`. */
|
||||
function merge(a: Poi[], b: Poi[]): Poi[] {
|
||||
if (!b.length) return a
|
||||
if (!a.length) return b
|
||||
const seen = new Set(a.map(p => p.osm_id))
|
||||
const extra = b.filter(p => !seen.has(p.osm_id))
|
||||
return extra.length ? [...a, ...extra] : a
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship } from 'lucide-react'
|
||||
import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight, Coins, Signpost, Ship, Car, Zap } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { convertDistance, formatDistance } from '../../utils/units'
|
||||
@@ -103,6 +104,13 @@ function AvoidRow({ icon: Icon, label, on, disabled, onToggle }: {
|
||||
)
|
||||
}
|
||||
|
||||
/** Not said, petrol, electric. "Not said" first, because it is what everybody starts on. */
|
||||
const VEHICLES: { key: 'combustion' | 'electric' | null; labelKey: string; Icon: typeof Car }[] = [
|
||||
{ key: null, labelKey: 'roadtrip.limit.vehicleAny', Icon: Car },
|
||||
{ key: 'combustion', labelKey: 'roadtrip.limit.vehicleCombustion', Icon: Fuel },
|
||||
{ key: 'electric', labelKey: 'roadtrip.limit.vehicleElectric', Icon: Zap },
|
||||
]
|
||||
|
||||
export default function RoadtripLimitsCard({ onSave }: {
|
||||
/**
|
||||
* Persists one setting. Absent leaves the dialog read-only.
|
||||
@@ -123,6 +131,7 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
|
||||
// Parsed rather than trusted: a per-user setting gets no server-side validation, and
|
||||
// an unknown word here would become a costing option the router does not have.
|
||||
const vehicle = settings.roadtrip_vehicle ?? ''
|
||||
const avoiding = parseAvoid(settings.roadtrip_avoid)
|
||||
// No second engine, no avoidance. An instance pointed at its own OSRM has one, and its
|
||||
// car profile is built without excludable classes on every public host.
|
||||
@@ -234,6 +243,25 @@ export default function RoadtripLimitsCard({ onSave }: {
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
onChange={v => onSave?.('roadtrip_day_minutes', v)}
|
||||
/>
|
||||
{/* Above the range, because it decides what the range even means: with no
|
||||
vehicle named, a petrol station and a charger both fill the tank, which
|
||||
is arithmetic that is wrong for everybody who drives just one of them. */}
|
||||
<label className="flex items-center gap-3">
|
||||
<Car size={16} className="shrink-0 text-content-faint" aria-hidden />
|
||||
<span className="min-w-0 flex-1 text-body text-content-secondary">{t('roadtrip.limit.vehicleLabel')}</span>
|
||||
<span className="w-[132px] shrink-0">
|
||||
<CustomSelect
|
||||
value={vehicle}
|
||||
onChange={next => onSave?.('roadtrip_vehicle', String(next))}
|
||||
size="sm"
|
||||
options={VEHICLES.map(({ key, labelKey, Icon }) => ({
|
||||
value: key ?? '',
|
||||
label: t(labelKey),
|
||||
icon: <Icon size={14} aria-hidden />,
|
||||
}))}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
<LimitRow
|
||||
icon={Fuel}
|
||||
label={t('roadtrip.limit.rangeLabel')}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
CarFront, Footprints, Bike, Zap, AlertTriangle,
|
||||
ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban,
|
||||
ParkingSquare, Shuffle, Fuel, Clock, Spline, Ban, Plus, Search, RotateCcw, X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import MDancingTrek from '../../mobile/components/MDancingTrek'
|
||||
@@ -327,81 +327,110 @@ function RefuelBand({ dry, refuel, dayId, onAsk, onAccept }: {
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const key = `${dayId}:${dry.legIndex}`
|
||||
const open = refuel.openFor === key
|
||||
const settled = open && !refuel.loading && refuel.outcome
|
||||
|
||||
return (
|
||||
<div className="my-1 ms-[30px] flex flex-col gap-1.5 rounded-lg border border-edge-faint bg-surface-secondary px-2.5 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Fuel size={12} className="shrink-0 text-content-faint" aria-hidden />
|
||||
<span className="min-w-0 flex-1 text-content-secondary" style={{ fontSize: FS.label }}>
|
||||
{t('roadtrip.refuel.dry', { distance: formatDistance(dry.sinceKm, distanceUnit) })}
|
||||
</span>
|
||||
{open ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={refuel.close}
|
||||
className="shrink-0 rounded px-1.5 py-0.5 text-content-faint transition-colors hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
style={{ fontSize: FS.label }}
|
||||
>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAsk}
|
||||
className="shrink-0 rounded bg-surface-card px-2 py-0.5 font-semibold text-content-secondary transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
style={{ fontSize: FS.label }}
|
||||
>
|
||||
{t('roadtrip.refuel.find')}
|
||||
</button>
|
||||
)}
|
||||
<div className="grid" style={RAIL_GRID}>
|
||||
<span className="relative z-[1] flex flex-col items-center" aria-hidden>
|
||||
<span className="flex-1" style={RAIL_DASH} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
{/* Built as a drive band, because that is what it belongs to: the leg the fuel
|
||||
runs out on. Same height, same type, same rail. The warning ring is the only
|
||||
difference, and it is the whole message — this is the band where the tank
|
||||
ends. */}
|
||||
<div className="my-1.5 flex flex-col gap-1 rounded-lg bg-warning-soft px-2 py-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Fuel size={12} strokeWidth={1.7} className="shrink-0 text-warning" aria-hidden />
|
||||
<span className="min-w-0 truncate font-medium text-content" style={{ fontSize: FS.meta }}>
|
||||
{t('roadtrip.refuel.dry')}
|
||||
</span>
|
||||
{/* 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. */}
|
||||
<span className="ms-auto shrink-0 tabular-nums text-content-muted" style={{ fontSize: FS.meta }}>
|
||||
{t('roadtrip.refuel.after', { distance: formatDistance(Math.round(dry.intoLegKm), distanceUnit) })}
|
||||
</span>
|
||||
{/* An icon, the size of the shuffle mark a drive band carries, because the
|
||||
band has to stay one line high beside its neighbours. What it does is in
|
||||
the tooltip and in the label a screen reader gets. */}
|
||||
{open && (refuel.loading || refuel.results.length) ? (
|
||||
<Tooltip label={t('common.close')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refuel.close}
|
||||
aria-label={t('common.close')}
|
||||
className="grid h-[18px] w-[18px] shrink-0 place-items-center rounded-md text-content-muted transition-colors hover:bg-surface-card hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<X size={12} aria-hidden />
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
/* An answer that found nothing leaves the button, not a dead end. The place
|
||||
search is a shared public service that does time out, and "it did not
|
||||
answer" with no way to ask again reads as broken rather than as busy. */
|
||||
<Tooltip label={settled && refuel.outcome !== 'found' ? t('roadtrip.refuel.again') : t('roadtrip.refuel.find')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAsk}
|
||||
aria-label={settled && refuel.outcome !== 'found' ? t('roadtrip.refuel.again') : t('roadtrip.refuel.find')}
|
||||
className="grid h-[18px] w-[18px] shrink-0 place-items-center rounded-md bg-surface-card text-content-secondary transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{settled && refuel.outcome !== 'found'
|
||||
? <RotateCcw size={11} aria-hidden />
|
||||
: <Search size={11} aria-hidden />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && refuel.loading ? (
|
||||
<span className="text-content-muted" style={{ fontSize: FS.meta }}>{t('roadtrip.refuel.looking')}</span>
|
||||
) : null}
|
||||
|
||||
{settled ? (
|
||||
refuel.results.length ? (
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{/* 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. */}
|
||||
{refuel.results.slice(0, 3).map(poi => (
|
||||
<li key={poi.osm_id} className="flex items-center gap-1.5">
|
||||
<span className="min-w-0 flex-1 truncate text-content" style={{ fontSize: FS.meta }}>
|
||||
{poi.name}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-content-muted" style={{ fontSize: FS.meta }}>
|
||||
{/* What is left when the car draws level, the figure that decides
|
||||
whether this one is any use. The detour is already in it. */}
|
||||
{t('roadtrip.refuel.spare', { distance: formatDistance(Math.round(poi.spareKm), distanceUnit) })}
|
||||
</span>
|
||||
{onAccept ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAccept(poi)}
|
||||
aria-label={t('roadtrip.refuel.add', { name: poi.name })}
|
||||
className="grid h-[18px] w-[18px] shrink-0 place-items-center rounded-md bg-surface-card text-content-secondary transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<Plus size={11} aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && refuel.loading ? (
|
||||
<span className="text-content-faint" style={{ fontSize: FS.label }}>{t('roadtrip.refuel.looking')}</span>
|
||||
) : null}
|
||||
|
||||
{open && !refuel.loading && refuel.outcome ? (
|
||||
refuel.results.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. */}
|
||||
{refuel.results.slice(0, 3).map(poi => (
|
||||
<li key={poi.osm_id} className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-content" style={{ fontSize: FS.label }}>
|
||||
{poi.name}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-content-faint" style={{ fontSize: FS.label }}>
|
||||
{/* What is left when the car draws level, which is the figure that
|
||||
decides whether this one is any use. The detour is in it already. */}
|
||||
{t('roadtrip.refuel.spare', { distance: formatDistance(Math.round(poi.spareKm), distanceUnit) })}
|
||||
</span>
|
||||
{onAccept ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAccept(poi)}
|
||||
aria-label={t('roadtrip.refuel.add', { name: poi.name })}
|
||||
className="shrink-0 rounded bg-surface-card px-1.5 py-0.5 font-semibold text-content-secondary transition-colors hover:bg-surface-hover hover:text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
style={{ fontSize: FS.label }}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</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, and that is worse than saying nothing. */
|
||||
<span className="text-content-faint" style={{ fontSize: FS.label }}>
|
||||
{refuel.outcome === 'none'
|
||||
? t('roadtrip.refuel.none')
|
||||
: refuel.outcome === 'incomplete'
|
||||
? t('roadtrip.refuel.incomplete')
|
||||
: t('roadtrip.refuel.failed')}
|
||||
</span>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -956,6 +985,17 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
const { t, language } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const last = day.stops.length - 1
|
||||
/**
|
||||
* The findings a stop wears, minus the one the refuel band already says better.
|
||||
*
|
||||
* The range warning marks the stop somebody FINDS OUT at and counts the whole tank;
|
||||
* the band sits on the leg the fuel actually ends on and offers somewhere to stop. Both
|
||||
* at once says the same thing twice, in the wrong order — the answer above the problem
|
||||
* — so the warning stands down wherever the band is showing.
|
||||
*/
|
||||
const showingBand = !!refuel && !loading && !!day.dryPoints?.length
|
||||
const findingsFor = (i: number) =>
|
||||
day.driveWarnings.filter(w => w.index === i && !(showingBand && w.code === 'range'))
|
||||
// The running number a stop wears, with the service stops passed over — so a day with a
|
||||
// charger halfway through still counts one, two, three the way its map pins do.
|
||||
let counted = 0
|
||||
@@ -1091,7 +1131,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
stop={stop}
|
||||
entry={day.schedule.entries[i]}
|
||||
late={marks.find(w => w.code === 'late')}
|
||||
driveFindings={day.driveWarnings.filter(w => w.index === i)}
|
||||
driveFindings={findingsFor(i)}
|
||||
selected={selectedAssignmentId === stop.assignmentId}
|
||||
onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined}
|
||||
onEditStay={onEditStay ? () => onEditStay({ placeId: stop.placeId, name: stop.name, minutes: stop.dwellMinutes, arrival: day.schedule.entries[i]?.arrival ?? null }) : undefined}
|
||||
@@ -1103,7 +1143,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
|
||||
number={counted}
|
||||
entry={day.schedule.entries[i]}
|
||||
late={marks.find(w => w.code === 'late')}
|
||||
driveFindings={day.driveWarnings.filter(w => w.index === i)}
|
||||
driveFindings={findingsFor(i)}
|
||||
selected={selectedAssignmentId === stop.assignmentId}
|
||||
continues={i < last}
|
||||
starts={i === 0}
|
||||
|
||||
@@ -77,8 +77,11 @@ describe('outcomeOf', () => {
|
||||
// failed request states a fact that was never checked.
|
||||
expect(outcomeOf([], null)).toBe('failed')
|
||||
expect(outcomeOf([], { truncated: true })).toBe('incomplete')
|
||||
expect(outcomeOf([], { clamped: true })).toBe('incomplete')
|
||||
expect(outcomeOf([], {})).toBe('none')
|
||||
// `clamped` is not the same thing: it means the server searched a smaller circle
|
||||
// than the box described, and the caller sizes the box so that never happens.
|
||||
// Treating it as "could not check" turned every empty answer into a shrug.
|
||||
expect(outcomeOf([], { clamped: true })).toBe('none')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-REFUEL-008: a hit is a hit even when the answer was cut short', () => {
|
||||
|
||||
@@ -86,7 +86,10 @@ export function outcomeOf(
|
||||
): RefuelOutcome {
|
||||
if (!answer) return 'failed'
|
||||
if (candidates.length) return 'found'
|
||||
return answer.truncated || answer.clamped ? 'incomplete' : 'none'
|
||||
// `truncated` alone. `clamped` only means the server searched a smaller circle than
|
||||
// the box described, which the caller now sizes so it never happens — and reporting it
|
||||
// as "we could not check" turned every empty answer into a shrug.
|
||||
return answer.truncated ? 'incomplete' : 'none'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
parseClock,
|
||||
splitIntoRuns,
|
||||
sumLegSeconds,
|
||||
refuelStopTypeFor,
|
||||
} from './roadtripModel'
|
||||
|
||||
describe('formatDurationShort', () => {
|
||||
@@ -687,3 +688,41 @@ describe('deriveDriveWarnings — where the tank actually runs dry', () => {
|
||||
expect(out.carryKm).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('refuelsRange — what fills which tank', () => {
|
||||
it('FE-ROADTRIP-MODEL-096: with no vehicle named, either kind fills up', () => {
|
||||
// What TREK did before the setting existed, and the right answer for somebody who
|
||||
// never opened the dialog. Anything else would quietly change their warnings.
|
||||
expect(refuelsRange('fuel')).toBe(true)
|
||||
expect(refuelsRange('charging')).toBe(true)
|
||||
expect(refuelsRange('fuel', null)).toBe(true)
|
||||
expect(refuelsRange('charging', null)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-097: a petrol station does not charge a battery', () => {
|
||||
// The bug this exists for: an electric car pausing at a petrol station had its
|
||||
// battery refilled on paper, the warnings went quiet for the rest of the day, and
|
||||
// the driver was told nothing.
|
||||
expect(refuelsRange('charging', 'electric')).toBe(true)
|
||||
expect(refuelsRange('fuel', 'electric')).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-098: and a charger does not fill a tank', () => {
|
||||
expect(refuelsRange('fuel', 'combustion')).toBe(true)
|
||||
expect(refuelsRange('charging', 'combustion')).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-099: nothing else refuels anything, whatever is driven', () => {
|
||||
for (const vehicle of [null, 'combustion', 'electric'] as const) {
|
||||
expect(refuelsRange('rest_area', vehicle)).toBe(false)
|
||||
expect(refuelsRange('restaurant', vehicle)).toBe(false)
|
||||
expect(refuelsRange(null, vehicle)).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-100: the search looks for what the vehicle actually takes', () => {
|
||||
expect(refuelStopTypeFor('combustion')).toEqual(['fuel'])
|
||||
expect(refuelStopTypeFor('electric')).toEqual(['charging'])
|
||||
expect(refuelStopTypeFor(null)).toEqual(['fuel', 'charging'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -123,9 +123,32 @@ export interface ScheduleWarning {
|
||||
*/
|
||||
export const REFUELLING_STOP_TYPES = ['fuel', 'charging'] as const
|
||||
|
||||
/** Whether stopping here starts the range budget over. */
|
||||
export function refuelsRange(stopType: string | null | undefined): boolean {
|
||||
return (REFUELLING_STOP_TYPES as readonly string[]).includes(stopType ?? '')
|
||||
/**
|
||||
* What the traveller drives, which decides what actually fills the tank.
|
||||
*
|
||||
* Absent means both count, which is what TREK did before the setting existed and is the
|
||||
* right answer for somebody who never said. It is only wrong once a vehicle IS named:
|
||||
* a petrol station does not charge a battery, and a charger does not fill a tank.
|
||||
*/
|
||||
export type VehicleKind = 'combustion' | 'electric'
|
||||
|
||||
/** The stop kind that refills the named vehicle, for filtering a search. */
|
||||
export function refuelStopTypeFor(vehicle: VehicleKind | null | undefined): readonly string[] {
|
||||
if (vehicle === 'combustion') return ['fuel']
|
||||
if (vehicle === 'electric') return ['charging']
|
||||
return REFUELLING_STOP_TYPES
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether stopping here starts the range budget over.
|
||||
*
|
||||
* With no vehicle named, either kind does — a plain reading of "I filled up". Once one is
|
||||
* named the other stops counting, because the alternative is arithmetic that is simply
|
||||
* wrong: an electric car pausing at a petrol station had its battery refilled on paper,
|
||||
* the warnings went quiet for the rest of the day, and the driver was told nothing.
|
||||
*/
|
||||
export function refuelsRange(stopType: string | null | undefined, vehicle?: VehicleKind | null): boolean {
|
||||
return refuelStopTypeFor(vehicle).includes(stopType ?? '')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { isEffectivelyOffline } from '../../sync/networkMode'
|
||||
import { boxAround, type LatLng } from './corridor'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { refuelStopTypeFor, type VehicleKind } from './roadtripModel'
|
||||
import { boxAround, pointAtMeters, type LatLng } from './corridor'
|
||||
import { reachableRefuels, outcomeOf, type RefuelCandidate, type RefuelOutcome } from './refuelSuggestion'
|
||||
|
||||
/**
|
||||
@@ -21,8 +23,27 @@ import { reachableRefuels, outcomeOf, type RefuelCandidate, type RefuelOutcome }
|
||||
* One request, one day, no shared state touched.
|
||||
*/
|
||||
|
||||
/** How far around the dry point to look, in kilometres. */
|
||||
const LOOK_KM = 25
|
||||
/**
|
||||
* How far around the search point to look, in kilometres.
|
||||
*
|
||||
* Fourteen and not more, because the server turns a box into a circle of half its
|
||||
* diagonal and silently narrows anything past 20 km. A box of ±14 asks for 19.8, which
|
||||
* is the largest question it answers in full — ±25 asked for 35 and came back flagged
|
||||
* as narrowed every single time, which then read on screen as "we could not check".
|
||||
*/
|
||||
const LOOK_KM = 14
|
||||
|
||||
/**
|
||||
* How far BEFORE the dry point to centre that circle, in kilometres.
|
||||
*
|
||||
* Only the road already driven is any use: a station past the point is on the far side
|
||||
* of an empty tank. Centring on the dry point spends half the circle on road nobody can
|
||||
* reach, so it is pulled back far enough that the useful half becomes the whole of it.
|
||||
*/
|
||||
const LOOK_BACK_KM = 12
|
||||
|
||||
/** Nothing on offer, as one stable array, so an idle search never redraws the map. */
|
||||
const NONE: RefuelCandidate[] = []
|
||||
|
||||
export interface RefuelSearch {
|
||||
/** Which dry point is being answered, as `<dayId>:<legIndex>`, or null when idle. */
|
||||
@@ -30,6 +51,15 @@ export interface RefuelSearch {
|
||||
loading: boolean
|
||||
outcome: RefuelOutcome | null
|
||||
results: RefuelCandidate[]
|
||||
/**
|
||||
* The ones on offer, for the map to draw.
|
||||
*
|
||||
* The same three the rail lists and no more: somebody is being asked to accept a stop,
|
||||
* and a stop that cannot be seen is not one that can be judged. Empty while nothing is
|
||||
* open, and the same array each time, because the map rebuilds every pin when this
|
||||
* reference changes.
|
||||
*/
|
||||
offered: RefuelCandidate[]
|
||||
/** Runs the one request. `key` identifies the dry point, so only one is open at a time. */
|
||||
ask: (key: string, at: LatLng, line: LatLng[], dryAlongKm: number, existing: LatLng[]) => Promise<void>
|
||||
close: () => void
|
||||
@@ -37,6 +67,9 @@ export interface RefuelSearch {
|
||||
|
||||
export function useRefuelSearch(): RefuelSearch {
|
||||
const { locale } = useTranslation()
|
||||
// What to look for. Somebody who said they drive an electric car has no use for a
|
||||
// petrol station in the list, and the other way round; with nothing said, both.
|
||||
const vehicle = useSettingsStore(s => s.settings.roadtrip_vehicle)
|
||||
const [openFor, setOpenFor] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [outcome, setOutcome] = useState<RefuelOutcome | null>(null)
|
||||
@@ -76,7 +109,14 @@ export function useRefuelSearch(): RefuelSearch {
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const answer = await mapsApi.pois('fuel,charging', boxAround(at, LOOK_KM), locale, controller.signal)
|
||||
const kind: VehicleKind | null =
|
||||
vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null
|
||||
const wanted = refuelStopTypeFor(kind).join(',')
|
||||
// Pulled back along the road, because only what lies before the dry point can be
|
||||
// reached. `at` stays the dry point itself: it is what the band names and what the
|
||||
// map is asked to show.
|
||||
const centre = pointAtMeters(line, Math.max(0, (dryAlongKm - LOOK_BACK_KM) * 1000)) ?? at
|
||||
const answer = await mapsApi.pois(wanted, boxAround(centre, LOOK_KM), locale, controller.signal)
|
||||
if (controller.signal.aborted) return
|
||||
const candidates = reachableRefuels(answer.pois, line, dryAlongKm, { existing })
|
||||
setResults(candidates)
|
||||
@@ -86,7 +126,9 @@ export function useRefuelSearch(): RefuelSearch {
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}, [locale])
|
||||
}, [locale, vehicle])
|
||||
|
||||
return { openFor, loading, outcome, results, ask, close }
|
||||
const offered = useMemo(() => (openFor ? results.slice(0, 3) : NONE), [openFor, results])
|
||||
|
||||
return { openFor, loading, outcome, results, offered, ask, close }
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalcula
|
||||
import { resolveLegMode } from '../Planner/legMode'
|
||||
import {
|
||||
computeSchedule, deriveDriveWarnings, isServiceStopType, legIndexForAlong, refuelsRange, splitIntoRuns,
|
||||
type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning, parseAvoid, type DryPoint,} from './roadtripModel'
|
||||
type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning, parseAvoid, type DryPoint, type VehicleKind } from './roadtripModel'
|
||||
import { projectOntoRoute, pointAtMeters} from './corridor'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import type { Assignment, AssignmentsMap, Day, RouteAvoidClass, RouteSegment, RouteVia, SnappedWaypoint } from '../../types'
|
||||
@@ -283,8 +283,13 @@ export function useRoadtripRoutes(
|
||||
* any value — and an unknown word here would become a costing option the router does
|
||||
* not have.
|
||||
*/
|
||||
const vehicle = useSettingsStore(s => s.settings.roadtrip_vehicle)
|
||||
const avoidSetting = useSettingsStore(s => s.settings.roadtrip_avoid)
|
||||
const avoid = useMemo(() => parseAvoid(avoidSetting), [avoidSetting])
|
||||
// Validated rather than trusted, like the avoidance above: a per-user setting gets no
|
||||
// server-side check, and an unknown word here would silently stop both kinds counting.
|
||||
const vehicleKind: VehicleKind | null =
|
||||
vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null
|
||||
const avoidKey = avoid.join(',')
|
||||
// Zero and absent both mean "no limit": zero is a legal thing to type and says the
|
||||
// same thing, so it is folded here rather than guarded at every reading.
|
||||
@@ -546,7 +551,7 @@ export function useRoadtripRoutes(
|
||||
legs,
|
||||
// One longer than the legs: the last stop of the day counts too, because filling
|
||||
// up on arrival is what makes the next morning start full.
|
||||
stops.map(s => refuelsRange(s.stopType)),
|
||||
stops.map(s => refuelsRange(s.stopType, vehicleKind)),
|
||||
limits,
|
||||
carryKm,
|
||||
)
|
||||
|
||||
@@ -267,7 +267,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
setRoadtripStopKind,
|
||||
saveRoadtripLimit,
|
||||
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia,
|
||||
routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints,
|
||||
routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints,
|
||||
stayDraft, setStayDraft, setRoadtripStay,
|
||||
highlightedAlternative, setHighlightedAlternative,
|
||||
moveRoadtripStopToDay,
|
||||
@@ -325,7 +325,10 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
// drive ("is there a hotel at tonight's stop" versus "what is along the way"), and the
|
||||
// two answers are drawn side by side rather than one hiding the other.
|
||||
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
|
||||
const mapPois = useMergedMapPois(roadtripActive ? roadtripCorridor.visible : null, poi.pois)
|
||||
// The refuel offers ride the same channel: in road trip mode this is the only way a
|
||||
// POI reaches the map, so without them somebody is asked to accept a stop they cannot
|
||||
// see. They vanish with the offer rather than lingering as a search result.
|
||||
const mapPois = useMergedMapPois(roadtripActive ? roadtripCorridor.visible : null, poi.pois, refuel.offered)
|
||||
|
||||
// Costs expense editor opened from a booking modal (save-then-open). Lives at the
|
||||
// page level so it has tripMembers / base currency / current user available.
|
||||
@@ -443,7 +446,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
onRouteClick={roadtripActive && can('day_edit', trip) ? addRoadtripVia : undefined}
|
||||
roadtripVias={roadtripActive ? roadtripVias.byDay : undefined}
|
||||
alternativeRoutes={alternativeOverlays}
|
||||
focusPoints={alternativeFocusPoints}
|
||||
focusPoints={mapFocusPoints}
|
||||
clusterLoosely={roadtripActive}
|
||||
activeAlternative={highlightedAlternative}
|
||||
onChooseAlternative={chooseRouteAlternative}
|
||||
|
||||
@@ -150,6 +150,9 @@ function baseState(): HookState {
|
||||
roadtripActive: false,
|
||||
roadtripRoutes: { lines: [], segments: [], days: [], loading: false, totalStops: 0 },
|
||||
roadtripCorridor: { visible: [], day: null, dayId: '', setDayId: vi.fn(), categories: [], toggleCategory: vi.fn(), widthKm: 5, setWidthKm: vi.fn(), search: vi.fn(), nameFilter: '', setNameFilter: vi.fn(), insertIndexFor: vi.fn() },
|
||||
refuel: { openFor: null, loading: false, outcome: null, results: [], offered: [], ask: vi.fn(), close: vi.fn() },
|
||||
askRefuel: vi.fn(),
|
||||
acceptRefuel: vi.fn(),
|
||||
followTrack: { dayId: null, open: vi.fn(), close: vi.fn(), tracks: [], busy: false, round: 0, error: null, outcome: null, apply: vi.fn(), clear: vi.fn(), viaCount: 0, available: false },
|
||||
roadtripViaCounts: {},
|
||||
stopDraft: null,
|
||||
|
||||
@@ -1075,6 +1075,21 @@ export function useTripPlanner() {
|
||||
[alternativeOverlays],
|
||||
)
|
||||
|
||||
/**
|
||||
* What the map should bring into view.
|
||||
*
|
||||
* Refuel offers win while they are open, and for the reason they exist at all: somebody
|
||||
* is being asked to accept a stop, and a stop off the edge of the map cannot be judged.
|
||||
* They are the newer, smaller and more specific answer, so they take the view from the
|
||||
* alternatives rather than being averaged with them into a frame that shows neither.
|
||||
*/
|
||||
const mapFocusPoints = useMemo<[number, number][]>(
|
||||
() => (refuel.offered.length
|
||||
? refuel.offered.map(p => [p.lat, p.lng] as [number, number])
|
||||
: alternativeFocusPoints),
|
||||
[refuel.offered, alternativeFocusPoints],
|
||||
)
|
||||
|
||||
/** Asks the router for other ways of driving one leg of one day. */
|
||||
const askRouteAlternatives = useCallback((dayId: number, legIndex: number) => {
|
||||
const day = roadtripRoutes.days.find(d => d.dayId === dayId)
|
||||
@@ -1870,7 +1885,7 @@ export function useTripPlanner() {
|
||||
saveRoadtripLimit,
|
||||
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia,
|
||||
refuel, askRefuel, acceptRefuel,
|
||||
routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints,
|
||||
routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints,
|
||||
stayDraft, setStayDraft, setRoadtripStay,
|
||||
highlightedAlternative, setHighlightedAlternative,
|
||||
moveRoadtripStopToDay,
|
||||
|
||||
@@ -64,6 +64,8 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
// Empty = the public Valhalla, unless routing_base_url names an own router, in which
|
||||
// case no second engine is asked at all. See valhallaBase().
|
||||
valhalla_base_url: '',
|
||||
// Empty = not said, and then a stop of either kind fills up. See refuelsRange().
|
||||
roadtrip_vehicle: '',
|
||||
dark_mode: false,
|
||||
// Empty = no personal display currency, so Costs falls back to the trip's own.
|
||||
default_currency: '',
|
||||
|
||||
@@ -159,6 +159,14 @@ export interface Settings {
|
||||
* because it goes to the router as one request either way.
|
||||
*/
|
||||
roadtrip_avoid?: string
|
||||
/**
|
||||
* What the traveller drives: 'combustion', 'electric', or absent for "did not say".
|
||||
*
|
||||
* Absent has to mean BOTH kinds refill, which is what the range budget did before this
|
||||
* existed. Anything else would quietly change the warnings of every traveller who never
|
||||
* opened the dialog.
|
||||
*/
|
||||
roadtrip_vehicle?: string
|
||||
/** CARTO basemaps watermark keyless tiles; the key is appended as ?key= (#2054). */
|
||||
carto_api_key?: string
|
||||
mapbox_access_token?: string
|
||||
|
||||
Reference in New Issue
Block a user