From dfb038d90f11e2e5e5399f8d84e82aeb0f84878b Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 4 Sep 2026 21:59:15 +0200 Subject: [PATCH] feat(roadtrip): say when a drive is longer than you drive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Roel and gfRally asked for three things: how far the car goes on a tank, how long you are willing to sit behind the wheel in one go, and how much driving belongs in a day. They are one feature. All three are a threshold, a walk over the legs, and a finding. Two things they deliberately are not. They are durations and distances rather than times of day, because with no stop pinned to a clock the cascade produces no times at all, for any stop, and a limit on an hour would simply never fire. And the day limit is called driving time, not travel time, because the figure it is measured against is exactly that: the badge beside it, which counts no time spent at stops and no leg to a hotel. The card says so out loud rather than leaving it to be discovered. The range budget carries across midnight, since a tank does not empty overnight, and resets after a finding: without that, 1800 km on a 600 km range flags every leg after the first overrun. Only fuel and charging reset it, never the other four service kinds — a two-hour lunch fills nothing, and nobody here knows whether the restaurant has a charger in its car park. Kilometres in storage, the traveller's own unit on screen. Skipping that round trip means an imperial user types 400 meaning miles, 400 km is stored, and the warnings arrive a third too early from then on. No stop is suggested and no vehicle is modelled. OSM does not know what car this is and neither do we, so the range is a number you type. Furkot does the same, for the same reason. --- .../Roadtrip/RoadtripCorridorPanel.test.tsx | 2 +- .../Roadtrip/RoadtripLimitsCard.tsx | 114 +++++++++++++++++ .../Roadtrip/RoadtripSidebar.test.tsx | 2 +- .../components/Roadtrip/RoadtripSidebar.tsx | 54 ++++++++ .../components/Roadtrip/roadtripModel.test.ts | 95 ++++++++++++++ .../src/components/Roadtrip/roadtripModel.ts | 117 +++++++++++++++++- .../Roadtrip/useRoadtripCorridor.test.ts | 2 +- .../components/Roadtrip/useRoadtripRoutes.ts | 57 ++++++--- client/src/pages/TripPlannerPage.tsx | 7 ++ .../src/pages/tripPlanner/useTripPlanner.ts | 20 ++- client/src/types.ts | 12 ++ shared/src/i18n/ar/roadtrip.ts | 10 ++ shared/src/i18n/br/roadtrip.ts | 10 ++ shared/src/i18n/ca/roadtrip.ts | 10 ++ shared/src/i18n/cs/roadtrip.ts | 10 ++ shared/src/i18n/de/roadtrip.ts | 10 ++ shared/src/i18n/en/roadtrip.ts | 10 ++ shared/src/i18n/es/roadtrip.ts | 10 ++ shared/src/i18n/fr/roadtrip.ts | 10 ++ shared/src/i18n/gr/roadtrip.ts | 10 ++ shared/src/i18n/hu/roadtrip.ts | 10 ++ shared/src/i18n/id/roadtrip.ts | 10 ++ shared/src/i18n/it/roadtrip.ts | 10 ++ shared/src/i18n/ja/roadtrip.ts | 10 ++ shared/src/i18n/ko/roadtrip.ts | 10 ++ shared/src/i18n/nl/roadtrip.ts | 10 ++ shared/src/i18n/pl/roadtrip.ts | 10 ++ shared/src/i18n/ru/roadtrip.ts | 10 ++ shared/src/i18n/sv/roadtrip.ts | 10 ++ shared/src/i18n/tr/roadtrip.ts | 10 ++ shared/src/i18n/uk/roadtrip.ts | 10 ++ shared/src/i18n/vi/roadtrip.ts | 10 ++ shared/src/i18n/zh-TW/roadtrip.ts | 10 ++ shared/src/i18n/zh/roadtrip.ts | 10 ++ 34 files changed, 686 insertions(+), 26 deletions(-) create mode 100644 client/src/components/Roadtrip/RoadtripLimitsCard.tsx diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx index 204e4a064..7cb72d113 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx @@ -30,7 +30,7 @@ const day = (dayId: number, dayNumber: number): RoadtripDay => ({ stops: [stop(dayId * 10 + 1, 'Hamburg'), stop(dayId * 10 + 2, 'Berlin')], legs: [], schedule: { entries: [], warnings: [] }, - legVias: [], + legVias: [], driveWarnings: [], dayWarning: null, geometry: [], distance: 0, duration: 0, diff --git a/client/src/components/Roadtrip/RoadtripLimitsCard.tsx b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx new file mode 100644 index 000000000..44880d13d --- /dev/null +++ b/client/src/components/Roadtrip/RoadtripLimitsCard.tsx @@ -0,0 +1,114 @@ +import React from 'react' +import { Clock, Fuel, CalendarClock } from 'lucide-react' +import { useTranslation } from '../../i18n/TranslationContext' +import { useSettingsStore } from '../../store/settingsStore' +import { convertDistance } from '../../utils/units' +import type { DistanceUnit } from '../../types' +import { FS } from './typeScale' + +/** + * The three numbers that decide when the rail speaks up about the driving. + * + * All three are personal rather than instance configuration: how long somebody is willing + * to sit behind a wheel, and how far their car goes on a tank, belong to the traveller. + * They are stored as plain per-user settings, which is why this needs no migration. + * + * Nothing here suggests a stop, adds one, or knows anything about a vehicle. OSM has no + * idea what car this is, and neither has TREK, so the range is a number the traveller + * types. Furkot does exactly the same, for the same reason. + */ + +/** Empty means no limit, and so does zero — both are stored as 0 and read as off. */ +function parseLimit(raw: string): number { + const n = Number(raw.replace(',', '.')) + return Number.isFinite(n) && n > 0 ? n : 0 +} + +function LimitRow({ icon: Icon, label, suffix, value, placeholder, onChange }: { + icon: typeof Clock + label: string + suffix: string + value: number | undefined + placeholder: string + onChange: (next: number) => void +}): React.ReactElement { + return ( + + ) +} + +export default function RoadtripLimitsCard({ onSave }: { + /** Persists one setting. Absent leaves the card read-only. */ + onSave?: (key: string, value: number) => void +}): React.ReactElement { + const { t } = useTranslation() + const settings = useSettingsStore(s => s.settings) + const distanceUnit: DistanceUnit = settings.distance_unit === 'imperial' ? 'imperial' : 'metric' + + // Kilometres in storage, the traveller's own unit on screen. Without the round trip an + // imperial user types 400 meaning miles, 400 km gets stored, and the warnings arrive a + // third too early for ever after. + const rangeShown = settings.roadtrip_range_km + ? Math.round(convertDistance(settings.roadtrip_range_km, distanceUnit)) + : undefined + const setRange = (shown: number) => { + const km = distanceUnit === 'imperial' ? shown / 0.621371 : shown + onSave?.('roadtrip_range_km', Math.round(km)) + } + + return ( +
+

+ {t('roadtrip.limit.title')} +

+
+ onSave?.('roadtrip_leg_minutes', v)} + /> + onSave?.('roadtrip_day_minutes', v)} + /> + +
+ {/* Said out loud rather than left to be discovered: the day figure this is measured + against is driving time only. It does not include how long anyone stands still, + and it does not include the leg to the hotel, because the rail does not model + one. A limit called "travel time per day" over that number would be a lie that + looks tidy. */} +

