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': '餐饮',