feat(roadtrip): let one stop say how full it fills

The fill level was one figure for the whole trip, and a trip does not have one.
A motorway rapid charger is worth about 80 % because the last fifth costs as
long again; the charger at the hotel is worth all of it because the car stands
there all night. Between those two the difference is a whole leg, and a single
setting has to be wrong about one of them.

So the badge on a refuelling stop is now the way in: it shows what that stop
will actually fill to, its own figure or the traveller's, and opens a panel of
the six answers people actually give. Under them it says what the choice buys in
kilometres, because 80 % is an abstraction and "about 290 km from here" is the
thing being decided. Picking the figure a stop already has hands it back to the
setting, the same gesture the kind picker uses to un-pick a kind.

The value lives in places.fill_percent, beside stop_type, because it is a
property of the stop rather than of the person. Null means "follow the setting",
which is every stop that predates the column and every stop nobody has an
opinion about, so nothing changes for a trip that never touches it.

Two pieces of shared machinery came out of it rather than being copied. The
popover chrome — measure before showing, flip when the window is short, close on
an outside click, Escape or a scroll — is one component now, used by the kind
picker and this one. So is the assembly of the range from seven settings, which
the routes hook and the rail both need and which would have drifted the first
time a field was added.
This commit is contained in:
Maurice
2026-09-09 23:05:35 +02:00
committed by Maurice
parent aaf6559d5d
commit 96ab748f34
46 changed files with 783 additions and 144 deletions
@@ -0,0 +1,84 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
/**
* A small panel hung under something in the rail.
*
* The rail has more than one badge worth opening — what kind of stop this is, how full it
* fills — and every one of them needs the same four things: measure before showing,
* flip above when the window is short, close on an outside click or Escape, and close on
* a scroll rather than chase the anchor. Each is one line to get wrong and none of them
* is about what the panel contains, which is why they live here once instead of beside
* every panel that needs them.
*
* Deliberately not the `CustomSelect` machinery: that one is a select, with a value, a
* search and a keyboard model. This is a surface.
*/
export default function AnchoredPopover({ anchor, label, children, onClose }: {
/** The element the panel hangs under. Null renders nothing, which is how it stays closed. */
anchor: HTMLElement | null
/** What the dialog is called, for anyone who cannot see where it is hanging. */
label: string
children: React.ReactNode
onClose: () => void
}): React.ReactElement | null {
const ref = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
// Measured after paint, before the browser shows it: reading the size first and then
// placing it is what stops the panel appearing at 0,0 for one frame.
useLayoutEffect(() => {
if (!anchor || !ref.current) return
const a = anchor.getBoundingClientRect()
const p = ref.current.getBoundingClientRect()
const gap = 8
let top = a.bottom + gap
// Flips above when there is no room below, which is most of the rail on a short
// window: a panel clipped by the viewport is one that cannot be used at all.
if (top + p.height > window.innerHeight - 8) top = Math.max(8, a.top - p.height - gap)
// Centred on the column, not on the anchor. A badge sits hard against one edge of the
// rail, so a panel centred on it hangs half off the sidebar and points at the map.
// Vertically it still follows the row it belongs to.
const column = anchor.closest('section')?.getBoundingClientRect() ?? a
const left = Math.min(
Math.max(8, column.left + column.width / 2 - p.width / 2),
window.innerWidth - p.width - 8,
)
setPos({ top, left })
}, [anchor])
useEffect(() => {
if (!anchor) return
const onDown = (e: MouseEvent) => {
if (ref.current?.contains(e.target as Node)) return
if (anchor.contains(e.target as Node)) return
onClose()
}
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
// Any scroll moves the anchor out from under the panel, and following it would mean
// measuring on every frame for a menu that is open for two seconds.
window.addEventListener('scroll', onClose, true)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
window.removeEventListener('scroll', onClose, true)
}
}, [anchor, onClose])
if (!anchor) return null
return createPortal(
<div
ref={ref}
role="dialog"
aria-label={label}
className="fixed z-[70] rounded-2xl border border-edge bg-surface-card p-2 shadow-xl"
style={{ top: pos?.top ?? -9999, left: pos?.left ?? -9999, visibility: pos ? 'visible' : 'hidden' }}
>
{children}
</div>,
document.body,
)
}
@@ -17,6 +17,8 @@ import { formatDurationShort, isServiceStopType, serviceColor, type ScheduleEntr
import { STOP_KIND_BY_KEY } from './stopKinds'
import { spurWorthLabelling } from './accessSpur'
import StopKindPicker from './StopKindPicker'
import StopFillPicker from './StopFillPicker'
import { useVehicleRange } from './useVehicleRange'
import type { RoadtripStopType } from '@trek/shared'
import type { QuietDay, RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
import type { RouteVia } from '../../types'
@@ -60,6 +62,11 @@ interface RoadtripSidebarProps {
* Absent leaves every disc read-only, which is also what a viewer sees.
*/
onSetStopKind?: (placeId: number, kind: RoadtripStopType | null) => Promise<void> | void
/**
* How full one stop fills the tank, 1-100, or null to follow the traveller's own
* setting. Absent leaves the badge readable but not editable.
*/
onSetStopFill?: (placeId: number, percent: number | null) => Promise<void> | void
/**
* Opens the dialog that makes a day follow an imported track (#1797).
*
@@ -168,27 +175,80 @@ function OffRoadBadge({ meters }: { meters: number }): React.ReactElement {
/**
* What a fill-up here actually puts in, beside the stay it takes.
*
* Only on a stop that refuels, and only when it is not a full tank: "100 %" would be a
* badge for the default. It reads as the second half of the stay badge because that is
* what it is — how long you stand here, and what you get for it.
* Only on a stop that refuels, because it is the only stop where the answer changes
* anything. It reads as the second half of the stay badge because that is what it is —
* how long you stand here, and what you get for it.
*
* The figure is the stop's own when it has one and the traveller's default otherwise, so
* what the badge says is always what the range budget will actually use. With neither it
* shows a "+", the same invitation the stay badge makes when nothing is planned yet: this
* badge is the only way into the per-stop figure, and a badge that hid itself until a
* value existed could never be used to create one.
*/
function FillBadge({ percent }: { percent: number }): React.ReactElement {
/**
* What a stop will actually fill to: its own figure, else the traveller's default.
*
* Null means nothing worth saying — no per-stop figure and a default that fills right up,
* which is what the budget did before any of this existed. An explicit 100 on the stop is
* NOT null: on a trip whose default is 80 %, "this one goes right up" is a decision, and
* hiding it would leave the traveller reading 80 on a stop that fills to 100.
*/
function effectiveFill(own: number | null | undefined, setting: number | undefined): number | null {
if (own !== null && own !== undefined) return own
return setting && setting > 0 && setting < 100 ? setting : null
}
function FillBadge({ percent, own, onEdit }: {
/** What this stop will actually fill to, inherited or not. Null when nothing says. */
percent: number | null
/** Whether that figure is the stop's own rather than the traveller's default. */
own: boolean
onEdit?: (anchor: HTMLElement) => void
}): React.ReactElement | null {
const { t } = useTranslation()
if (percent === null && !onEdit) return null
const shell = 'inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border border-edge'
const icon = (
<span
className="flex items-center bg-surface-tertiary px-1 text-content-faint"
style={{ fontSize: FS.micro }}
>
<BatteryCharging size={9} aria-hidden />
</span>
)
const value = (
<span
className={`flex items-center border-s border-edge bg-surface-card px-1.5 font-semibold tabular-nums ${
percent === null ? 'text-content-faint' : own ? 'text-content-secondary' : 'text-content-faint'
}`}
style={{ fontSize: FS.micro }}
>
{percent === null ? '+' : `${percent} %`}
</span>
)
if (!onEdit) return <span className={shell}>{icon}{value}</span>
// A span carrying the button role, not a <button>: the whole stop row is already one,
// and a button inside a button is invalid HTML that React warns about and that browsers
// resolve by dropping the inner element.
return (
<Tooltip label={t('roadtrip.limit.fillBadge', { percent })}>
<span className="inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border border-edge">
<span
className="flex items-center bg-surface-tertiary px-1 text-content-faint"
style={{ fontSize: FS.micro }}
>
<BatteryCharging size={9} aria-hidden />
</span>
<span
className="flex items-center border-s border-edge bg-surface-card px-1.5 font-semibold tabular-nums text-content-secondary"
style={{ fontSize: FS.micro }}
>
{`${percent} %`}
</span>
<Tooltip label={percent === null ? t('roadtrip.stop.fillSet') : t('roadtrip.limit.fillBadge', { percent })}>
<span
role="button"
tabIndex={0}
// Stops the click reaching the row, which would select the stop and move the map
// out from under the panel that is about to open.
onClick={e => { e.stopPropagation(); onEdit(e.currentTarget as HTMLElement) }}
onKeyDown={e => {
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
e.stopPropagation()
onEdit(e.currentTarget as HTMLElement)
}}
className={`${shell} cursor-pointer transition-colors hover:border-content-faint focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent`}
>
{icon}{value}
</span>
</Tooltip>
)
@@ -550,7 +610,7 @@ function DriveBand({ leg, onAskAlternatives, alternativesOpen }: {
* the trip is actually for. Its own icon on one flat disc: three kinds of pause that all
* mean "we are still driving", and the icon is what tells them apart.
*/
function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onEditStay, onPickKind }: {
function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onEditStay, onPickKind, onPickFill }: {
stop: RoadtripStop
entry: ScheduleEntry | undefined
/** How late the drive reaches a time pinned on this pause, the same finding a numbered
@@ -561,6 +621,8 @@ function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onE
driveFindings?: ScheduleWarning[]
/** Opens the kind picker on the disc. Absent leaves the rail read-only. */
onPickKind?: (anchor: HTMLElement) => void
/** Opens the panel that sets how full THIS stop fills, hung under the badge. */
onPickFill?: (anchor: HTMLElement) => void
selected: boolean
onSelect?: () => void
/** Opens the dialog for how long this pause takes. Absent means the rail is read-only. */
@@ -570,9 +632,7 @@ function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onE
// Read here rather than threaded down: both stop shapes need the same two, and the
// badge is the only thing in the rail that depends on them.
const fillPercent = useSettingsStore(st => st.settings.roadtrip_fill_percent)
const vehicleSetting = useSettingsStore(st => st.settings.roadtrip_vehicle)
const vehicle: VehicleKind | null =
vehicleSetting === 'combustion' || vehicleSetting === 'electric' ? vehicleSetting : null
const { vehicleKind } = useVehicleRange()
const kind = STOP_KIND_BY_KEY[stop.stopType ?? '']
const Icon = kind?.Icon ?? ParkingSquare
const label = t(kind?.labelKey ?? 'roadtrip.poi.rest')
@@ -639,7 +699,13 @@ function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onE
</span>
<span className="flex flex-wrap items-center gap-1">
<StayBadge minutes={stop.dwellMinutes} onEdit={onEditStay} />
{fillPercent && refuelsRange(stop.stopType, vehicle) ? <FillBadge percent={fillPercent} /> : null}
{refuelsRange(stop.stopType, vehicleKind) ? (
<FillBadge
percent={effectiveFill(stop.fillPercent, fillPercent)}
own={stop.fillPercent !== null && stop.fillPercent !== undefined}
onEdit={onPickFill}
/>
) : null}
{spurWorthLabelling(stop.offRoadMeters) ? <OffRoadBadge meters={stop.offRoadMeters ?? 0} /> : null}
{(driveFindings ?? []).map(w => <DriveFindingBadge key={w.code} warning={w} />)}
<LateBadge late={late} />
@@ -833,7 +899,7 @@ function Arrival({ entry }: { entry: ScheduleEntry }): React.ReactElement {
)
}
function Stop({ stop, number, entry, late, driveFindings, selected, continues, starts, onSelect, onMove, canMove, onEditStay, onPickKind }: {
function Stop({ stop, number, entry, late, driveFindings, selected, continues, starts, onSelect, onMove, canMove, onEditStay, onPickKind, onPickFill }: {
stop: RoadtripStop
/** Position within the day — the same count the map badges its markers with. */
number: number
@@ -848,6 +914,8 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
starts?: boolean
/** Opens the kind picker on the number. Absent leaves the rail read-only. */
onPickKind?: (anchor: HTMLElement) => void
/** Opens the panel that sets how full THIS stop fills, hung under the badge. */
onPickFill?: (anchor: HTMLElement) => void
onSelect?: () => void
/** Moves this stop by one place. Absent means the chain is read-only. */
onMove?: (delta: number) => void
@@ -860,9 +928,7 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
// Read here rather than threaded down: both stop shapes need the same two, and the
// badge is the only thing in the rail that depends on them.
const fillPercent = useSettingsStore(st => st.settings.roadtrip_fill_percent)
const vehicleSetting = useSettingsStore(st => st.settings.roadtrip_vehicle)
const vehicle: VehicleKind | null =
vehicleSetting === 'combustion' || vehicleSetting === 'electric' ? vehicleSetting : null
const { vehicleKind } = useVehicleRange()
return (
<button
type="button"
@@ -943,7 +1009,13 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
one; the number is for luggage, a gate, a track a hire car should not be on. */}
<span className="flex flex-wrap items-center gap-1">
<StayBadge minutes={stop.dwellMinutes} onEdit={onEditStay} />
{fillPercent && refuelsRange(stop.stopType, vehicle) ? <FillBadge percent={fillPercent} /> : null}
{refuelsRange(stop.stopType, vehicleKind) ? (
<FillBadge
percent={effectiveFill(stop.fillPercent, fillPercent)}
own={stop.fillPercent !== null && stop.fillPercent !== undefined}
onEdit={onPickFill}
/>
) : null}
{spurWorthLabelling(stop.offRoadMeters) ? <OffRoadBadge meters={stop.offRoadMeters ?? 0} /> : null}
{(driveFindings ?? []).map(w => <DriveFindingBadge key={w.code} warning={w} />)}
<LateBadge late={late} />
@@ -999,7 +1071,7 @@ function LateBadge({ late }: { late: ScheduleWarning | undefined }): React.React
* (`dayOrderMap` numbers the selected day's assignments from 1). A rail counting across
* the trip would put "17" beside a pin the map calls "3".
*/
function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading }: {
function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, drag, onAskAlternatives, openAlternatives, onEditStay, onSetStopKind, onSetStopFill, onFollowTrack, viaCount, trackName, refuel, onAskRefuel, onAcceptRefuel, loading }: {
day: RoadtripDay
selectedAssignmentId?: number | null
onSelectStop?: (placeId: number, assignmentId: number) => void
@@ -1011,6 +1083,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
openAlternatives?: RoadtripSidebarProps['openAlternatives']
onEditStay?: RoadtripSidebarProps['onEditStay']
onSetStopKind?: RoadtripSidebarProps['onSetStopKind']
onSetStopFill?: RoadtripSidebarProps['onSetStopFill']
onFollowTrack?: RoadtripSidebarProps['onFollowTrack']
viaCount?: number
trackName?: string
@@ -1025,6 +1098,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
// Which disc the picker hangs under, and for which stop. One at a time: two open
// popovers over the same rail is two answers to one question.
const [picking, setPicking] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null)
const [filling, setFilling] = useState<{ anchor: HTMLElement; stop: RoadtripStop } | null>(null)
const { t, language } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const last = day.stops.length - 1
@@ -1179,6 +1253,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
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}
onPickKind={onSetStopKind ? anchor => setPicking({ anchor, stop }) : undefined}
onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined}
/>
) : (
<Stop
@@ -1195,6 +1270,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
canMove={{ up: i > 0, down: i < last }}
onEditStay={onEditStay ? () => onEditStay({ placeId: stop.placeId, name: stop.name, minutes: stop.dwellMinutes, arrival: day.schedule.entries[i]?.arrival ?? null }) : undefined}
onPickKind={onSetStopKind ? anchor => setPicking({ anchor, stop }) : undefined}
onPickFill={onSetStopFill ? anchor => setFilling({ anchor, stop }) : undefined}
/>
)}
{i < last ? (
@@ -1242,6 +1318,17 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
}}
/>
) : null}
{filling ? (
<StopFillPicker
anchor={filling.anchor}
current={filling.stop.fillPercent ?? null}
onClose={() => setFilling(null)}
onPick={percent => {
setFilling(null)
void onSetStopFill?.(filling.stop.placeId, percent)
}}
/>
) : null}
</section>
)
}
@@ -1320,7 +1407,7 @@ function QuietDaySection({ day, onMoveStopToDay, drag }: {
*/
export default function RoadtripSidebar({
routes, selectedAssignmentId, onSelectStop, onReorderStop, onMoveStopToDay, onAskAlternatives, openAlternatives, onEditStay,
onSetStopKind, onFollowTrack, viaCounts, trackNames, refuel, onAskRefuel, onAcceptRefuel,
onSetStopKind, onSetStopFill, onFollowTrack, viaCounts, trackNames, refuel, onAskRefuel, onAcceptRefuel,
}: RoadtripSidebarProps): React.ReactElement {
const { t } = useTranslation()
// One drag state for the whole rail rather than one per day: a stop that cannot leave
@@ -1372,6 +1459,7 @@ export default function RoadtripSidebar({
openAlternatives={openAlternatives}
onEditStay={onEditStay}
onSetStopKind={onSetStopKind}
onSetStopFill={onSetStopFill}
onFollowTrack={onFollowTrack}
viaCount={viaCounts?.[day.dayId] ?? 0}
trackName={trackNames?.[day.dayId]}
@@ -0,0 +1,112 @@
import React from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { fireEvent, render, screen } from '../../../tests/helpers/render'
import { useSettingsStore } from '../../store/settingsStore'
import StopFillPicker from './StopFillPicker'
/**
* FE-STOPFILL-001..010 — how full one stop fills, as opposed to how full they all do.
*
* The figure is two-sided: a stop either has its own or borrows the traveller's, and the
* panel has to open showing which. What is worth pinning is the seam between them —
* that an inherited figure reads as chosen, that picking it again hands the stop back to
* the setting rather than writing the same number, and that the kilometre line under the
* pills is absent rather than invented when no range is set.
*/
let anchor: HTMLElement
beforeEach(() => {
anchor = document.createElement('button')
document.body.appendChild(anchor)
useSettingsStore.setState({
settings: { roadtrip_fill_percent: 80, roadtrip_range_km: 500, distance_unit: 'metric' } as never,
})
})
afterEach(() => { anchor.remove() })
function open(over: { current?: number | null; onPick?: (p: number | null) => void } = {}) {
const onPick = over.onPick ?? vi.fn()
const onClose = vi.fn()
render(
<StopFillPicker
anchor={anchor}
current={over.current ?? null}
onPick={onPick}
onClose={onClose}
/>,
)
return { onPick, onClose }
}
const pill = (percent: number) => screen.getByRole('button', { name: String(percent) })
describe('StopFillPicker', () => {
it('FE-STOPFILL-001: without an anchor there is nothing to hang under', () => {
render(<StopFillPicker anchor={null} current={null} onPick={vi.fn()} onClose={vi.fn()} />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
it('FE-STOPFILL-002: the traveller own figure is the one shown as chosen', () => {
// Nothing said about this stop, so it fills the way every other one does. Opening
// with nothing pressed would suggest the stop has no answer, when it has one.
open({ current: null })
expect(pill(80)).toHaveAttribute('aria-pressed', 'true')
expect(pill(100)).toHaveAttribute('aria-pressed', 'false')
})
it('FE-STOPFILL-003: the stop own figure wins over the traveller own', () => {
open({ current: 60 })
expect(pill(60)).toHaveAttribute('aria-pressed', 'true')
expect(pill(80)).toHaveAttribute('aria-pressed', 'false')
})
it('FE-STOPFILL-004: picking a percentage reports it', () => {
const { onPick } = open({ current: null })
fireEvent.click(pill(60))
expect(onPick).toHaveBeenCalledWith(60)
})
it('FE-STOPFILL-005: picking the figure the stop already has hands it back to the setting', () => {
// The same gesture the kind picker uses to un-pick a kind. Writing 60 again would
// leave the stop pinned to a number it only ever had by inheritance.
const { onPick } = open({ current: 60 })
fireEvent.click(pill(60))
expect(onPick).toHaveBeenCalledWith(null)
})
it('FE-STOPFILL-006: an inherited figure can be pinned by picking it', () => {
// Pressed because it is what the stop uses, but not its own — so clicking it means
// "make this one mine", not "take it off".
const { onPick } = open({ current: null })
fireEvent.click(pill(80))
expect(onPick).toHaveBeenCalledWith(80)
})
it('FE-STOPFILL-007: what the choice buys is stated, and only when it is known', () => {
open({ current: 60 })
// 60 % of a 500 km range.
expect(screen.getByText(/300 km/)).toBeInTheDocument()
})
it('FE-STOPFILL-008: with no range set the kilometres are absent rather than invented', () => {
useSettingsStore.setState({
settings: { roadtrip_fill_percent: 80, distance_unit: 'metric' } as never,
})
open({ current: 60 })
expect(screen.queryByText(/km/)).not.toBeInTheDocument()
})
it('FE-STOPFILL-009: the way back to the default appears only once there is one to undo', () => {
// On a stop with no figure of its own it would be a button that says "leave
// everything as it is".
open({ current: null })
expect(screen.queryByText('Use my default')).not.toBeInTheDocument()
})
it('FE-STOPFILL-010: a stop with its own figure can be handed back to the setting', () => {
const { onPick } = open({ current: 60 })
fireEvent.click(screen.getByText('Use my default'))
expect(onPick).toHaveBeenCalledWith(null)
})
})
@@ -0,0 +1,111 @@
import React from 'react'
import { RotateCcw } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { useSettingsStore } from '../../store/settingsStore'
import { formatDistance } from '../../utils/units'
import AnchoredPopover from './AnchoredPopover'
import { useVehicleRange } from './useVehicleRange'
import { FS } from './typeScale'
import type { DistanceUnit } from '../../types'
/**
* How full THIS stop fills up.
*
* A property of the stop rather than of the traveller, which is the whole reason it is
* here and not only in the settings dialog: a motorway rapid charger is worth about 80 %
* because the last fifth costs as long again, while the one at the hotel is worth all of
* it because the car stands there all night. One figure for the whole trip cannot say
* both, and the difference between them is a leg.
*
* Six pills rather than a slider or a field. A tank is not filled to 63 %, and the number
* being chosen is a decision — "top up" or "fill right up" — not a measurement, so a
* control that offers the six answers people actually give is one tap where a slider is a
* drag and a field is a keyboard.
*
* Under them, what the choice is worth in kilometres, because 80 % is an abstraction and
* "about 290 km from here" is the thing the traveller is actually deciding.
*/
/** The answers people actually give. Below half a tank nobody stops on purpose. */
const PRESETS = [50, 60, 70, 80, 90, 100] as const
export default function StopFillPicker({ anchor, current, onPick, onClose }: {
/** The element the popover hangs under: the stop's own fill badge. */
anchor: HTMLElement | null
/** What this stop says, or null when it follows the traveller's own setting. */
current: number | null
/** A percentage for this stop, or null to hand it back to the setting. */
onPick: (percent: number | null) => void
onClose: () => void
}): React.ReactElement | null {
const { t } = useTranslation()
const unit: DistanceUnit = useSettingsStore(s => s.settings.distance_unit) === 'imperial' ? 'imperial' : 'metric'
const fallback = useSettingsStore(s => s.settings.roadtrip_fill_percent)
const { rangeKm } = useVehicleRange()
// Zero, absent and 100 all mean the same thing in the settings, so they read as full
// here too rather than as three different defaults.
const settingPercent = fallback && fallback > 0 && fallback < 100 ? fallback : 100
const shown = current ?? settingPercent
return (
<AnchoredPopover anchor={anchor} label={t('roadtrip.stop.fillTitle')} onClose={onClose}>
<div className="flex flex-col gap-2 p-0.5">
<p
className="px-1 font-geist font-semibold uppercase tracking-[0.12em] text-content-faint"
style={{ fontSize: FS.label }}
>
{t('roadtrip.stop.fillTitle')}
</p>
<div className="flex gap-1">
{PRESETS.map(percent => {
// Pressed against what the stop will actually use, inherited or not, so the
// panel opens showing where the stop stands rather than showing nothing.
const on = shown === percent
return (
<button
key={percent}
type="button"
aria-pressed={on}
// Picking what it already is hands it back to the setting, the same way
// the kind picker un-picks a kind it already has.
onClick={() => onPick(current === percent ? null : percent)}
className={`h-9 w-9 rounded-xl border text-caption font-semibold tabular-nums transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
on
? 'border-accent bg-accent text-accent-text'
: 'border-edge bg-surface-card text-content-secondary hover:bg-surface-hover'
}`}
>
{percent}
</button>
)
})}
</div>
{/* What the choice buys. Absent rather than guessed when no range is set: a
kilometre figure worked out from nothing would be the most convincing wrong
number on the screen. */}
<p className="px-1 text-caption text-content-muted">
{rangeKm
? t('roadtrip.stop.fillGives', { distance: formatDistance((rangeKm * shown) / 100, unit) })
: t('roadtrip.stop.fillNoRange')}
</p>
{/* Only once there is something to undo. Without an override it would be a button
that says "leave everything as it is". */}
{current !== null ? (
<button
type="button"
onClick={() => onPick(null)}
className="flex w-full items-center gap-2 rounded-xl px-2.5 py-2 text-caption text-content-secondary transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<RotateCcw size={14} className="shrink-0 text-content-faint" aria-hidden />
<span className="flex-1 text-start">{t('roadtrip.stop.fillDefault')}</span>
<span className="tabular-nums text-content-faint">{`${settingPercent} %`}</span>
</button>
) : null}
</div>
</AnchoredPopover>
)
}
@@ -1,8 +1,8 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import React from 'react'
import { MapPin } from 'lucide-react'
import { useTranslation } from '../../i18n/TranslationContext'
import { Tooltip } from '../shared/Tooltip'
import AnchoredPopover from './AnchoredPopover'
import { STOP_KINDS } from './stopKinds'
import type { RoadtripStopType } from '@trek/shared'
@@ -33,61 +33,9 @@ export default function StopKindPicker({ anchor, current, onPick, onClose }: {
onClose: () => void
}): React.ReactElement | null {
const { t } = useTranslation()
const ref = useRef<HTMLDivElement>(null)
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
// Measured after paint, before the browser shows it: reading the size first and then
// placing it is what stops the panel appearing at 0,0 for one frame.
useLayoutEffect(() => {
if (!anchor || !ref.current) return
const a = anchor.getBoundingClientRect()
const p = ref.current.getBoundingClientRect()
const gap = 8
let top = a.bottom + gap
// Flips above when there is no room below, which is most of the rail on a short
// window: a panel clipped by the viewport is one that cannot be used at all.
if (top + p.height > window.innerHeight - 8) top = Math.max(8, a.top - p.height - gap)
// Centred on the column, not on the number. The number sits hard against the left
// edge of the rail, so a panel centred on it hangs half off the sidebar and points at
// the map. Vertically it still follows the row it belongs to.
const column = anchor.closest('section')?.getBoundingClientRect() ?? a
const left = Math.min(
Math.max(8, column.left + column.width / 2 - p.width / 2),
window.innerWidth - p.width - 8,
)
setPos({ top, left })
}, [anchor])
useEffect(() => {
if (!anchor) return
const onDown = (e: MouseEvent) => {
if (ref.current?.contains(e.target as Node)) return
if (anchor.contains(e.target as Node)) return
onClose()
}
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
// Any scroll moves the anchor out from under the panel, and following it would mean
// measuring on every frame for a menu that is open for two seconds.
window.addEventListener('scroll', onClose, true)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
window.removeEventListener('scroll', onClose, true)
}
}, [anchor, onClose])
if (!anchor) return null
return createPortal(
<div
ref={ref}
role="dialog"
aria-label={t('roadtrip.stop.kind')}
className="fixed z-[70] rounded-2xl border border-edge bg-surface-card p-2 shadow-xl"
style={{ top: pos?.top ?? -9999, left: pos?.left ?? -9999, visibility: pos ? 'visible' : 'hidden' }}
>
return (
<AnchoredPopover anchor={anchor} label={t('roadtrip.stop.kind')} onClose={onClose}>
<div className="flex gap-1">
{STOP_KINDS.map(({ key, labelKey, Icon, color }) => {
const on = current === key
@@ -127,7 +75,6 @@ export default function StopKindPicker({ anchor, current, onPick, onClose }: {
{t('roadtrip.stop.backToDestination')}
</button>
) : null}
</div>,
document.body,
</AnchoredPopover>
)
}
@@ -768,4 +768,46 @@ describe('deriveDriveWarnings — filling only part way', () => {
expect(out.warnings).toEqual([])
expect(out.emptyAt).toEqual([])
})
it('FE-ROADTRIP-MODEL-104: a stop that says how full it fills is read against itself', () => {
// The motorway charger tops up to 60 %; the traveller's own default is 80. Reading
// the default here would promise 100 km the car does not have.
const out = deriveDriveWarnings(
[drive(60, 100), drive(60, 450)],
[false, true, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 },
0,
[null, 60, null],
)
// 60 % of 500 km is 300, so the tank is dry 300 km into the second leg rather than
// the 400 the traveller's own figure would have given.
expect(out.emptyAt).toHaveLength(1)
expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, intoLegKm: 300 })
})
it('FE-ROADTRIP-MODEL-105: a stop with no opinion still follows the traveller', () => {
// Same day, same stop, nothing said about it: the default has to keep applying, or
// adding the field would have quietly changed every trip that never used it.
const out = deriveDriveWarnings(
[drive(60, 100), drive(60, 450)],
[false, true, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 },
0,
[null, null, null],
)
expect(out.emptyAt[0]).toMatchObject({ legIndex: 1, intoLegKm: 400 })
})
it('FE-ROADTRIP-MODEL-106: a stop can also say it fills right up', () => {
// The charger at the hotel: the car stands there all night, so this one goes to 100
// even on a trip whose default stops at 80.
const out = deriveDriveWarnings(
[drive(60, 100), drive(60, 450)],
[false, true, false],
{ legMinutes: null, dayMinutes: null, rangeKm: 500, fillPercent: 80 },
0,
[null, 100, null],
)
expect(out.emptyAt).toEqual([])
})
})
@@ -432,6 +432,18 @@ export function deriveDriveWarnings(
limits: DriveLimits,
/** Kilometres already on the tank when the day starts; null when that is unknown. */
carryKm: number | null,
/**
* Per stop, how full THAT stop fills up, 1-100. Absent follows `limits.fillPercent`.
*
* A property of the stop rather than of the traveller: a motorway rapid charger is
* worth about 80 % because the last fifth costs as long again, while the one at the
* hotel is worth all of it because the car stands there all night. One figure for the
* whole trip cannot say both, and the difference between them is a leg.
*
* Last and optional so the four-argument call, which is every caller that has no
* per-stop opinion, keeps meaning exactly what it did.
*/
fillAt: readonly (number | null | undefined)[] = [],
): { warnings: ScheduleWarning[]; day: DayWarning | null; carryKm: number | null; emptyAt: DryPoint[] } {
const warnings: ScheduleWarning[] = []
const emptyAt: DryPoint[] = []
@@ -443,15 +455,22 @@ export function deriveDriveWarnings(
// walk the driving legs only, which is why this is counted here rather than derived.
let drivenMeters = 0
// What is left on the clock straight after a stop. A tank filled to 80 % has already
// used a fifth of its range before the car moves, which is the honest way to say it in
// a budget that counts upwards.
const usedAfterFilling = limits.rangeKm && limits.fillPercent
? limits.rangeKm * (1 - limits.fillPercent / 100)
: 0
/**
* What is already on the clock the moment the car pulls away from stop `i`.
*
* A tank filled to 80 % has used a fifth of its range before it moves, which is how a
* budget that counts upwards says "not full". Zero, absent and 100 all mean it filled
* right up, so all three land on nothing used.
*/
const usedAfterFilling = (i: number): number => {
const percent = fillAt[i] ?? limits.fillPercent
return limits.rangeKm && percent && percent > 0 && percent < 100
? limits.rangeKm * (1 - percent / 100)
: 0
}
for (let i = 0; i < legs.length; i++) {
if (refuelsAt[i]) budget = usedAfterFilling
if (refuelsAt[i]) budget = usedAfterFilling(i)
const leg = legs[i]
// The stop this leg arrives at. Both findings are about what is true on arrival.
const at = i + 1
@@ -502,7 +521,7 @@ export function deriveDriveWarnings(
}
// The last stop of the day counts too: filling up on arrival is what makes the next
// morning start with a full tank.
if (refuelsAt[legs.length]) budget = usedAfterFilling
if (refuelsAt[legs.length]) budget = usedAfterFilling(legs.length)
const minutes = Math.round(totalSeconds / 60)
const day = limits.dayMinutes && minutes > limits.dayMinutes
@@ -6,7 +6,7 @@ import {
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 { effectiveRangeKm } from './vehicleRange'
import { useVehicleRange } from './useVehicleRange'
import type { Assignment, AssignmentsMap, Day, RouteAvoidClass, RouteSegment, RouteVia, SnappedWaypoint } from '../../types'
import { spurFor } from './accessSpur'
import type { RoadtripVia } from '@trek/shared'
@@ -27,6 +27,17 @@ export interface RoadtripStop {
incomingLegMode: string | null
/** fuel / charging / rest_area / campsite, or null for an ordinary place (#1797). */
stopType: string | null
/**
* How full THIS stop fills up, 1-100, or null to follow the traveller's own setting.
*
* A property of the stop: the motorway rapid charger is worth 80 % because the last
* fifth costs as long again, the one at the hotel is worth all of it because the car
* stands there all night.
*
* Optional, like `offRoadMeters`: absent and null say the same thing, and a stop built
* anywhere that has no opinion about filling up should not have to say so.
*/
fillPercent?: number | null
/**
* How far the router had to go to find a road, in metres, once this stop has routed.
*
@@ -228,6 +239,7 @@ const asStop = (a: Assignment): RoadtripStop | null => {
legMode: a.leg_transport_mode ?? null,
incomingLegMode: a.incoming_leg_transport_mode ?? null,
stopType: p.stop_type ?? null,
fillPercent: typeof p.fill_percent === 'number' ? p.fill_percent : null,
}
}
@@ -275,7 +287,6 @@ export function useRoadtripRoutes(
const legMinutes = useSettingsStore(s => s.settings.roadtrip_leg_minutes)
const dayMinutes = useSettingsStore(s => s.settings.roadtrip_day_minutes)
const rangeKm = useSettingsStore(s => s.settings.roadtrip_range_km)
const fillPercent = useSettingsStore(s => s.settings.roadtrip_fill_percent)
/**
* Road classes to weight away, as the settings row stores them: a comma list.
@@ -285,37 +296,11 @@ 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
/**
* What the vehicle is made of, when the traveller filled that in instead of a range.
*
* Read as five primitives rather than as one object so the memo below compares numbers.
* An object rebuilt each render would give `limits` a new identity every time, and
* `limits` is what the route effect watches — every keystroke anywhere in the app would
* recalculate every day's driving.
*/
const tankLitres = useSettingsStore(s => s.settings.roadtrip_tank_litres)
const litresPer100 = useSettingsStore(s => s.settings.roadtrip_litres_per_100)
const batteryKwh = useSettingsStore(s => s.settings.roadtrip_battery_kwh)
const kwhPer100 = useSettingsStore(s => s.settings.roadtrip_kwh_per_100)
const degradationPercent = useSettingsStore(s => s.settings.roadtrip_battery_degradation)
// The parts win over the typed number when they add up, because they are the more
// specific answer; the dialog shows the result, so it is never a surprise. Zero and
// absent both mean "no limit", which is why the falsy fold happens here once.
const planningRangeKm = useMemo(
() => effectiveRangeKm(
vehicleKind,
{ tankLitres, litresPer100, batteryKwh, kwhPer100, degradationPercent },
rangeKm,
) || null,
[vehicleKind, tankLitres, litresPer100, batteryKwh, kwhPer100, degradationPercent, rangeKm],
)
// What the car is and how far it goes on one fill, assembled in one place because the
// rail needs the same answer to say what a given fill buys at a given stop.
const { vehicleKind, rangeKm: planningRangeKm } = useVehicleRange()
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.
@@ -587,6 +572,9 @@ export function useRoadtripRoutes(
stops.map(s => refuelsRange(s.stopType, vehicleKind)),
limits,
carryKm,
// Same length and same order, so a stop that says how far it fills is read
// against itself rather than against the traveller's default.
stops.map(s => s.fillPercent),
)
carryKm = drive.carryKm
// Where each tank runs dry, as a place rather than a distance.
@@ -0,0 +1,46 @@
import { useMemo } from 'react'
import { useSettingsStore } from '../../store/settingsStore'
import { effectiveRangeKm } from './vehicleRange'
import type { VehicleKind } from './roadtripModel'
/**
* How far the traveller's car goes on one fill, and what kind of car it is.
*
* One place rather than two, because the answer is assembled from seven settings and two
* readers need it: the routes hook, which turns it into the range budget every warning is
* measured against, and the rail, which tells a traveller what a given fill buys at a
* given stop. Assembling it twice is the hand-mirrored state this codebase refuses
* everywhere else, and the two copies would drift the first time a field is added.
*
* Each setting is read as its own primitive so the memo compares numbers. An object
* rebuilt on every render would give the result a new identity every time, and what
* depends on it is the effect that recalculates every day's driving.
*/
export function useVehicleRange(): { vehicleKind: VehicleKind | null; rangeKm: number | null } {
const rangeKm = useSettingsStore(s => s.settings.roadtrip_range_km)
// Validated rather than trusted: a per-user setting gets no server-side check, and an
// unknown word here would silently stop both kinds of stop counting as a fill-up.
const vehicle = useSettingsStore(s => s.settings.roadtrip_vehicle)
const tankLitres = useSettingsStore(s => s.settings.roadtrip_tank_litres)
const litresPer100 = useSettingsStore(s => s.settings.roadtrip_litres_per_100)
const batteryKwh = useSettingsStore(s => s.settings.roadtrip_battery_kwh)
const kwhPer100 = useSettingsStore(s => s.settings.roadtrip_kwh_per_100)
const degradationPercent = useSettingsStore(s => s.settings.roadtrip_battery_degradation)
const vehicleKind: VehicleKind | null =
vehicle === 'combustion' || vehicle === 'electric' ? vehicle : null
// The parts win over the typed number when they add up, because they are the more
// specific answer; the settings dialog shows the result, so it is never a surprise.
// Zero and absent both mean "no limit", which is why the falsy fold happens here once.
const planningKm = useMemo(
() => effectiveRangeKm(
vehicleKind,
{ tankLitres, litresPer100, batteryKwh, kwhPer100, degradationPercent },
rangeKm,
) || null,
[vehicleKind, tankLitres, litresPer100, batteryKwh, kwhPer100, degradationPercent, rangeKm],
)
return useMemo(() => ({ vehicleKind, rangeKm: planningKm }), [vehicleKind, planningKm])
}
+2
View File
@@ -265,6 +265,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId,
stopDraft, setStopDraft, saveStopDraft, saveStopDraftAsNight, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop,
setRoadtripStopKind,
setRoadtripStopFill,
saveRoadtripLimit,
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia,
routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints,
@@ -549,6 +550,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
openAlternatives={routeAlternatives.open}
onEditStay={can('place_edit', trip) ? setStayDraft : undefined}
onSetStopKind={can('place_edit', trip) ? setRoadtripStopKind : undefined}
onSetStopFill={can('place_edit', trip) ? setRoadtripStopFill : undefined}
onFollowTrack={can('day_edit', trip) && followTrack.available ? followTrack.open : undefined}
viaCounts={roadtripViaCounts}
trackNames={followTrack.namesByDay}
@@ -888,6 +888,22 @@ export function useTripPlanner() {
}
}, [tripId, tripActions, toast, t])
/**
* How full THIS stop fills the tank, from the road trip rail.
*
* The same shape as the kind above and for the same reason: one field on one place,
* with the rail redrawing off the store the moment it lands. Null hands the stop back
* to whatever the traveller set as their own default, which is what every stop does
* until somebody has an opinion about one.
*/
const setRoadtripStopFill = useCallback(async (placeId: number, percent: number | null) => {
try {
await tripActions.updatePlace(tripId, placeId, { fill_percent: percent })
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
}
}, [tripId, tripActions, toast, t])
/**
* Moves a stop within its day, from the road trip rail.
*
@@ -1882,6 +1898,7 @@ export function useTripPlanner() {
placeFormDayId, setPlaceFormDayId, reservationModalDayId, setReservationModalDayId,
stopDraft, setStopDraft, saveStopDraft, saveStopDraftAsNight, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop,
setRoadtripStopKind,
setRoadtripStopFill,
saveRoadtripLimit,
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia,
refuel, askRefuel, acceptRefuel,
+20
View File
@@ -4514,6 +4514,26 @@ function runMigrations(db: Database.Database): void {
// Retention deletes by day, and the summary reads the newest days first.
db.exec('CREATE INDEX IF NOT EXISTS idx_route_usage_day ON route_usage_daily(day)');
},
/**
* How full THIS stop fills the tank, 1 to 100 (#1797).
*
* Beside stop_type rather than in the traveller's settings, because it is a property
* of the stop and not of the person: a motorway rapid charger gets 80 % because the
* last fifth would cost as long again, while the one at the hotel gets 100 % because
* the car stands there all night. One figure for the whole trip cannot say both, and
* the difference between them is a leg.
*
* NULL means "whatever the traveller's own setting says", which is every row that
* exists today and every stop nobody has an opinion about.
*
* Appended LAST: the array is index-addressed against schema_version.
*/
() => {
const cols = db.prepare("SELECT name FROM pragma_table_info('places')").all() as Array<{ name: string }>;
if (!cols.some(c => c.name === 'fill_percent')) {
db.exec('ALTER TABLE places ADD COLUMN fill_percent INTEGER');
}
},
];
if (currentVersion < migrations.length) {
@@ -60,7 +60,7 @@ export class AssignmentsService {
COALESCE(da.assignment_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as end_time,
p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, p.stop_type,
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, p.stop_type, p.fill_percent,
c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da
JOIN places p ON da.place_id = p.id
@@ -96,7 +96,7 @@ export class AssignmentsService {
COALESCE(da.assignment_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as end_time,
p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, p.stop_type,
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, p.stop_type, p.fill_percent,
c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da
JOIN places p ON da.place_id = p.id
+3
View File
@@ -46,6 +46,9 @@ export function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial<Tag>[]
// The rail marks a fuel stop as one; without it here every stop would need
// its own place request to find out what kind it is.
stop_type: a.stop_type ?? null,
// Same reason: the rail resets a range budget here and has to know how far this
// stop fills, not how far the traveller's default one does.
fill_percent: a.fill_percent ?? null,
category: a.category_id ? {
id: a.category_id,
name: a.category_name,
+2 -2
View File
@@ -91,7 +91,7 @@ export class DaysService {
COALESCE(da.assignment_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as end_time,
p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, p.stop_type,
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, p.stop_type, p.fill_percent,
c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da
JOIN places p ON da.place_id = p.id
@@ -171,7 +171,7 @@ export class DaysService {
COALESCE(da.assignment_time, p.place_time) as place_time,
COALESCE(da.assignment_end_time, p.end_time) as end_time,
p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, p.stop_type,
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.website, p.phone, p.stop_type, p.fill_percent,
c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da
JOIN places p ON da.place_id = p.id
+8 -4
View File
@@ -180,25 +180,27 @@ export class PlacesMcp {
google_place_id: z.string().optional().describe('Google Place ID (e.g. "ChIJd8BlQ2BZwokRAFUEcm_qrcA")'),
google_ftid: z.string().optional().describe('Google Maps feature ID (e.g. "0x89c259b7abdd4769:0x103aaf1c8bf8a050")'),
stop_type: roadtripStopTypeSchema.nullable().optional().describe('What kind of stop on a drive this is: fuel, charging, rest_area, campsite, restaurant or sights. Pass null to turn a service stop back into an ordinary place.'),
fill_percent: z.number().int().min(1).max(100).nullable().optional().describe('How full THIS stop fills the tank, 1-100. A motorway rapid charger is worth about 80 %, the one at the hotel 100 %. Pass null to follow whatever the traveller set as their default fill.'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
access: { group: 'places', mode: 'write' },
})
async updatePlace(
{ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, image_url, transport_mode, osm_id, google_place_id, google_ftid, stop_type }: {
{ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, image_url, transport_mode, osm_id, google_place_id, google_ftid, stop_type, fill_percent }: {
tripId: number; placeId: number; name?: string; description?: string; lat?: number; lng?: number;
address?: string; category_id?: number; price?: number; currency?: string; place_time?: string;
end_time?: string; duration_minutes?: number; notes?: string; website?: string; phone?: string;
image_url?: string | null;
transport_mode?: 'walking' | 'driving' | 'cycling' | 'transit' | 'flight'; osm_id?: string;
google_place_id?: string; google_ftid?: string; stop_type?: RoadtripStopType | null;
fill_percent?: number | null;
},
ctx: McpContext,
) {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
const place = await this.places.update(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, image_url, transport_mode, osm_id, google_place_id, google_ftid, stop_type });
const place = await this.places.update(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, image_url, transport_mode, osm_id, google_place_id, google_ftid, stop_type, fill_percent });
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
this.guards.safeBroadcast(tripId, 'place:updated', { place });
return ok({ place });
@@ -427,16 +429,18 @@ export class PlacesMcp {
image_url: placeImageUrlSchema.nullable().optional().describe('Thumbnail for every listed place: an /uploads/ path, an /api/maps/place-photo/ path, an inline data: image, or an https URL. Pass null to strip the pictures off a batch at once'),
description: z.string().max(2000).optional(),
stop_type: roadtripStopTypeSchema.nullable().optional().describe('What kind of stop on a drive this is: fuel, charging, rest_area, campsite, restaurant or sights. Pass null to turn a service stop back into an ordinary place.'),
fill_percent: z.number().int().min(1).max(100).nullable().optional().describe('How full THIS stop fills the tank, 1-100. A motorway rapid charger is worth about 80 %, the one at the hotel 100 %. Pass null to follow whatever the traveller set as their default fill.'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
access: { group: 'places', mode: 'write' },
})
async bulkUpdatePlaces(
{ tripId, placeIds, category_id, price, currency, transport_mode, place_time, end_time, duration_minutes, notes, website, phone, image_url, description, stop_type }: {
{ tripId, placeIds, category_id, price, currency, transport_mode, place_time, end_time, duration_minutes, notes, website, phone, image_url, description, stop_type, fill_percent }: {
tripId: number; placeIds: number[]; category_id?: number; price?: number; currency?: string;
transport_mode?: 'walking' | 'driving' | 'cycling' | 'transit' | 'flight'; place_time?: string;
end_time?: string; duration_minutes?: number; notes?: string; website?: string; phone?: string;
image_url?: string | null; description?: string; stop_type?: RoadtripStopType | null;
fill_percent?: number | null;
},
ctx: McpContext,
) {
@@ -444,7 +448,7 @@ export class PlacesMcp {
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
const fields = { category_id, price, currency, transport_mode, place_time, end_time, duration_minutes, notes, website, phone, image_url, description, stop_type };
const fields = { category_id, price, currency, transport_mode, place_time, end_time, duration_minutes, notes, website, phone, image_url, description, stop_type, fill_percent };
if (Object.values(fields).every(v => v === undefined)) {
return { content: [{ type: 'text' as const, text: 'Provide at least one field to update.' }], isError: true };
}
+14 -4
View File
@@ -70,6 +70,8 @@ export interface PlaceCreateInput {
google_place_id?: string; google_ftid?: string; osm_id?: string; website?: string; phone?: string;
/** What kind of stop this is on a drive (fuel, charging, rest_area, campsite); null for an ordinary place. */
stop_type?: string | null;
/** How full THIS stop fills the tank, 1-100; null to follow the traveller's own setting. */
fill_percent?: number | null;
transport_mode?: string; route_geometry?: string; route_color?: string; tags?: number[];
}
@@ -82,6 +84,8 @@ export interface PlaceUpdateInput {
google_place_id?: string; google_ftid?: string; osm_id?: string; website?: string; phone?: string;
/** What kind of stop this is on a drive (fuel, charging, rest_area, campsite); null for an ordinary place. */
stop_type?: string | null;
/** How full THIS stop fills the tank, 1-100; null to follow the traveller's own setting. */
fill_percent?: number | null;
transport_mode?: string; route_color?: string | null; tags?: number[];
}
@@ -229,15 +233,15 @@ export class PlacesService {
name, description, lat, lng, address, category_id, price, currency,
place_time, end_time,
duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone,
transport_mode, route_geometry, route_color, stop_type, tags = [],
transport_mode, route_geometry, route_color, stop_type, fill_percent, tags = [],
} = body;
const result = this.dbs.run(`
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
place_time, end_time,
duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone, transport_mode,
route_geometry, route_color, stop_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
route_geometry, route_color, stop_type, fill_percent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
// lat/lng/price/duration_minutes use an explicit undefined check, not `||`:
// 0 is a legitimate value for all four (Null Island, a free entry, a
@@ -253,6 +257,9 @@ export class PlacesService {
place_time || null, end_time || null, duration_minutes ?? 60, notes || null, image_url || null,
google_place_id || null, google_ftid || null, osm_id || null, website || null, phone || null, transport_mode || 'walking',
route_geometry || null, route_color || null, stop_type || null,
// `?? null` rather than `|| null`, the same reason lat/lng have it: the column is a
// percentage and the falsy check would be a silent floor.
fill_percent ?? null,
);
const placeId = result.lastInsertRowid;
@@ -317,7 +324,7 @@ export class PlacesService {
name, description, lat, lng, address, category_id, price, currency,
place_time, end_time,
duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, website, phone,
transport_mode, route_color, stop_type, tags,
transport_mode, route_color, stop_type, fill_percent, tags,
} = body;
this.dbs.run(`
@@ -343,6 +350,7 @@ export class PlacesService {
transport_mode = COALESCE(?, transport_mode),
route_color = ?,
stop_type = ?,
fill_percent = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`,
@@ -372,6 +380,8 @@ export class PlacesService {
route_color !== undefined ? route_color : existingPlace.route_color,
// Same shape: an explicit null is how a fuel stop becomes an ordinary place again.
stop_type !== undefined ? stop_type : existingPlace.stop_type,
// And how a stop that had its own fill amount goes back to following the setting.
fill_percent !== undefined ? fill_percent : existingPlace.fill_percent,
placeId,
);
+4
View File
@@ -80,6 +80,8 @@ export interface Place {
transport_mode?: string;
/** What kind of stop this is on a drive (#1797); null for an ordinary place. */
stop_type?: string | null;
/** How full this stop fills the tank, 1-100; null follows the traveller's own setting. */
fill_percent?: number | null;
created_at?: string;
updated_at?: string;
}
@@ -348,6 +350,8 @@ export interface AssignmentRow extends DayAssignment {
website: string | null;
phone: string | null;
stop_type: string | null;
/** How full this stop fills the tank, 1-100; null follows the traveller's own setting. */
fill_percent: number | null;
category_name: string | null;
category_color: string | null;
category_icon: string | null;
+1 -1
View File
@@ -30,7 +30,7 @@ const { db } = vi.hoisted(() => {
description TEXT, lat REAL, lng REAL, address TEXT, category_id INTEGER, price REAL, currency TEXT,
place_time TEXT, end_time TEXT, duration_minutes INTEGER DEFAULT 60, notes TEXT, image_url TEXT,
transport_mode TEXT DEFAULT 'walking', google_place_id TEXT, google_ftid TEXT, osm_id TEXT, website TEXT, phone TEXT,
stop_type TEXT);`);
stop_type TEXT, fill_percent INTEGER);`);
tmp.exec(`CREATE TABLE day_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL,
place_id INTEGER NOT NULL, order_index INTEGER NOT NULL DEFAULT 0, notes TEXT,
assignment_time TEXT, assignment_end_time TEXT, leg_transport_mode TEXT,
+1 -1
View File
@@ -33,7 +33,7 @@ const { db } = vi.hoisted(() => {
tmp.exec(`CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT,
description TEXT, lat REAL, lng REAL, address TEXT, category_id INTEGER, price REAL, currency TEXT,
place_time TEXT, end_time TEXT, duration_minutes INTEGER, notes TEXT, image_url TEXT, transport_mode TEXT,
google_place_id TEXT, google_ftid TEXT, osm_id TEXT, website TEXT, phone TEXT, stop_type TEXT);`);
google_place_id TEXT, google_ftid TEXT, osm_id TEXT, website TEXT, phone TEXT, stop_type TEXT, fill_percent INTEGER);`);
tmp.exec(`CREATE TABLE day_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL,
place_id INTEGER NOT NULL, order_index INTEGER DEFAULT 0, notes TEXT,
assignment_time TEXT, assignment_end_time TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
+1 -1
View File
@@ -28,7 +28,7 @@ const { db } = vi.hoisted(() => {
description TEXT, lat REAL, lng REAL, address TEXT, category_id INTEGER, price REAL, currency TEXT,
place_time TEXT, end_time TEXT, duration_minutes INTEGER, notes TEXT, image_url TEXT,
google_place_id TEXT, google_ftid TEXT, osm_id TEXT, website TEXT, phone TEXT, transport_mode TEXT,
route_geometry TEXT, route_color TEXT, stop_type TEXT,
route_geometry TEXT, route_color TEXT, stop_type TEXT, fill_percent INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP);`);
tmp.exec(`CREATE TABLE categories (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT, icon TEXT);`);
tmp.exec(`CREATE TABLE tags (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT,
+1 -1
View File
@@ -34,7 +34,7 @@ const { db } = vi.hoisted(() => {
tmp.exec(`CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL,
day_number INTEGER, date TEXT, title TEXT, notes TEXT);`);
tmp.exec(`CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT,
lat REAL, lng REAL, route_geometry TEXT, stop_type TEXT);`);
lat REAL, lng REAL, route_geometry TEXT, stop_type TEXT, fill_percent INTEGER);`);
tmp.exec(`CREATE TABLE roadtrip_vias (id INTEGER PRIMARY KEY AUTOINCREMENT,
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
after_order_index INTEGER NOT NULL, sequence INTEGER NOT NULL DEFAULT 0,
+5
View File
@@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.leg.driveText': '{distance} خلال {time}',
'roadtrip.day.stopCount': '{count} محطات',
'roadtrip.stop.stayShort': 'مدة',
'roadtrip.stop.fillTitle': 'يملأ حتى',
'roadtrip.stop.fillGives': 'نحو {distance} من هنا',
'roadtrip.stop.fillNoRange': 'أدخل المدى لترى ما الذي يمنحه ذلك.',
'roadtrip.stop.fillDefault': 'استخدام الإعداد الافتراضي الخاص بي',
'roadtrip.stop.fillSet': 'حدد مقدار التعبئة في هذه المحطة',
'roadtrip.leg.pending': 'لا يوجد مسار',
'roadtrip.stop.addTitle': 'إضافة كمحطة توقف',
'roadtrip.stop.kind': 'نوع المحطة',
+5
View File
@@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.leg.driveText': '{distance} em {time}',
'roadtrip.day.stopCount': '{count} paradas',
'roadtrip.stop.stayShort': 'Parada',
'roadtrip.stop.fillTitle': 'Abastece até',
'roadtrip.stop.fillGives': 'cerca de {distance} a partir daqui',
'roadtrip.stop.fillNoRange': 'Informe uma autonomia para ver o que isso rende.',
'roadtrip.stop.fillDefault': 'Usar meu padrão',
'roadtrip.stop.fillSet': 'Definir quanto abastecer nesta parada',
'roadtrip.leg.pending': 'Sem rota',
'roadtrip.stop.addTitle': 'Adicionar como parada',
'roadtrip.stop.kind': 'Tipo de parada',
+5
View File
@@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.leg.driveText': '{distance} en {time}',
'roadtrip.day.stopCount': '{count} parades',
'roadtrip.stop.stayShort': 'Parada',
'roadtrip.stop.fillTitle': 'Omple al',
'roadtrip.stop.fillGives': 'uns {distance} des d’aquí',
'roadtrip.stop.fillNoRange': 'Indica una autonomia per veure què dona.',
'roadtrip.stop.fillDefault': 'Usa el meu valor per defecte',
'roadtrip.stop.fillSet': 'Defineix quant s’omple en aquesta parada',
'roadtrip.leg.pending': 'Sense ruta',
'roadtrip.stop.addTitle': 'Afegeix com a parada',
'roadtrip.stop.kind': 'Tipus de parada',
+5
View File
@@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.leg.driveText': '{distance} za {time}',
'roadtrip.day.stopCount': 'zastávek: {count}',
'roadtrip.stop.stayShort': 'Pobyt',
'roadtrip.stop.fillTitle': 'Natankuje na',
'roadtrip.stop.fillGives': 'asi {distance} odsud',
'roadtrip.stop.fillNoRange': 'Zadej dojezd, ať je vidět, co to vynese.',
'roadtrip.stop.fillDefault': 'Použít moji výchozí hodnotu',
'roadtrip.stop.fillSet': 'Nastavit, na kolik se tady tankuje',
'roadtrip.leg.pending': 'Bez trasy',
'roadtrip.stop.addTitle': 'Přidat jako zastávku',
'roadtrip.stop.kind': 'Druh zastávky',
+5
View File
@@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.leg.driveText': '{distance} in {time}',
'roadtrip.day.stopCount': '{count} Stopps',
'roadtrip.stop.stayShort': 'Dauer',
'roadtrip.stop.fillTitle': 'Tankt auf',
'roadtrip.stop.fillGives': 'etwa {distance} ab hier',
'roadtrip.stop.fillNoRange': 'Trag eine Reichweite ein, dann steht hier, was das bringt.',
'roadtrip.stop.fillDefault': 'Meinen Standard nehmen',
'roadtrip.stop.fillSet': 'Festlegen, wie voll hier getankt wird',
'roadtrip.leg.pending': 'Keine Route',
'roadtrip.stop.addTitle': 'Als Stopp hinzufügen',
'roadtrip.stop.kind': 'Art des Stopps',
+5
View File
@@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.leg.driveText': '{distance} in {time}',
'roadtrip.day.stopCount': '{count} stops',
'roadtrip.stop.stayShort': 'Stay',
'roadtrip.stop.fillTitle': 'Fills to',
'roadtrip.stop.fillGives': 'about {distance} from here',
'roadtrip.stop.fillNoRange': 'Set a range to see what that buys.',
'roadtrip.stop.fillDefault': 'Use my default',
'roadtrip.stop.fillSet': 'Set how full this stop fills',
'roadtrip.leg.pending': 'No route',
'roadtrip.stop.addTitle': 'Add as a stop',
'roadtrip.stop.kind': 'Kind of stop',
+5
View File
@@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.leg.driveText': '{distance} en {time}',
'roadtrip.day.stopCount': '{count} paradas',
'roadtrip.stop.stayShort': 'Parada',
'roadtrip.stop.fillTitle': 'Reposta al',
'roadtrip.stop.fillGives': 'unos {distance} desde aquí',
'roadtrip.stop.fillNoRange': 'Indica una autonomía para ver lo que da.',
'roadtrip.stop.fillDefault': 'Usar mi valor por defecto',
'roadtrip.stop.fillSet': 'Definir cuánto se reposta en esta parada',
'roadtrip.leg.pending': 'Sin ruta',
'roadtrip.stop.addTitle': 'Añadir como parada',
'roadtrip.stop.kind': 'Tipo de parada',
+5
View File
@@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.leg.driveText': '{distance} en {time}',
'roadtrip.day.stopCount': '{count} arrêts',
'roadtrip.stop.stayShort': 'Durée',
'roadtrip.stop.fillTitle': 'Remplit à',
'roadtrip.stop.fillGives': 'environ {distance} à partir d’ici',
'roadtrip.stop.fillNoRange': 'Indiquez une autonomie pour voir ce que cela donne.',
'roadtrip.stop.fillDefault': 'Utiliser ma valeur par défaut',
'roadtrip.stop.fillSet': 'Définir le niveau de remplissage de cet arrêt',
'roadtrip.leg.pending': 'Pas d’itinéraire',
'roadtrip.stop.addTitle': 'Ajouter comme arrêt',
'roadtrip.stop.kind': 'Type d’arrêt',
+5
View File
@@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.leg.driveText': '{distance} σε {time}',
'roadtrip.day.stopCount': '{count} στάσεις',
'roadtrip.stop.stayShort': 'Στάση',
'roadtrip.stop.fillTitle': 'Γεμίζει στο',
'roadtrip.stop.fillGives': 'περίπου {distance} από εδώ',
'roadtrip.stop.fillNoRange': 'Δώσε μια αυτονομία για να φανεί τι αποδίδει.',
'roadtrip.stop.fillDefault': 'Χρήση της προεπιλογής μου',
'roadtrip.stop.fillSet': 'Όρισε πόσο γεμίζει αυτή η στάση',
'roadtrip.leg.pending': 'Χωρίς διαδρομή',
'roadtrip.stop.addTitle': 'Προσθήκη ως στάση',
'roadtrip.stop.kind': 'Είδος στάσης',
+5
View File
@@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.leg.driveText': '{distance} {time} alatt',
'roadtrip.day.stopCount': '{count} megálló',
'roadtrip.stop.stayShort': 'Idő',
'roadtrip.stop.fillTitle': 'Feltölt eddig',
'roadtrip.stop.fillGives': 'innen körülbelül {distance}',
'roadtrip.stop.fillNoRange': 'Add meg a hatótávot, hogy látszódjon, mennyit ér.',
'roadtrip.stop.fillDefault': 'Az alapértelmezettet használom',
'roadtrip.stop.fillSet': 'Beállítás: mennyire tölt fel ez a megálló',
'roadtrip.leg.pending': 'Nincs útvonal',
'roadtrip.stop.addTitle': 'Hozzáadás megállóként',
'roadtrip.stop.kind': 'Megálló típusa',
+5
View File
@@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.leg.driveText': '{distance} dalam {time}',
'roadtrip.day.stopCount': '{count} perhentian',
'roadtrip.stop.stayShort': 'Singgah',
'roadtrip.stop.fillTitle': 'Isi sampai',
'roadtrip.stop.fillGives': 'sekitar {distance} dari sini',
'roadtrip.stop.fillNoRange': 'Isi jarak jangkau untuk melihat hasilnya.',
'roadtrip.stop.fillDefault': 'Pakai bawaan saya',
'roadtrip.stop.fillSet': 'Atur seberapa penuh perhentian ini mengisi',
'roadtrip.leg.pending': 'Tidak ada rute',
'roadtrip.stop.addTitle': 'Tambahkan sebagai perhentian',
'roadtrip.stop.kind': 'Jenis perhentian',
+5
View File
@@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.leg.driveText': '{distance} in {time}',
'roadtrip.day.stopCount': '{count} soste',
'roadtrip.stop.stayShort': 'Sosta',
'roadtrip.stop.fillTitle': 'Riempie al',
'roadtrip.stop.fillGives': 'circa {distance} da qui',
'roadtrip.stop.fillNoRange': 'Inserisci un’autonomia per vedere quanto rende.',
'roadtrip.stop.fillDefault': 'Usa il mio valore predefinito',
'roadtrip.stop.fillSet': 'Imposta quanto si riempie in questa sosta',
'roadtrip.leg.pending': 'Nessun percorso',
'roadtrip.stop.addTitle': 'Aggiungi come sosta',
'roadtrip.stop.kind': 'Tipo di sosta',
+5
View File
@@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.leg.driveText': '{distance}/{time}',
'roadtrip.day.stopCount': '{count} 箇所',
'roadtrip.stop.stayShort': '滞在',
'roadtrip.stop.fillTitle': '補給する量',
'roadtrip.stop.fillGives': 'ここから約 {distance}',
'roadtrip.stop.fillNoRange': '航続距離を入れると、これで何 km 走れるか出ます。',
'roadtrip.stop.fillDefault': '自分の既定値を使う',
'roadtrip.stop.fillSet': 'この停車地でどこまで補給するか決める',
'roadtrip.leg.pending': 'ルートなし',
'roadtrip.stop.addTitle': '立ち寄り先として追加',
'roadtrip.stop.kind': '立ち寄りの種類',
+5
View File
@@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.leg.driveText': '{distance}, {time}',
'roadtrip.day.stopCount': '{count}곳',
'roadtrip.stop.stayShort': '체류',
'roadtrip.stop.fillTitle': '충전 목표',
'roadtrip.stop.fillGives': '여기서 약 {distance}',
'roadtrip.stop.fillNoRange': '주행거리를 입력하면 얼마나 가는지 표시됩니다.',
'roadtrip.stop.fillDefault': '내 기본값 사용',
'roadtrip.stop.fillSet': '이 정차지에서 얼마나 충전할지 설정',
'roadtrip.leg.pending': '경로 없음',
'roadtrip.stop.addTitle': '경유지로 추가',
'roadtrip.stop.kind': '경유지 종류',
+5
View File
@@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.leg.driveText': '{distance} in {time}',
'roadtrip.day.stopCount': '{count} stops',
'roadtrip.stop.stayShort': 'Duur',
'roadtrip.stop.fillTitle': 'Vult tot',
'roadtrip.stop.fillGives': 'ongeveer {distance} vanaf hier',
'roadtrip.stop.fillNoRange': 'Vul een actieradius in om te zien wat dat oplevert.',
'roadtrip.stop.fillDefault': 'Mijn standaard gebruiken',
'roadtrip.stop.fillSet': 'Instellen hoe vol er hier wordt getankt',
'roadtrip.leg.pending': 'Geen route',
'roadtrip.stop.addTitle': 'Als stop toevoegen',
'roadtrip.stop.kind': 'Soort stop',
+5
View File
@@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.leg.driveText': '{distance} w {time}',
'roadtrip.day.stopCount': '{count} przystanków',
'roadtrip.stop.stayShort': 'Postój',
'roadtrip.stop.fillTitle': 'Tankuje do',
'roadtrip.stop.fillGives': 'około {distance} stąd',
'roadtrip.stop.fillNoRange': 'Podaj zasięg, żeby zobaczyć, ile to daje.',
'roadtrip.stop.fillDefault': 'Użyj mojej wartości domyślnej',
'roadtrip.stop.fillSet': 'Ustaw, do ile tankować na tym postoju',
'roadtrip.leg.pending': 'Brak trasy',
'roadtrip.stop.addTitle': 'Dodaj jako przystanek',
'roadtrip.stop.kind': 'Rodzaj przystanku',
+5
View File
@@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.leg.driveText': '{distance} за {time}',
'roadtrip.day.stopCount': 'остановок: {count}',
'roadtrip.stop.stayShort': 'Стоянка',
'roadtrip.stop.fillTitle': 'Заправка до',
'roadtrip.stop.fillGives': 'примерно {distance} отсюда',
'roadtrip.stop.fillNoRange': 'Укажите запас хода, чтобы увидеть, сколько это даёт.',
'roadtrip.stop.fillDefault': 'Взять моё значение по умолчанию',
'roadtrip.stop.fillSet': 'Задать, насколько заправляться на этой остановке',
'roadtrip.leg.pending': 'Нет маршрута',
'roadtrip.stop.addTitle': 'Добавить как остановку',
'roadtrip.stop.kind': 'Тип остановки',
+5
View File
@@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.leg.driveText': '{distance} på {time}',
'roadtrip.day.stopCount': '{count} stopp',
'roadtrip.stop.stayShort': 'Stopp',
'roadtrip.stop.fillTitle': 'Fyller till',
'roadtrip.stop.fillGives': 'cirka {distance} härifrån',
'roadtrip.stop.fillNoRange': 'Ange en räckvidd för att se vad det ger.',
'roadtrip.stop.fillDefault': 'Använd mitt standardvärde',
'roadtrip.stop.fillSet': 'Ange hur mycket det här stoppet fyller',
'roadtrip.leg.pending': 'Ingen rutt',
'roadtrip.stop.addTitle': 'Lägg till som stopp',
'roadtrip.stop.kind': 'Typ av stopp',
+5
View File
@@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.leg.driveText': '{time} içinde {distance}',
'roadtrip.day.stopCount': '{count} mola',
'roadtrip.stop.stayShort': 'Süre',
'roadtrip.stop.fillTitle': 'Şu kadar dolar',
'roadtrip.stop.fillGives': 'buradan yaklaşık {distance}',
'roadtrip.stop.fillNoRange': 'Ne kazandırdığını görmek için bir menzil gir.',
'roadtrip.stop.fillDefault': 'Kendi varsayılanımı kullan',
'roadtrip.stop.fillSet': 'Bu molada ne kadar doldurulacağını ayarla',
'roadtrip.leg.pending': 'Rota yok',
'roadtrip.stop.addTitle': 'Mola olarak ekle',
'roadtrip.stop.kind': 'Mola türü',
+5
View File
@@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.leg.driveText': '{distance} за {time}',
'roadtrip.day.stopCount': 'зупинок: {count}',
'roadtrip.stop.stayShort': 'Стоянка',
'roadtrip.stop.fillTitle': 'Заправка до',
'roadtrip.stop.fillGives': 'приблизно {distance} звідси',
'roadtrip.stop.fillNoRange': 'Вкажіть запас ходу, щоб побачити, скільки це дає.',
'roadtrip.stop.fillDefault': 'Узяти моє значення за замовчуванням',
'roadtrip.stop.fillSet': 'Задати, наскільки заправлятися на цій зупинці',
'roadtrip.leg.pending': 'Немає маршруту',
'roadtrip.stop.addTitle': 'Додати як зупинку',
'roadtrip.stop.kind': 'Тип зупинки',
+5
View File
@@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.leg.driveText': '{distance} trong {time}',
'roadtrip.day.stopCount': '{count} điểm dừng',
'roadtrip.stop.stayShort': 'Dừng',
'roadtrip.stop.fillTitle': 'Đổ đến',
'roadtrip.stop.fillGives': 'khoảng {distance} từ đây',
'roadtrip.stop.fillNoRange': 'Nhập quãng đường để thấy được bao nhiêu.',
'roadtrip.stop.fillDefault': 'Dùng mặc định của tôi',
'roadtrip.stop.fillSet': 'Đặt mức đổ tại điểm dừng này',
'roadtrip.leg.pending': 'Không có lộ trình',
'roadtrip.stop.addTitle': 'Thêm làm điểm dừng',
'roadtrip.stop.kind': 'Loại điểm dừng',
+5
View File
@@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.leg.driveText': '{distance},{time}',
'roadtrip.day.stopCount': '{count} 個停靠點',
'roadtrip.stop.stayShort': '停留',
'roadtrip.stop.fillTitle': '加到',
'roadtrip.stop.fillGives': '從這裡約 {distance}',
'roadtrip.stop.fillNoRange': '填寫續航後,這裡會顯示能跑多遠。',
'roadtrip.stop.fillDefault': '使用我的預設值',
'roadtrip.stop.fillSet': '設定這一站加到多少',
'roadtrip.leg.pending': '無路線',
'roadtrip.stop.addTitle': '新增為停靠點',
'roadtrip.stop.kind': '停靠類型',
+5
View File
@@ -143,6 +143,11 @@ const roadtrip: TranslationStrings = {
'roadtrip.leg.driveText': '{distance},{time}',
'roadtrip.day.stopCount': '{count} 个停靠点',
'roadtrip.stop.stayShort': '停留',
'roadtrip.stop.fillTitle': '加到',
'roadtrip.stop.fillGives': '从这里约 {distance}',
'roadtrip.stop.fillNoRange': '填写续航后,这里会显示能跑多远。',
'roadtrip.stop.fillDefault': '使用我的默认值',
'roadtrip.stop.fillSet': '设置这一站加到多少',
'roadtrip.leg.pending': '无路线',
'roadtrip.stop.addTitle': '添加为停靠点',
'roadtrip.stop.kind': '停靠类型',
+28 -1
View File
@@ -94,6 +94,14 @@ export type PlaceRatingVote = z.infer<typeof placeRatingVoteSchema>;
export const roadtripStopTypeSchema = z.enum(['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights']);
export type RoadtripStopType = z.infer<typeof roadtripStopTypeSchema>;
/**
* A percentage of a tank, 1 to 100, or null for "no opinion, use the setting".
*
* Integer because a tenth of a percent of a tank is not a thing anybody knows about
* their own car, and bounded away from zero because a fill of nothing is not a fill.
*/
const fillPercentSchema = z.number().int().min(1).max(100).nullable().optional();
export const placeSchema = z.object({
id: z.number(),
trip_id: z.number(),
@@ -128,6 +136,16 @@ export const placeSchema = z.object({
* already knows and that owns its own icon and colour.
*/
stop_type: roadtripStopTypeSchema.nullable().optional(),
/**
* How full THIS stop fills the tank, 1-100 (#1797). null follows the traveller's own
* setting, which is every place that predates the field.
*
* A property of the stop rather than of the person: a motorway rapid charger is worth
* 80 % because the last fifth costs as long again, while the one at the hotel is worth
* 100 % because the car stands there all night. One figure for the whole trip cannot
* say both, and the difference between them is a leg.
*/
fill_percent: fillPercentSchema,
website: z.string().nullable().optional(),
phone: z.string().nullable().optional(),
transport_mode: z.string().nullable().optional(),
@@ -175,6 +193,9 @@ export const assignmentPlaceSchema = z.object({
// Carried through so the road-trip rail can mark a fuel stop as one without a
// second request per stop.
stop_type: roadtripStopTypeSchema.nullable().optional(),
// Carried for the same reason as stop_type: the rail resets a range budget at this
// stop and needs to know how far it fills without a request per stop.
fill_percent: fillPercentSchema,
category: placeCategorySchema.optional(),
tags: z.array(tagSchema.partial()).optional(),
});
@@ -191,7 +212,13 @@ export type AssignmentPlace = z.infer<typeof assignmentPlaceSchema>;
* Nullable: an explicit null is how a fuel stop becomes an ordinary place again, and the
* service already reads it that way rather than as "leave alone".
*/
const stopTypeField = z.object({ stop_type: roadtripStopTypeSchema.nullable().optional() });
const stopTypeField = z.object({
stop_type: roadtripStopTypeSchema.nullable().optional(),
// Named for the same reason: a bounded vocabulary on an otherwise open body. Zero is
// outside it on purpose — a stop that fills nothing is not a stop, and letting one
// through would leave a range budget that never resets.
fill_percent: fillPercentSchema,
});
export const placeCreateRequestSchema = open.and(z.object({ name: z.string().min(1) })).and(stopTypeField);
export type PlaceCreateRequest = z.infer<typeof placeCreateRequestSchema>;