From 96ab748f34ddb30bc36b35dd16ed6925b90ecbfd Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 9 Sep 2026 23:05:35 +0200 Subject: [PATCH] feat(roadtrip): let one stop say how full it fills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/Roadtrip/AnchoredPopover.tsx | 84 ++++++++++ .../components/Roadtrip/RoadtripSidebar.tsx | 148 ++++++++++++++---- .../Roadtrip/StopFillPicker.test.tsx | 112 +++++++++++++ .../components/Roadtrip/StopFillPicker.tsx | 111 +++++++++++++ .../components/Roadtrip/StopKindPicker.tsx | 63 +------- .../components/Roadtrip/roadtripModel.test.ts | 42 +++++ .../src/components/Roadtrip/roadtripModel.ts | 35 ++++- .../components/Roadtrip/useRoadtripRoutes.ts | 50 +++--- .../components/Roadtrip/useVehicleRange.ts | 46 ++++++ client/src/pages/TripPlannerPage.tsx | 2 + .../src/pages/tripPlanner/useTripPlanner.ts | 17 ++ server/src/db/migrations.ts | 20 +++ .../nest/assignments/assignments.service.ts | 4 +- server/src/nest/common/rowShape.ts | 3 + server/src/nest/days/days.service.ts | 4 +- server/src/nest/places/places.mcp.ts | 12 +- server/src/nest/places/places.service.ts | 18 ++- server/src/types.ts | 4 + server/tests/e2e/assignments.e2e.test.ts | 2 +- server/tests/e2e/days.e2e.test.ts | 2 +- server/tests/e2e/places.e2e.test.ts | 2 +- server/tests/e2e/roadtrip.e2e.test.ts | 2 +- shared/src/i18n/ar/roadtrip.ts | 5 + shared/src/i18n/br/roadtrip.ts | 5 + shared/src/i18n/ca/roadtrip.ts | 5 + shared/src/i18n/cs/roadtrip.ts | 5 + shared/src/i18n/de/roadtrip.ts | 5 + shared/src/i18n/en/roadtrip.ts | 5 + shared/src/i18n/es/roadtrip.ts | 5 + shared/src/i18n/fr/roadtrip.ts | 5 + shared/src/i18n/gr/roadtrip.ts | 5 + shared/src/i18n/hu/roadtrip.ts | 5 + shared/src/i18n/id/roadtrip.ts | 5 + shared/src/i18n/it/roadtrip.ts | 5 + shared/src/i18n/ja/roadtrip.ts | 5 + shared/src/i18n/ko/roadtrip.ts | 5 + shared/src/i18n/nl/roadtrip.ts | 5 + shared/src/i18n/pl/roadtrip.ts | 5 + shared/src/i18n/ru/roadtrip.ts | 5 + shared/src/i18n/sv/roadtrip.ts | 5 + shared/src/i18n/tr/roadtrip.ts | 5 + shared/src/i18n/uk/roadtrip.ts | 5 + shared/src/i18n/vi/roadtrip.ts | 5 + shared/src/i18n/zh-TW/roadtrip.ts | 5 + shared/src/i18n/zh/roadtrip.ts | 5 + shared/src/place/place.schema.ts | 29 +++- 46 files changed, 783 insertions(+), 144 deletions(-) create mode 100644 client/src/components/Roadtrip/AnchoredPopover.tsx create mode 100644 client/src/components/Roadtrip/StopFillPicker.test.tsx create mode 100644 client/src/components/Roadtrip/StopFillPicker.tsx create mode 100644 client/src/components/Roadtrip/useVehicleRange.ts 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;