diff --git a/client/src/components/Roadtrip/AnchoredPopover.tsx b/client/src/components/Roadtrip/AnchoredPopover.tsx new file mode 100644 index 000000000..692cea62b --- /dev/null +++ b/client/src/components/Roadtrip/AnchoredPopover.tsx @@ -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(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( +
+ {children} +
, + document.body, + ) +} diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index c60c57a19..984398a33 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -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 + /** + * 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 /** * 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 = ( + + + + ) + const value = ( + + {percent === null ? '+' : `${percent} %`} + + ) + + if (!onEdit) return {icon}{value} + // A span carrying the button role, not a + ) + })} + + + {/* 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. */} +

+ {rangeKm + ? t('roadtrip.stop.fillGives', { distance: formatDistance((rangeKm * shown) / 100, unit) }) + : t('roadtrip.stop.fillNoRange')} +

+ + {/* Only once there is something to undo. Without an override it would be a button + that says "leave everything as it is". */} + {current !== null ? ( + + ) : null} + + + ) +} diff --git a/client/src/components/Roadtrip/StopKindPicker.tsx b/client/src/components/Roadtrip/StopKindPicker.tsx index 11dfbf4c2..e037f2350 100644 --- a/client/src/components/Roadtrip/StopKindPicker.tsx +++ b/client/src/components/Roadtrip/StopKindPicker.tsx @@ -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(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( -
+ return ( +
{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')} ) : null} -
, - document.body, +
) } diff --git a/client/src/components/Roadtrip/roadtripModel.test.ts b/client/src/components/Roadtrip/roadtripModel.test.ts index 3cebde8ea..1dc6b7a30 100644 --- a/client/src/components/Roadtrip/roadtripModel.test.ts +++ b/client/src/components/Roadtrip/roadtripModel.test.ts @@ -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([]) + }) }) diff --git a/client/src/components/Roadtrip/roadtripModel.ts b/client/src/components/Roadtrip/roadtripModel.ts index fd8ccfc78..b9d408dd4 100644 --- a/client/src/components/Roadtrip/roadtripModel.ts +++ b/client/src/components/Roadtrip/roadtripModel.ts @@ -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 diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts index 69c1e554e..75bb78d04 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts @@ -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. diff --git a/client/src/components/Roadtrip/useVehicleRange.ts b/client/src/components/Roadtrip/useVehicleRange.ts new file mode 100644 index 000000000..742a41409 --- /dev/null +++ b/client/src/components/Roadtrip/useVehicleRange.ts @@ -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]) +} diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 8d0ce48b9..f8989dde7 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -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} diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 5dc36c36e..e5f58c5fe 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -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, diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index f2e9c6408..d9746f80a 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -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) { diff --git a/server/src/nest/assignments/assignments.service.ts b/server/src/nest/assignments/assignments.service.ts index 155469b29..e745aed32 100644 --- a/server/src/nest/assignments/assignments.service.ts +++ b/server/src/nest/assignments/assignments.service.ts @@ -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 diff --git a/server/src/nest/common/rowShape.ts b/server/src/nest/common/rowShape.ts index e48d76733..2c964e8ec 100644 --- a/server/src/nest/common/rowShape.ts +++ b/server/src/nest/common/rowShape.ts @@ -46,6 +46,9 @@ export function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial[] // 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, diff --git a/server/src/nest/days/days.service.ts b/server/src/nest/days/days.service.ts index 70479427a..8a471ab4a 100644 --- a/server/src/nest/days/days.service.ts +++ b/server/src/nest/days/days.service.ts @@ -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 diff --git a/server/src/nest/places/places.mcp.ts b/server/src/nest/places/places.mcp.ts index 03325847b..80b5cf666 100644 --- a/server/src/nest/places/places.mcp.ts +++ b/server/src/nest/places/places.mcp.ts @@ -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 }; } diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts index 730c6342d..c9229212d 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -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, ); diff --git a/server/src/types.ts b/server/src/types.ts index cbb56802c..9ed944821 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -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; diff --git a/server/tests/e2e/assignments.e2e.test.ts b/server/tests/e2e/assignments.e2e.test.ts index 56b4f3b74..339d781c5 100644 --- a/server/tests/e2e/assignments.e2e.test.ts +++ b/server/tests/e2e/assignments.e2e.test.ts @@ -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, diff --git a/server/tests/e2e/days.e2e.test.ts b/server/tests/e2e/days.e2e.test.ts index 99fe187e9..6b2083d9a 100644 --- a/server/tests/e2e/days.e2e.test.ts +++ b/server/tests/e2e/days.e2e.test.ts @@ -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);`); diff --git a/server/tests/e2e/places.e2e.test.ts b/server/tests/e2e/places.e2e.test.ts index 20dabf0fd..a00ab161f 100644 --- a/server/tests/e2e/places.e2e.test.ts +++ b/server/tests/e2e/places.e2e.test.ts @@ -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, diff --git a/server/tests/e2e/roadtrip.e2e.test.ts b/server/tests/e2e/roadtrip.e2e.test.ts index d9351eb7c..5ae8009e4 100644 --- a/server/tests/e2e/roadtrip.e2e.test.ts +++ b/server/tests/e2e/roadtrip.e2e.test.ts @@ -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, diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts index e938bee95..61052d6f3 100644 --- a/shared/src/i18n/ar/roadtrip.ts +++ b/shared/src/i18n/ar/roadtrip.ts @@ -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': 'نوع المحطة', diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts index 9f2070960..34645d09f 100644 --- a/shared/src/i18n/br/roadtrip.ts +++ b/shared/src/i18n/br/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts index 0bb4aadb0..1dbd40f01 100644 --- a/shared/src/i18n/ca/roadtrip.ts +++ b/shared/src/i18n/ca/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts index 805f0e48a..7cec377c0 100644 --- a/shared/src/i18n/cs/roadtrip.ts +++ b/shared/src/i18n/cs/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts index 57ce5e001..c965bb4f4 100644 --- a/shared/src/i18n/de/roadtrip.ts +++ b/shared/src/i18n/de/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts index 90b1c6cd6..69dfeae72 100644 --- a/shared/src/i18n/en/roadtrip.ts +++ b/shared/src/i18n/en/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts index 51ca47b6f..deed3d6db 100644 --- a/shared/src/i18n/es/roadtrip.ts +++ b/shared/src/i18n/es/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts index 3e61f6303..3be9269cb 100644 --- a/shared/src/i18n/fr/roadtrip.ts +++ b/shared/src/i18n/fr/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts index 826161587..cb0b47aca 100644 --- a/shared/src/i18n/gr/roadtrip.ts +++ b/shared/src/i18n/gr/roadtrip.ts @@ -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': 'Είδος στάσης', diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts index 87c9d9a87..97e53731d 100644 --- a/shared/src/i18n/hu/roadtrip.ts +++ b/shared/src/i18n/hu/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts index e72111c13..bfb4069c6 100644 --- a/shared/src/i18n/id/roadtrip.ts +++ b/shared/src/i18n/id/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts index 1a771bc22..54b6c004e 100644 --- a/shared/src/i18n/it/roadtrip.ts +++ b/shared/src/i18n/it/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts index cebaa68d3..3bc49908e 100644 --- a/shared/src/i18n/ja/roadtrip.ts +++ b/shared/src/i18n/ja/roadtrip.ts @@ -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': '立ち寄りの種類', diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts index 16f1a004b..e6e9f420b 100644 --- a/shared/src/i18n/ko/roadtrip.ts +++ b/shared/src/i18n/ko/roadtrip.ts @@ -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': '경유지 종류', diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts index c5a3fe842..9a86638a6 100644 --- a/shared/src/i18n/nl/roadtrip.ts +++ b/shared/src/i18n/nl/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts index 786aa7256..ae8a5ca72 100644 --- a/shared/src/i18n/pl/roadtrip.ts +++ b/shared/src/i18n/pl/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts index bbe047fb4..d84c80206 100644 --- a/shared/src/i18n/ru/roadtrip.ts +++ b/shared/src/i18n/ru/roadtrip.ts @@ -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': 'Тип остановки', diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts index cf2cfabc6..1a7662e7e 100644 --- a/shared/src/i18n/sv/roadtrip.ts +++ b/shared/src/i18n/sv/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts index 1be063470..9ca75533e 100644 --- a/shared/src/i18n/tr/roadtrip.ts +++ b/shared/src/i18n/tr/roadtrip.ts @@ -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ü', diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts index aae2b8754..39467a7db 100644 --- a/shared/src/i18n/uk/roadtrip.ts +++ b/shared/src/i18n/uk/roadtrip.ts @@ -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': 'Тип зупинки', diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts index 15aa0bda2..f56c11f81 100644 --- a/shared/src/i18n/vi/roadtrip.ts +++ b/shared/src/i18n/vi/roadtrip.ts @@ -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', diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts index 04cca91c7..c6e76c0b9 100644 --- a/shared/src/i18n/zh-TW/roadtrip.ts +++ b/shared/src/i18n/zh-TW/roadtrip.ts @@ -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': '停靠類型', diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts index 79a9ca89f..9d8255b69 100644 --- a/shared/src/i18n/zh/roadtrip.ts +++ b/shared/src/i18n/zh/roadtrip.ts @@ -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': '停靠类型', diff --git a/shared/src/place/place.schema.ts b/shared/src/place/place.schema.ts index f067c3d11..f01e90484 100644 --- a/shared/src/place/place.schema.ts +++ b/shared/src/place/place.schema.ts @@ -94,6 +94,14 @@ export type PlaceRatingVote = z.infer; export const roadtripStopTypeSchema = z.enum(['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights']); export type RoadtripStopType = z.infer; +/** + * 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; * 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;