+ {t('roadtrip.limit.hint')} +

+
+ ) +} diff --git a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx index ad0b3b65a..f8b6ed0d6 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx @@ -38,7 +38,7 @@ function day(over: Partial = {}): RoadtripDay { title: null, stops, legs: [leg()], - legVias: [], + legVias: [], driveWarnings: [], dayWarning: null, schedule: { entries: stops.map(() => ({ arrival: null, departure: null, anchored: false, dayOffset: 0 })), warnings: [] }, geometry: [], distance: 100000, diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx index efbfa73d4..a0427e0fa 100644 --- a/client/src/components/Roadtrip/RoadtripSidebar.tsx +++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx @@ -422,6 +422,42 @@ function RouteViaStop({ via }: { via: RouteVia }): React.ReactElement { ) } +/** + * A finding about the drive rather than about a stop: too long at the wheel in one go, or + * the tank running out before anywhere to fill it. + * + * Sits in the chain between the two stops the drive runs between, because that is where + * it happens. Neither finding names a time of day: with no stop pinned to a clock the + * cascade produces no times at all, so both are durations and distances, which exist as + * soon as the leg has routed. + */ +function DriveFinding({ warning }: { warning: ScheduleWarning }): React.ReactElement | null { + const { t } = useTranslation() + const distanceUnit = useSettingsStore(s => s.settings.distance_unit) + const text = warning.code === 'leg' + ? t('roadtrip.limit.legOver', { time: formatDurationShort((warning.overMinutes ?? 0) * 60) }) + : warning.code === 'range' + ? t('roadtrip.limit.range', { distance: formatDistance(warning.sinceKm ?? 0, distanceUnit) }) + : null + if (!text) return null + return ( +
+ + + + + + + {text} + + +
+ ) +} + /** * Midnight, marked where it happens — in the chain, between the two stops the drive runs * between, rather than as a fourth badge hanging off one of them. @@ -647,6 +683,18 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on time: formatDurationShort(day.duration), })} + {day.dayWarning ? ( + + + {t('roadtrip.limit.dayOver', { + time: formatDurationShort((day.dayWarning.minutes - day.dayWarning.limitMinutes) * 60), + })} + + ) : null} {t('roadtrip.day.stopCount', { count: day.stops.filter(s => !isServiceStopType(s.stopType)).length })} @@ -725,6 +773,12 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on alternativesOpen={openAlternatives?.dayId === day.dayId && openAlternatives.index === i} /> ) : null} + {/* Between the two stops, which is where "somewhere on this drive" actually + is. The range finding is the only one with its remedy right beside it: + fuel and charging are the corridor search's first two categories. */} + {i < last ? day.driveWarnings.filter(w => w.index === i).map(w => ( + + )) : null} {/* After the band, because a plugin halt happens on the drive it describes rather than before setting off. */} {i < last ? (day.legVias[i] ?? []).map((via, vi) => ( diff --git a/client/src/components/Roadtrip/roadtripModel.test.ts b/client/src/components/Roadtrip/roadtripModel.test.ts index df276c3c5..b7834b132 100644 --- a/client/src/components/Roadtrip/roadtripModel.test.ts +++ b/client/src/components/Roadtrip/roadtripModel.test.ts @@ -4,7 +4,9 @@ import { formatClock, formatDurationShort, insertIndexForAlong, + deriveDriveWarnings, legIndexForAlong, + refuelsRange, parseClock, splitIntoRuns, sumLegSeconds, @@ -276,3 +278,96 @@ describe('legIndexForAlong', () => { expect(legIndexForAlong([], 100)).toBe(-1) }) }) + +describe('deriveDriveWarnings', () => { + const noLimits = { legMinutes: null, dayMinutes: null, rangeKm: null } + const leg = (minutes: number, km: number) => ({ duration: minutes * 60, distance: km * 1000 }) + + it('FE-ROADTRIP-MODEL-060: no limits set means nothing to report', () => { + const out = deriveDriveWarnings([leg(600, 900)], [false, false], noLimits, 0) + expect(out.warnings).toEqual([]) + expect(out.day).toBeNull() + }) + + it('FE-ROADTRIP-MODEL-061: a leg over the limit reports how far over, on the stop it leaves', () => { + const out = deriveDriveWarnings( + [leg(100, 90), leg(240, 200)], + [false, false, false], + { ...noLimits, legMinutes: 180 }, + 0, + ) + expect(out.warnings).toEqual([{ index: 1, code: 'leg', overMinutes: 60 }]) + }) + + it('FE-ROADTRIP-MODEL-062: the day figure is the sum of the legs, and only that', () => { + const out = deriveDriveWarnings( + [leg(200, 180), leg(200, 180)], + [false, false, false], + { ...noLimits, dayMinutes: 360 }, + 0, + ) + expect(out.day).toEqual({ code: 'dayDriving', minutes: 400, limitMinutes: 360 }) + }) + + it('FE-ROADTRIP-MODEL-063: the range runs out once and then starts over', () => { + // Without the reset, a 1800 km trip on a 600 km range flags every leg after the first + // overrun, which is a column of red nobody reads. With it the marks stand one range + // apart and each means "fill up around here". + const out = deriveDriveWarnings( + [leg(60, 300), leg(60, 400), leg(60, 300), leg(60, 400)], + [false, false, false, false, false], + { ...noLimits, rangeKm: 600 }, + 0, + ) + expect(out.warnings.map(w => w.code)).toEqual(['range', 'range']) + expect(out.warnings[0]).toEqual({ index: 1, code: 'range', sinceKm: 700 }) + }) + + it('FE-ROADTRIP-MODEL-064: filling up starts the budget over, resting does not', () => { + const stops = [false, true, false] + const out = deriveDriveWarnings( + [leg(60, 500), leg(60, 500)], + stops, + { ...noLimits, rangeKm: 600 }, + 0, + ) + // The tank is filled at stop 1, so the second 500 km starts from zero and neither leg + // trips the limit. Without the refuel it would be 1000 km on a 600 km range. + expect(out.warnings).toEqual([]) + }) + + it('FE-ROADTRIP-MODEL-065: only fuel and charging refuel', () => { + expect(refuelsRange('fuel')).toBe(true) + expect(refuelsRange('charging')).toBe(true) + // A two-hour lunch fills no tank, and TREK does not know whether the restaurant has a + // charger in its car park. + expect(refuelsRange('restaurant')).toBe(false) + expect(refuelsRange('rest_area')).toBe(false) + expect(refuelsRange('campsite')).toBe(false) + expect(refuelsRange(null)).toBe(false) + }) + + it('FE-ROADTRIP-MODEL-066: the budget carries into the next day, because a tank does not empty overnight', () => { + const first = deriveDriveWarnings([leg(60, 400)], [false, false], { ...noLimits, rangeKm: 600 }, 0) + expect(first.carryKm).toBe(400) + + const second = deriveDriveWarnings([leg(60, 300)], [false, false], { ...noLimits, rangeKm: 600 }, first.carryKm) + expect(second.warnings).toEqual([{ index: 0, code: 'range', sinceKm: 700 }]) + }) + + it('FE-ROADTRIP-MODEL-067: an unrouted leg gives the budget up rather than guessing', () => { + const out = deriveDriveWarnings( + [undefined, leg(60, 500)], + [false, false, false], + { ...noLimits, rangeKm: 100 }, + 0, + ) + expect(out.warnings).toEqual([]) + expect(out.carryKm).toBeNull() + }) + + it('FE-ROADTRIP-MODEL-068: filling up at the last stop of the day still counts', () => { + const out = deriveDriveWarnings([leg(60, 400)], [false, true], { ...noLimits, rangeKm: 600 }, 0) + expect(out.carryKm).toBe(0) + }) +}) diff --git a/client/src/components/Roadtrip/roadtripModel.ts b/client/src/components/Roadtrip/roadtripModel.ts index f7554fad6..19ea12af0 100644 --- a/client/src/components/Roadtrip/roadtripModel.ts +++ b/client/src/components/Roadtrip/roadtripModel.ts @@ -91,11 +91,62 @@ export interface ScheduleStop { } export interface ScheduleWarning { - /** Index of the stop the finding belongs to. */ + /** + * Index of the stop the finding belongs to. + * + * For the two leg codes this is the stop the leg LEAVES, which is the same numbering + * `legSeconds[i]` uses and the same row the drive band is already drawn on. + */ index: number - code: 'late' | 'overnight' + code: 'late' | 'overnight' | 'leg' | 'range' /** Minutes the computed arrival misses the anchor by (code 'late'). */ minutes?: number + /** Minutes this leg runs over the longest single drive allowed (code 'leg'). */ + overMinutes?: number + /** Kilometres driven since the last fill-up when the range ran out (code 'range'). */ + sinceKm?: number +} + +/** + * The two kinds of stop that put fuel or charge back. + * + * Deliberately shorter than `SERVICE_STOP_TYPES`: a two-hour lunch fills no tank, and + * TREK knows nothing about whether the restaurant has a charger in its car park. Reusing + * the service list here would quietly reset the range budget at every rest area and hide + * the one finding this feature exists to produce. + */ +export const REFUELLING_STOP_TYPES = ['fuel', 'charging'] as const + +/** Whether stopping here starts the range budget over. */ +export function refuelsRange(stopType: string | null | undefined): boolean { + return (REFUELLING_STOP_TYPES as readonly string[]).includes(stopType ?? '') +} + +/** + * The three numbers a traveller can set. null means no limit, which is an explicit off + * rather than a sentinel: zero is a legal thing to type and means the same as unset. + */ +export interface DriveLimits { + /** Longest single drive between two stops, in minutes. */ + legMinutes: number | null + /** Longest total driving in one day, in minutes. */ + dayMinutes: number | null + /** How far one tank or charge goes, in kilometres. */ + rangeKm: number | null +} + +/** + * A finding about the day as a whole. + * + * Its own shape rather than a ScheduleWarning with index -1: a sentinel index would be + * read by every consumer that filters findings by stop, and one of them would eventually + * draw the day's finding on the first stop. + */ +export interface DayWarning { + code: 'dayDriving' + /** Driving time of the day in minutes, the same number the badge beside it shows. */ + minutes: number + limitMinutes: number } export interface ScheduleEntry { @@ -240,6 +291,68 @@ export function sumLegSeconds(legSeconds: (number | undefined)[]): number { * town. A point past the end lands in the last leg rather than nowhere, because that is * a rounding edge and not a missing leg. */ +/** + * The findings about the driving itself: too long at the wheel, too long in one day, and + * the tank running out before anywhere to fill it. + * + * Reads the legs and nothing else. The clock cascade is not available to lean on: with no + * stop pinned to a time it produces no times at all, for any stop, so a limit expressed as + * an hour of the day would simply never fire. All three limits are therefore durations and + * kilometres, which exist as soon as a leg has routed. + * + * The range budget carries across midnight, because a tank does not empty overnight, and + * resets to zero after a finding: without that reset, one trip of 1800 km on a 600 km + * range flags every leg after the first overrun, which is a column of red nobody reads. + * With it, the marks stand one range apart and each one means "fill up around here". + * + * An unrouted leg gives the budget up entirely rather than guessing, the same way the + * schedule abandons its cursor: a distance we do not have cannot be added to one we do. + */ +export function deriveDriveWarnings( + legs: ({ duration?: number; distance?: number } | undefined)[], + /** Whether the stop at each index refuels. One entry longer than `legs`. */ + refuelsAt: boolean[], + limits: DriveLimits, + /** Kilometres already on the tank when the day starts; null when that is unknown. */ + carryKm: number | null, +): { warnings: ScheduleWarning[]; day: DayWarning | null; carryKm: number | null } { + const warnings: ScheduleWarning[] = [] + let budget = carryKm + let totalSeconds = 0 + + for (let i = 0; i < legs.length; i++) { + if (refuelsAt[i]) budget = 0 + const leg = legs[i] + const seconds = leg?.duration + if (typeof seconds === 'number') { + totalSeconds += seconds + if (limits.legMinutes && seconds / 60 > limits.legMinutes) { + warnings.push({ index: i, code: 'leg', overMinutes: Math.round(seconds / 60 - limits.legMinutes) }) + } + } + const metres = leg?.distance + if (typeof metres !== 'number') { + budget = null + } else if (budget !== null) { + budget += metres / 1000 + if (limits.rangeKm && budget > limits.rangeKm) { + warnings.push({ index: i, code: 'range', sinceKm: Math.round(budget) }) + budget = 0 + } + } + } + // 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 = 0 + + const minutes = Math.round(totalSeconds / 60) + const day = limits.dayMinutes && minutes > limits.dayMinutes + ? { code: 'dayDriving' as const, minutes, limitMinutes: limits.dayMinutes } + : null + + return { warnings, day, carryKm: budget } +} + export function legIndexForAlong(legEndMeters: number[], alongMeters: number): number { if (!legEndMeters.length) return -1 for (let i = 0; i < legEndMeters.length; i++) { diff --git a/client/src/components/Roadtrip/useRoadtripCorridor.test.ts b/client/src/components/Roadtrip/useRoadtripCorridor.test.ts index 5100f28dc..7fc157bc8 100644 --- a/client/src/components/Roadtrip/useRoadtripCorridor.test.ts +++ b/client/src/components/Roadtrip/useRoadtripCorridor.test.ts @@ -24,7 +24,7 @@ function day(over: Partial = {}): RoadtripDay { { assignmentId: 1, placeId: 10, name: 'A', lat: 53.5, lng: 9.9, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null }, { assignmentId: 2, placeId: 20, name: 'B', lat: 52.5, lng: 13.4, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null }, ], - legs: [], legVias: [], schedule: { entries: [], warnings: [] }, + legs: [], legVias: [], driveWarnings: [], dayWarning: null, schedule: { entries: [], warnings: [] }, geometry: [[53.5, 9.9], [53.0, 11.0], [52.5, 13.4]], distance: 0, duration: 0, ...over, diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts index ffbf9285f..1edf6ca79 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts @@ -1,7 +1,10 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { calculateRouteWithLegs, RoutingRefusedError } from '../Map/RouteCalculator' import { resolveLegMode } from '../Planner/legMode' -import { computeSchedule, isServiceStopType, legIndexForAlong, splitIntoRuns, type Schedule } from './roadtripModel' +import { + computeSchedule, deriveDriveWarnings, isServiceStopType, legIndexForAlong, refuelsRange, splitIntoRuns, + type DayWarning, type DriveLimits, type Schedule, type ScheduleWarning, +} from './roadtripModel' import { projectOntoRoute } from './corridor' import { useSettingsStore } from '../../store/settingsStore' import type { Assignment, AssignmentsMap, Day, RouteSegment, RouteVia, SnappedWaypoint } from '../../types' @@ -65,9 +68,9 @@ export interface RoadtripDay { * findings about a stop, because these belong to no stop and because `Schedule` is * pinned shape-for-shape by its own tests. */ - driveWarnings?: DriveWarning[] - /** A finding about the day as a whole, such as more driving than the day allows. */ - dayWarning?: DriveWarning | null + driveWarnings: ScheduleWarning[] + /** More driving in this day than the traveller allows, or null. */ + dayWarning: DayWarning | null } /** A place, the road it is driven to from, and how far apart the two are. */ @@ -78,15 +81,6 @@ export interface AccessSpur { stopKey: string } -/** A finding about the drive rather than about a stop. */ -export interface DriveWarning { - /** Which leg it belongs to; -1 means the day as a whole. */ - legIndex: number - code: 'legTooLong' | 'dayTooLong' | 'outOfRange' - /** By how much, in the unit the code implies: minutes for the first two, km for range. */ - over?: number -} - /** A day the rail does not draw a drive for, but that a stop can still be moved onto. */ export interface QuietDay { dayId: number @@ -256,6 +250,16 @@ export function useRoadtripRoutes( const [loading, setLoading] = useState(false) const abortRef = useRef(null) + 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) + // 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. + const limits = useMemo( + () => ({ legMinutes: legMinutes || null, dayMinutes: dayMinutes || null, rangeKm: rangeKm || null }), + [legMinutes, dayMinutes, rangeKm], + ) + const plan = useMemo(() => { return [...days] .sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0)) @@ -273,6 +277,8 @@ export function useRoadtripRoutes( stops, legs: [], legVias: [], + driveWarnings: [], + dayWarning: null, schedule: { entries: [], warnings: [] }, geometry: [], distance: 0, @@ -466,11 +472,11 @@ export function useRoadtripRoutes( const lines: [number, number][][] = [] const segments: RouteSegment[] = [] const accessLines: RoadtripRoutes['accessLines'] = [] - // A loop rather than a map: the days are walked in order and findings that outlive a - // single day (a range budget carried across midnight) need somewhere to live between - // iterations. Nothing accumulates yet; the shape is here so the feature that needs it - // does not have to rewrite this block a second time. + // A loop rather than a map, because the range budget outlives a single day: a tank + // does not empty overnight, so what is left at the end of one day is what the next + // one starts with. `plan` is already in day order. const out: RoadtripDay[] = [] + let carryKm: number | null = 0 for (const day of plan) { const dayLegs = legsByDay[day.dayId] ?? {} const daySnaps = snapByDay[day.dayId] ?? {} @@ -497,7 +503,20 @@ export function useRoadtripRoutes( if (line) accessLines.push({ line, meters: snap.meters, stopKey: stopKey(s) }) return { ...s, offRoadMeters: line ? snap.meters : null } }) - out.push({ ...day, stops, legs, legVias, schedule, geometry, distance, duration }) + const drive = deriveDriveWarnings( + legs, + // One longer than the legs: the last stop of the day counts too, because filling + // up on arrival is what makes the next morning start full. + stops.map(s => refuelsRange(s.stopType)), + limits, + carryKm, + ) + carryKm = drive.carryKm + out.push({ + ...day, stops, legs, legVias, schedule, geometry, distance, duration, + driveWarnings: drive.warnings, + dayWarning: drive.day, + }) } return { days: out, @@ -513,5 +532,5 @@ export function useRoadtripRoutes( quietDays, loading, } - }, [plan, legsByDay, snapByDay, loading, quietDays]) + }, [plan, legsByDay, snapByDay, loading, quietDays, limits]) } diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index cf8d7a70d..b326375fc 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -69,6 +69,7 @@ const ExpenseModal = lazyWithRetry(() => const CollabPanel = lazyWithRetry(() => import('../components/Collab/CollabPanel')) const RoadtripSidebar = lazyWithRetry(() => import('../components/Roadtrip/RoadtripSidebar')) const RoadtripCorridorPanel = lazyWithRetry(() => import('../components/Roadtrip/RoadtripCorridorPanel')) +const RoadtripLimitsCard = lazyWithRetry(() => import('../components/Roadtrip/RoadtripLimitsCard')) const RoadtripStopPopup = lazyWithRetry(() => import('../components/Roadtrip/RoadtripStopPopup')) const RoadtripStayModal = lazyWithRetry(() => import('../components/Roadtrip/RoadtripStayModal')) const RoadtripAlternativesBar = lazyWithRetry(() => import('../components/Roadtrip/RoadtripAlternativesBar')) @@ -260,6 +261,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace, prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId, stopDraft, setStopDraft, saveStopDraft, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop, + saveRoadtripLimit, roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, stayDraft, setStayDraft, setRoadtripStay, @@ -649,6 +651,11 @@ function TripPlannerPageDesktop(): React.ReactElement | null { routes={roadtripRoutes} onAddPoi={can('place_edit', trip) ? handlePoiClick : undefined} /> + {/* Under the search, because the limits are read while looking at + what the drive is doing rather than set up front. */} +
+ +
) : ( s.plugins) const pluginsLoaded = usePluginStore(s => s.loaded) @@ -715,6 +715,21 @@ export function useTripPlanner() { * fuel — the road-trip kinds are their own dimension, deliberately not one of the * traveller's editable categories, so the palette and the meaning stay put. */ + /** + * Stores one of the three driving limits. + * + * Straight to the settings store rather than through the offline queue: these are + * per-user preferences on the settings table, the same path the map provider and the + * distance unit take, and they are read locally the moment they change. + */ + const saveRoadtripLimit = useCallback(async (key: string, value: number) => { + try { + await updateSettings({ [key]: value } as Partial) + } catch { + toast.error(t('settings.saveFailed')) + } + }, [updateSettings, toast, t]) + const saveStopDraft = useCallback(async ({ stopType, dwellMinutes }: { stopType: RoadtripStopType | null; dwellMinutes: number }) => { if (!stopDraft) return const { poi, dayId, position } = stopDraft @@ -1600,6 +1615,7 @@ export function useTripPlanner() { prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId, placeFormDayId, setPlaceFormDayId, reservationModalDayId, setReservationModalDayId, stopDraft, setStopDraft, saveStopDraft, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop, + saveRoadtripLimit, roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, stayDraft, setStayDraft, setRoadtripStay, diff --git a/client/src/types.ts b/client/src/types.ts index e1f0b9b44..695ce3ebb 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -129,6 +129,18 @@ export interface Settings { map_provider?: 'leaflet' | 'mapbox-gl' | 'maplibre-gl' /** Leaflet base layer: default street tiles or a satellite/aerial view. */ map_base_layer?: 'default' | 'satellite' + /** + * The three road-trip driving limits (#1797). All three are personal rather than + * instance configuration — how long you are willing to drive and how far your car goes + * are properties of the traveller, so they are plain per-user settings and stay out of + * the defaultable list. + * + * Stored as numbers; 0 or absent means no limit. Kilometres are always kilometres in + * storage and converted for display, the same rule the corridor widths follow. + */ + roadtrip_leg_minutes?: number + roadtrip_day_minutes?: number + roadtrip_range_km?: number /** CARTO basemaps watermark keyless tiles; the key is appended as ?key= (#2054). */ carto_api_key?: string mapbox_access_token?: string diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts index dfc9ce058..077d0bbba 100644 --- a/shared/src/i18n/ar/roadtrip.ts +++ b/shared/src/i18n/ar/roadtrip.ts @@ -19,6 +19,16 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'شحن', 'roadtrip.stop.offRoad': '{distance} من الطريق', 'roadtrip.via.plugin': 'توقف في الطريق', + 'roadtrip.limit.title': 'حدود القيادة', + 'roadtrip.limit.legLabel': 'أطول قيادة متواصلة', + 'roadtrip.limit.dayLabel': 'القيادة في اليوم', + 'roadtrip.limit.rangeLabel': 'المدى بخزان واحد', + 'roadtrip.limit.minutes': 'دقيقة', + 'roadtrip.limit.off': 'معطل', + 'roadtrip.limit.hint': 'يحسب وقت القيادة فقط. لا يشمل التوقفات والمبيت.', + 'roadtrip.limit.legOver': '{time} فوق أطول قيادة لديك', + 'roadtrip.limit.dayOver': '{time} زيادة', + 'roadtrip.limit.range': '{distance} منذ آخر تعبئة', 'roadtrip.poi.rest': 'استراحة', 'roadtrip.poi.campsite': 'مخيم', 'roadtrip.poi.food': 'طعام', diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts index 2f7535d97..e9ba6e53a 100644 --- a/shared/src/i18n/br/roadtrip.ts +++ b/shared/src/i18n/br/roadtrip.ts @@ -19,6 +19,16 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Carregamento', 'roadtrip.stop.offRoad': '{distance} da estrada', 'roadtrip.via.plugin': 'Parada no caminho', + 'roadtrip.limit.title': 'Limites de direção', + 'roadtrip.limit.legLabel': 'Trecho mais longo seguido', + 'roadtrip.limit.dayLabel': 'Condução por dia', + 'roadtrip.limit.rangeLabel': 'Autonomia por tanque', + 'roadtrip.limit.minutes': 'min', + 'roadtrip.limit.off': 'desligado', + 'roadtrip.limit.hint': 'Conta apenas o tempo de condução. Paradas e pernoites não estão incluídos.', + 'roadtrip.limit.legOver': '{time} acima do seu trecho máximo', + 'roadtrip.limit.dayOver': '{time} a mais', + 'roadtrip.limit.range': '{distance} desde o último abastecimento', 'roadtrip.poi.rest': 'Área de descanso', 'roadtrip.poi.campsite': 'Camping', 'roadtrip.poi.food': 'Comida', diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts index a8b081f9d..9305c4555 100644 --- a/shared/src/i18n/ca/roadtrip.ts +++ b/shared/src/i18n/ca/roadtrip.ts @@ -19,6 +19,16 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Càrrega', 'roadtrip.stop.offRoad': '{distance} des de la carretera', 'roadtrip.via.plugin': 'Parada al camí', + 'roadtrip.limit.title': 'Límits de conducció', + 'roadtrip.limit.legLabel': 'Trajecte més llarg seguit', + 'roadtrip.limit.dayLabel': 'Conducció per dia', + 'roadtrip.limit.rangeLabel': 'Autonomia per dipòsit', + 'roadtrip.limit.minutes': 'min', + 'roadtrip.limit.off': 'desactivat', + 'roadtrip.limit.hint': 'Només compta el temps de conducció. Les parades i les nits no hi són incloses.', + 'roadtrip.limit.legOver': '{time} per sobre del teu trajecte màxim', + 'roadtrip.limit.dayOver': '{time} de més', + 'roadtrip.limit.range': '{distance} des de l’última càrrega', 'roadtrip.poi.rest': 'Àrea de descans', 'roadtrip.poi.campsite': 'Càmping', 'roadtrip.poi.food': 'Menjar', diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts index ad77da054..ff3e0d510 100644 --- a/shared/src/i18n/cs/roadtrip.ts +++ b/shared/src/i18n/cs/roadtrip.ts @@ -19,6 +19,16 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Nabíjení', 'roadtrip.stop.offRoad': '{distance} od silnice', 'roadtrip.via.plugin': 'Zastávka po cestě', + 'roadtrip.limit.title': 'Limity jízdy', + 'roadtrip.limit.legLabel': 'Nejdelší jízda v kuse', + 'roadtrip.limit.dayLabel': 'Jízda za den', + 'roadtrip.limit.rangeLabel': 'Dojezd na jednu nádrž', + 'roadtrip.limit.minutes': 'min', + 'roadtrip.limit.off': 'vypnuto', + 'roadtrip.limit.hint': 'Počítá jen čas jízdy. Zastávky a noclehy nejsou zahrnuty.', + 'roadtrip.limit.legOver': 'o {time} déle než vaše nejdelší jízda', + 'roadtrip.limit.dayOver': 'o {time} více', + 'roadtrip.limit.range': '{distance} od posledního tankování', 'roadtrip.poi.rest': 'Odpočívadlo', 'roadtrip.poi.campsite': 'Kemp', 'roadtrip.poi.food': 'Jídlo', diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts index 5793d3065..e64a54837 100644 --- a/shared/src/i18n/de/roadtrip.ts +++ b/shared/src/i18n/de/roadtrip.ts @@ -19,6 +19,16 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Laden', 'roadtrip.stop.offRoad': '{distance} von der Straße', 'roadtrip.via.plugin': 'Halt unterwegs', + 'roadtrip.limit.title': 'Fahrgrenzen', + 'roadtrip.limit.legLabel': 'Längste Fahrt am Stück', + 'roadtrip.limit.dayLabel': 'Fahrzeit pro Tag', + 'roadtrip.limit.rangeLabel': 'Reichweite pro Tankfüllung', + 'roadtrip.limit.minutes': 'Min', + 'roadtrip.limit.off': 'aus', + 'roadtrip.limit.hint': 'Zählt nur Fahrzeit. Standzeiten und Übernachtungen sind nicht enthalten.', + 'roadtrip.limit.legOver': '{time} über deiner längsten Fahrt', + 'roadtrip.limit.dayOver': '{time} drüber', + 'roadtrip.limit.range': '{distance} seit dem letzten Tanken', 'roadtrip.poi.rest': 'Rastplatz', 'roadtrip.poi.campsite': 'Campingplatz', 'roadtrip.poi.food': 'Essen', diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts index d2e19d57f..f20e343de 100644 --- a/shared/src/i18n/en/roadtrip.ts +++ b/shared/src/i18n/en/roadtrip.ts @@ -19,6 +19,16 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Charging', 'roadtrip.stop.offRoad': '{distance} from the road', 'roadtrip.via.plugin': 'Stop on the way', + 'roadtrip.limit.title': 'Driving limits', + 'roadtrip.limit.legLabel': 'Longest drive at once', + 'roadtrip.limit.dayLabel': 'Driving per day', + 'roadtrip.limit.rangeLabel': 'Range on one tank', + 'roadtrip.limit.minutes': 'min', + 'roadtrip.limit.off': 'off', + 'roadtrip.limit.hint': 'Counts driving time only. Time spent at stops and overnight legs are not included.', + 'roadtrip.limit.legOver': '{time} over your longest drive', + 'roadtrip.limit.dayOver': '{time} over', + 'roadtrip.limit.range': '{distance} since the last fill-up', 'roadtrip.poi.rest': 'Rest area', 'roadtrip.poi.campsite': 'Campsite', 'roadtrip.poi.food': 'Food', diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts index 38634e9bc..a126b1785 100644 --- a/shared/src/i18n/es/roadtrip.ts +++ b/shared/src/i18n/es/roadtrip.ts @@ -19,6 +19,16 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Recarga', 'roadtrip.stop.offRoad': '{distance} desde la carretera', 'roadtrip.via.plugin': 'Parada en el camino', + 'roadtrip.limit.title': 'Límites de conducción', + 'roadtrip.limit.legLabel': 'Trayecto más largo seguido', + 'roadtrip.limit.dayLabel': 'Conducción por día', + 'roadtrip.limit.rangeLabel': 'Autonomía por depósito', + 'roadtrip.limit.minutes': 'min', + 'roadtrip.limit.off': 'desactivado', + 'roadtrip.limit.hint': 'Solo cuenta el tiempo de conducción. Las paradas y las noches no se incluyen.', + 'roadtrip.limit.legOver': '{time} más que tu trayecto máximo', + 'roadtrip.limit.dayOver': '{time} de más', + 'roadtrip.limit.range': '{distance} desde el último repostaje', 'roadtrip.poi.rest': 'Área de descanso', 'roadtrip.poi.campsite': 'Camping', 'roadtrip.poi.food': 'Comida', diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts index 09b7be8f5..2ea5f56ef 100644 --- a/shared/src/i18n/fr/roadtrip.ts +++ b/shared/src/i18n/fr/roadtrip.ts @@ -19,6 +19,16 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Recharge', 'roadtrip.stop.offRoad': '{distance} de la route', 'roadtrip.via.plugin': 'Arrêt en chemin', + 'roadtrip.limit.title': 'Limites de conduite', + 'roadtrip.limit.legLabel': 'Plus long trajet d’affilée', + 'roadtrip.limit.dayLabel': 'Conduite par jour', + 'roadtrip.limit.rangeLabel': 'Autonomie par plein', + 'roadtrip.limit.minutes': 'min', + 'roadtrip.limit.off': 'désactivé', + 'roadtrip.limit.hint': 'Compte uniquement le temps de conduite. Les arrêts et les nuits ne sont pas inclus.', + 'roadtrip.limit.legOver': '{time} de plus que votre trajet maximal', + 'roadtrip.limit.dayOver': '{time} de trop', + 'roadtrip.limit.range': '{distance} depuis le dernier plein', 'roadtrip.poi.rest': 'Aire de repos', 'roadtrip.poi.campsite': 'Camping', 'roadtrip.poi.food': 'Restauration', diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts index f1b847474..b57a6d7c9 100644 --- a/shared/src/i18n/gr/roadtrip.ts +++ b/shared/src/i18n/gr/roadtrip.ts @@ -19,6 +19,16 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Φόρτιση', 'roadtrip.stop.offRoad': '{distance} από τον δρόμο', 'roadtrip.via.plugin': 'Στάση στον δρόμο', + 'roadtrip.limit.title': 'Όρια οδήγησης', + 'roadtrip.limit.legLabel': 'Μεγαλύτερη συνεχής οδήγηση', + 'roadtrip.limit.dayLabel': 'Οδήγηση ανά ημέρα', + 'roadtrip.limit.rangeLabel': 'Αυτονομία με ένα γέμισμα', + 'roadtrip.limit.minutes': 'λεπ', + 'roadtrip.limit.off': 'ανενεργό', + 'roadtrip.limit.hint': 'Μετρά μόνο τον χρόνο οδήγησης. Οι στάσεις και οι διανυκτερεύσεις δεν περιλαμβάνονται.', + 'roadtrip.limit.legOver': '{time} πάνω από τη μέγιστη οδήγησή σας', + 'roadtrip.limit.dayOver': '{time} πάνω', + 'roadtrip.limit.range': '{distance} από τον τελευταίο ανεφοδιασμό', 'roadtrip.poi.rest': 'Χώρος ανάπαυσης', 'roadtrip.poi.campsite': 'Κάμπινγκ', 'roadtrip.poi.food': 'Φαγητό', diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts index a34b6453a..ccce516ae 100644 --- a/shared/src/i18n/hu/roadtrip.ts +++ b/shared/src/i18n/hu/roadtrip.ts @@ -19,6 +19,16 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Töltés', 'roadtrip.stop.offRoad': '{distance} az úttól', 'roadtrip.via.plugin': 'Megálló útközben', + 'roadtrip.limit.title': 'Vezetési korlátok', + 'roadtrip.limit.legLabel': 'Leghosszabb egyhuzamban vezetés', + 'roadtrip.limit.dayLabel': 'Vezetés naponta', + 'roadtrip.limit.rangeLabel': 'Hatótáv egy tankkal', + 'roadtrip.limit.minutes': 'perc', + 'roadtrip.limit.off': 'ki', + 'roadtrip.limit.hint': 'Csak a vezetési időt számolja. A megállók és az éjszakázások nincsenek benne.', + 'roadtrip.limit.legOver': '{time} a leghosszabb vezetésed felett', + 'roadtrip.limit.dayOver': '{time} túl', + 'roadtrip.limit.range': '{distance} az utolsó tankolás óta', 'roadtrip.poi.rest': 'Pihenőhely', 'roadtrip.poi.campsite': 'Kemping', 'roadtrip.poi.food': 'Étkezés', diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts index feae282f5..3d5512514 100644 --- a/shared/src/i18n/id/roadtrip.ts +++ b/shared/src/i18n/id/roadtrip.ts @@ -19,6 +19,16 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Pengisian daya', 'roadtrip.stop.offRoad': '{distance} dari jalan', 'roadtrip.via.plugin': 'Berhenti di jalan', + 'roadtrip.limit.title': 'Batas mengemudi', + 'roadtrip.limit.legLabel': 'Perjalanan terlama sekali jalan', + 'roadtrip.limit.dayLabel': 'Mengemudi per hari', + 'roadtrip.limit.rangeLabel': 'Jarak per tangki', + 'roadtrip.limit.minutes': 'mnt', + 'roadtrip.limit.off': 'mati', + 'roadtrip.limit.hint': 'Hanya menghitung waktu mengemudi. Waktu berhenti dan menginap tidak termasuk.', + 'roadtrip.limit.legOver': '{time} lebih dari perjalanan terlama', + 'roadtrip.limit.dayOver': 'lebih {time}', + 'roadtrip.limit.range': '{distance} sejak pengisian terakhir', 'roadtrip.poi.rest': 'Tempat istirahat', 'roadtrip.poi.campsite': 'Perkemahan', 'roadtrip.poi.food': 'Makanan', diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts index 20a08b33f..4b6d7887d 100644 --- a/shared/src/i18n/it/roadtrip.ts +++ b/shared/src/i18n/it/roadtrip.ts @@ -19,6 +19,16 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Ricarica', 'roadtrip.stop.offRoad': '{distance} dalla strada', 'roadtrip.via.plugin': 'Sosta lungo il percorso', + 'roadtrip.limit.title': 'Limiti di guida', + 'roadtrip.limit.legLabel': 'Tratto più lungo di fila', + 'roadtrip.limit.dayLabel': 'Guida al giorno', + 'roadtrip.limit.rangeLabel': 'Autonomia con un pieno', + 'roadtrip.limit.minutes': 'min', + 'roadtrip.limit.off': 'off', + 'roadtrip.limit.hint': 'Conta solo il tempo di guida. Le soste e i pernottamenti non sono inclusi.', + 'roadtrip.limit.legOver': '{time} oltre il tuo tratto massimo', + 'roadtrip.limit.dayOver': '{time} in più', + 'roadtrip.limit.range': '{distance} dall’ultimo rifornimento', 'roadtrip.poi.rest': 'Area di sosta', 'roadtrip.poi.campsite': 'Campeggio', 'roadtrip.poi.food': 'Ristoro', diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts index 33ad0cb61..f4c9b3b0c 100644 --- a/shared/src/i18n/ja/roadtrip.ts +++ b/shared/src/i18n/ja/roadtrip.ts @@ -19,6 +19,16 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': '充電', 'roadtrip.stop.offRoad': '道路から{distance}', 'roadtrip.via.plugin': '途中の立ち寄り', + 'roadtrip.limit.title': '運転の上限', + 'roadtrip.limit.legLabel': '連続運転の上限', + 'roadtrip.limit.dayLabel': '1日の運転', + 'roadtrip.limit.rangeLabel': '満タンでの航続', + 'roadtrip.limit.minutes': '分', + 'roadtrip.limit.off': 'なし', + 'roadtrip.limit.hint': '運転時間のみを数えます。滞在時間と宿泊は含みません。', + 'roadtrip.limit.legOver': '連続運転の上限を{time}超過', + 'roadtrip.limit.dayOver': '{time}超過', + 'roadtrip.limit.range': '前回の給油から{distance}', 'roadtrip.poi.rest': 'サービスエリア', 'roadtrip.poi.campsite': 'キャンプ場', 'roadtrip.poi.food': '食事', diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts index 6b405d223..c8c5f31a2 100644 --- a/shared/src/i18n/ko/roadtrip.ts +++ b/shared/src/i18n/ko/roadtrip.ts @@ -19,6 +19,16 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': '충전', 'roadtrip.stop.offRoad': '도로에서 {distance}', 'roadtrip.via.plugin': '경유 정차', + 'roadtrip.limit.title': '운전 한도', + 'roadtrip.limit.legLabel': '연속 운전 최대', + 'roadtrip.limit.dayLabel': '하루 운전', + 'roadtrip.limit.rangeLabel': '1회 주유 주행거리', + 'roadtrip.limit.minutes': '분', + 'roadtrip.limit.off': '끔', + 'roadtrip.limit.hint': '운전 시간만 계산합니다. 정차 시간과 숙박은 포함되지 않습니다.', + 'roadtrip.limit.legOver': '연속 운전 한도 {time} 초과', + 'roadtrip.limit.dayOver': '{time} 초과', + 'roadtrip.limit.range': '마지막 주유 이후 {distance}', 'roadtrip.poi.rest': '휴게소', 'roadtrip.poi.campsite': '캠핑장', 'roadtrip.poi.food': '식사', diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts index b061b15e1..e7cc61453 100644 --- a/shared/src/i18n/nl/roadtrip.ts +++ b/shared/src/i18n/nl/roadtrip.ts @@ -19,6 +19,16 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Laden', 'roadtrip.stop.offRoad': '{distance} vanaf de weg', 'roadtrip.via.plugin': 'Stop onderweg', + 'roadtrip.limit.title': 'Rijlimieten', + 'roadtrip.limit.legLabel': 'Langste rit achtereen', + 'roadtrip.limit.dayLabel': 'Rijden per dag', + 'roadtrip.limit.rangeLabel': 'Bereik per tank', + 'roadtrip.limit.minutes': 'min', + 'roadtrip.limit.off': 'uit', + 'roadtrip.limit.hint': 'Telt alleen rijtijd. Tijd bij stops en overnachtingen tellen niet mee.', + 'roadtrip.limit.legOver': '{time} boven je langste rit', + 'roadtrip.limit.dayOver': '{time} te veel', + 'roadtrip.limit.range': '{distance} sinds de laatste tankbeurt', 'roadtrip.poi.rest': 'Rustplaats', 'roadtrip.poi.campsite': 'Camping', 'roadtrip.poi.food': 'Eten', diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts index ff4f79d96..ff5c69a33 100644 --- a/shared/src/i18n/pl/roadtrip.ts +++ b/shared/src/i18n/pl/roadtrip.ts @@ -19,6 +19,16 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Ładowanie', 'roadtrip.stop.offRoad': '{distance} od drogi', 'roadtrip.via.plugin': 'Postojój po drodze', + 'roadtrip.limit.title': 'Limity jazdy', + 'roadtrip.limit.legLabel': 'Najdłuższa jazda bez przerwy', + 'roadtrip.limit.dayLabel': 'Jazda dziennie', + 'roadtrip.limit.rangeLabel': 'Zasięg na jednym baku', + 'roadtrip.limit.minutes': 'min', + 'roadtrip.limit.off': 'wył.', + 'roadtrip.limit.hint': 'Liczy tylko czas jazdy. Postoje i noclegi nie są wliczane.', + 'roadtrip.limit.legOver': '{time} ponad twoją najdłuższą jazdę', + 'roadtrip.limit.dayOver': '{time} ponad', + 'roadtrip.limit.range': '{distance} od ostatniego tankowania', 'roadtrip.poi.rest': 'Miejsce odpoczynku', 'roadtrip.poi.campsite': 'Kemping', 'roadtrip.poi.food': 'Jedzenie', diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts index dab3d4c57..ba373a2f2 100644 --- a/shared/src/i18n/ru/roadtrip.ts +++ b/shared/src/i18n/ru/roadtrip.ts @@ -19,6 +19,16 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Зарядка', 'roadtrip.stop.offRoad': '{distance} от дороги', 'roadtrip.via.plugin': 'Остановка в пути', + 'roadtrip.limit.title': 'Пределы вождения', + 'roadtrip.limit.legLabel': 'Самый долгий перегон', + 'roadtrip.limit.dayLabel': 'Вождение за день', + 'roadtrip.limit.rangeLabel': 'Запас хода на баке', + 'roadtrip.limit.minutes': 'мин', + 'roadtrip.limit.off': 'выкл', + 'roadtrip.limit.hint': 'Учитывается только время за рулём. Остановки и ночёвки не входят.', + 'roadtrip.limit.legOver': 'на {time} дольше вашего перегона', + 'roadtrip.limit.dayOver': 'на {time} больше', + 'roadtrip.limit.range': '{distance} с последней заправки', 'roadtrip.poi.rest': 'Зона отдыха', 'roadtrip.poi.campsite': 'Кемпинг', 'roadtrip.poi.food': 'Еда', diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts index e1474a91d..3002f5096 100644 --- a/shared/src/i18n/sv/roadtrip.ts +++ b/shared/src/i18n/sv/roadtrip.ts @@ -19,6 +19,16 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Laddning', 'roadtrip.stop.offRoad': '{distance} från vägen', 'roadtrip.via.plugin': 'Stopp på vägen', + 'roadtrip.limit.title': 'Körgränser', + 'roadtrip.limit.legLabel': 'Längsta körning i sträck', + 'roadtrip.limit.dayLabel': 'Körning per dag', + 'roadtrip.limit.rangeLabel': 'Räckvidd per tank', + 'roadtrip.limit.minutes': 'min', + 'roadtrip.limit.off': 'av', + 'roadtrip.limit.hint': 'Räknar bara körtid. Tid vid stopp och övernattningar ingår inte.', + 'roadtrip.limit.legOver': '{time} över din längsta körning', + 'roadtrip.limit.dayOver': '{time} över', + 'roadtrip.limit.range': '{distance} sedan senaste tankningen', 'roadtrip.poi.rest': 'Rastplats', 'roadtrip.poi.campsite': 'Campingplats', 'roadtrip.poi.food': 'Mat', diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts index d10ff3c44..4405b11f3 100644 --- a/shared/src/i18n/tr/roadtrip.ts +++ b/shared/src/i18n/tr/roadtrip.ts @@ -19,6 +19,16 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Şarj', 'roadtrip.stop.offRoad': 'yoldan {distance}', 'roadtrip.via.plugin': 'Yol üzerinde mola', + 'roadtrip.limit.title': 'Sürüş sınırları', + 'roadtrip.limit.legLabel': 'Aralıksız en uzun sürüş', + 'roadtrip.limit.dayLabel': 'Günlük sürüş', + 'roadtrip.limit.rangeLabel': 'Bir depoyla menzil', + 'roadtrip.limit.minutes': 'dk', + 'roadtrip.limit.off': 'kapalı', + 'roadtrip.limit.hint': 'Yalnızca sürüş süresini sayar. Molalar ve geceleme dahil değildir.', + 'roadtrip.limit.legOver': 'en uzun sürüşünüzden {time} fazla', + 'roadtrip.limit.dayOver': '{time} fazla', + 'roadtrip.limit.range': 'son yakıt alımından beri {distance}', 'roadtrip.poi.rest': 'Dinlenme alanı', 'roadtrip.poi.campsite': 'Kamp alanı', 'roadtrip.poi.food': 'Yemek', diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts index bc1a1a34c..77be579b7 100644 --- a/shared/src/i18n/uk/roadtrip.ts +++ b/shared/src/i18n/uk/roadtrip.ts @@ -19,6 +19,16 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Зарядка', 'roadtrip.stop.offRoad': '{distance} від дороги', 'roadtrip.via.plugin': 'Зупинка в дорозі', + 'roadtrip.limit.title': 'Межі водіння', + 'roadtrip.limit.legLabel': 'Найдовший перегін', + 'roadtrip.limit.dayLabel': 'Водіння за день', + 'roadtrip.limit.rangeLabel': 'Запас ходу на баку', + 'roadtrip.limit.minutes': 'хв', + 'roadtrip.limit.off': 'вимк', + 'roadtrip.limit.hint': 'Рахує лише час за кермом. Зупинки та ночівлі не враховано.', + 'roadtrip.limit.legOver': 'на {time} довше вашого перегону', + 'roadtrip.limit.dayOver': 'на {time} більше', + 'roadtrip.limit.range': '{distance} від останньої заправки', 'roadtrip.poi.rest': 'Зона відпочинку', 'roadtrip.poi.campsite': 'Кемпінг', 'roadtrip.poi.food': 'Їжа', diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts index d541f1657..8d692a0e3 100644 --- a/shared/src/i18n/vi/roadtrip.ts +++ b/shared/src/i18n/vi/roadtrip.ts @@ -19,6 +19,16 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': 'Sạc điện', 'roadtrip.stop.offRoad': '{distance} từ đường', 'roadtrip.via.plugin': 'Dừng chân trên đường', + 'roadtrip.limit.title': 'Giới hạn lái xe', + 'roadtrip.limit.legLabel': 'Chặng lái dài nhất', + 'roadtrip.limit.dayLabel': 'Lái xe mỗi ngày', + 'roadtrip.limit.rangeLabel': 'Quãng đường mỗi bình', + 'roadtrip.limit.minutes': 'phút', + 'roadtrip.limit.off': 'tắt', + 'roadtrip.limit.hint': 'Chỉ tính thời gian lái. Thời gian dừng và nghỉ đêm không được tính.', + 'roadtrip.limit.legOver': 'vượt {time} so với chặng dài nhất', + 'roadtrip.limit.dayOver': 'vượt {time}', + 'roadtrip.limit.range': '{distance} kể từ lần đổ nhiên liệu cuối', 'roadtrip.poi.rest': 'Trạm dừng nghỉ', 'roadtrip.poi.campsite': 'Khu cắm trại', 'roadtrip.poi.food': 'Đồ ăn', diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts index 0770581d8..de5fd30ea 100644 --- a/shared/src/i18n/zh-TW/roadtrip.ts +++ b/shared/src/i18n/zh-TW/roadtrip.ts @@ -19,6 +19,16 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': '充電', 'roadtrip.stop.offRoad': '距道路 {distance}', 'roadtrip.via.plugin': '途中停靠', + 'roadtrip.limit.title': '駕駛限制', + 'roadtrip.limit.legLabel': '單次最長駕駛', + 'roadtrip.limit.dayLabel': '每天駕駛', + 'roadtrip.limit.rangeLabel': '單箱續航', + 'roadtrip.limit.minutes': '分鐘', + 'roadtrip.limit.off': '關閉', + 'roadtrip.limit.hint': '僅計算駕駛時間,不含停留與過夜。', + 'roadtrip.limit.legOver': '超出最長駕駛 {time}', + 'roadtrip.limit.dayOver': '超出 {time}', + 'roadtrip.limit.range': '距上次加油 {distance}', 'roadtrip.poi.rest': '休息站', 'roadtrip.poi.campsite': '露營地', 'roadtrip.poi.food': '餐飲', diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts index f895039db..74fc800c8 100644 --- a/shared/src/i18n/zh/roadtrip.ts +++ b/shared/src/i18n/zh/roadtrip.ts @@ -19,6 +19,16 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.charging': '充电', 'roadtrip.stop.offRoad': '距道路 {distance}', 'roadtrip.via.plugin': '途中停靠', + 'roadtrip.limit.title': '驾驶限制', + 'roadtrip.limit.legLabel': '单次最长驾驶', + 'roadtrip.limit.dayLabel': '每天驾驶', + 'roadtrip.limit.rangeLabel': '单箱续航', + 'roadtrip.limit.minutes': '分钟', + 'roadtrip.limit.off': '关闭', + 'roadtrip.limit.hint': '仅计算驾驶时间,不含停留和过夜。', + 'roadtrip.limit.legOver': '超出最长驾驶 {time}', + 'roadtrip.limit.dayOver': '超出 {time}', + 'roadtrip.limit.range': '距上次加油 {distance}', 'roadtrip.poi.rest': '服务区', 'roadtrip.poi.campsite': '露营地', 'roadtrip.poi.food': '餐饮',