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])
}