feat(roadtrip): say when a drive is longer than you drive

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.
This commit is contained in:
Maurice
2026-09-04 21:59:15 +02:00
committed by Maurice
parent 56266138c6
commit dfb038d90f
34 changed files with 686 additions and 26 deletions
@@ -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,
@@ -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 (
<label className="flex items-center gap-2.5">
<Icon size={14} className="shrink-0 text-content-faint" aria-hidden />
<span className="min-w-0 flex-1 text-content-secondary" style={{ fontSize: FS.label }}>{label}</span>
<input
type="number"
inputMode="numeric"
min={0}
value={value || ''}
placeholder={placeholder}
onChange={e => onChange(parseLimit(e.target.value))}
className="w-16 rounded-md border border-edge bg-surface px-1.5 py-0.5 text-end tabular-nums text-content focus:border-accent focus:outline-none"
style={{ fontSize: FS.label }}
/>
<span className="w-6 text-content-faint" style={{ fontSize: FS.label }}>{suffix}</span>
</label>
)
}
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 (
<section className="rounded-xl border border-edge-faint bg-surface-card p-3.5">
<h3 className="mb-2.5 font-geist font-semibold uppercase tracking-[0.15em] text-content-faint" style={{ fontSize: FS.label }}>
{t('roadtrip.limit.title')}
</h3>
<div className="flex flex-col gap-2">
<LimitRow
icon={Clock}
label={t('roadtrip.limit.legLabel')}
suffix={t('roadtrip.limit.minutes')}
value={settings.roadtrip_leg_minutes}
placeholder={t('roadtrip.limit.off')}
onChange={v => onSave?.('roadtrip_leg_minutes', v)}
/>
<LimitRow
icon={CalendarClock}
label={t('roadtrip.limit.dayLabel')}
suffix={t('roadtrip.limit.minutes')}
value={settings.roadtrip_day_minutes}
placeholder={t('roadtrip.limit.off')}
onChange={v => onSave?.('roadtrip_day_minutes', v)}
/>
<LimitRow
icon={Fuel}
label={t('roadtrip.limit.rangeLabel')}
suffix={distanceUnit === 'imperial' ? 'mi' : 'km'}
value={rangeShown}
placeholder={t('roadtrip.limit.off')}
onChange={setRange}
/>
</div>
{/* 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. */}
<p className="mt-2.5 text-content-faint" style={{ fontSize: FS.label }}>
{t('roadtrip.limit.hint')}
</p>
</section>
)
}
@@ -38,7 +38,7 @@ function day(over: Partial<RoadtripDay> = {}): 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,
@@ -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 (
<div className="grid items-stretch" style={RAIL_GRID}>
<span className="relative z-[1] flex flex-col items-center">
<span className="flex-1" style={RAIL_DASH} aria-hidden />
</span>
<span className="px-1.5 pb-1">
<span
className="inline-flex w-fit items-center gap-1 rounded-full bg-warning-soft px-1.5 py-0.5 font-semibold text-warning"
style={{ fontSize: FS.label }}
>
<AlertTriangle size={10} className="shrink-0" aria-hidden />
{text}
</span>
</span>
</div>
)
}
/**
* 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),
})}
</span>
{day.dayWarning ? (
<span
className={`${DAY_BADGE} bg-warning-soft text-warning`}
style={{ fontSize: FS.label }}
title={t('roadtrip.limit.hint')}
>
<AlertTriangle size={10} className="shrink-0" aria-hidden />
{t('roadtrip.limit.dayOver', {
time: formatDurationShort((day.dayWarning.minutes - day.dayWarning.limitMinutes) * 60),
})}
</span>
) : null}
<span className={`${DAY_BADGE} bg-surface-tertiary`} style={{ fontSize: FS.label }}>
{t('roadtrip.day.stopCount', { count: day.stops.filter(s => !isServiceStopType(s.stopType)).length })}
</span>
@@ -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 => (
<DriveFinding key={w.code} warning={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) => (
@@ -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)
})
})
+115 -2
View File
@@ -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++) {
@@ -24,7 +24,7 @@ function day(over: Partial<RoadtripDay> = {}): 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,
@@ -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<AbortController | null>(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<DriveLimits>(
() => ({ legMinutes: legMinutes || null, dayMinutes: dayMinutes || null, rangeKm: rangeKm || null }),
[legMinutes, dayMinutes, rangeKm],
)
const plan = useMemo<RoadtripDay[]>(() => {
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])
}
+7
View File
@@ -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. */}
<div className="px-3.5 pb-3.5">
<RoadtripLimitsCard onSave={saveRoadtripLimit} />
</div>
</LazyPanel>
) : (
<PlacesSidebar
+18 -2
View File
@@ -39,7 +39,7 @@ import { usePlannerHistory } from '../../hooks/usePlannerHistory'
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
import { useIsTouch } from '../../hooks/useIsTouch'
import { usePluginStore } from '../../store/pluginStore'
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
import type { Accommodation, TripMember, Day, Place, Reservation, Settings } from '../../types'
import { OFM_POSITRON, DEFAULT_MAP_LAT, DEFAULT_MAP_LNG, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { useTileUrl } from '../../hooks/useTileUrl'
import { resolvePoolAssignmentId } from './tripPlannerModel'
@@ -72,7 +72,7 @@ export function useTripPlanner() {
const navigate = useNavigate()
const toast = useToast()
const { t, language } = useTranslation()
const { settings } = useSettingsStore()
const { settings, updateSettings } = useSettingsStore()
// trip-page plugins mount as tabs inside this trip planner (tripId-scoped).
const allPlugins = usePluginStore(s => 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<Settings>)
} 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,
+12
View File
@@ -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
+10
View File
@@ -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': 'طعام',
+10
View File
@@ -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',
+10
View File
@@ -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',
+10
View File
@@ -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',
+10
View File
@@ -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',
+10
View File
@@ -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',
+10
View File
@@ -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',
+10
View File
@@ -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',
+10
View File
@@ -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': 'Φαγητό',
+10
View File
@@ -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',
+10
View File
@@ -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',
+10
View File
@@ -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',
+10
View File
@@ -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': '食事',
+10
View File
@@ -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': '식사',
+10
View File
@@ -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',
+10
View File
@@ -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',
+10
View File
@@ -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': 'Еда',
+10
View File
@@ -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',
+10
View File
@@ -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',
+10
View File
@@ -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': 'Їжа',
+10
View File
@@ -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',
+10
View File
@@ -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': '餐飲',
+10
View File
@@ -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': '餐饮',