refactor(roadtrip): put the driving limits behind a button, and the walk beside the stay

Two things the column was getting wrong. The limits sat open as a card, three
rows of a narrow sidebar for something set once on the first day of planning
and left alone after; they are behind a proper full-width button now, with what
is currently set riding on the button so the form does not have to be open to
read it. And the walk from the road was its own line under the stay, where it
looked like an unrelated note; it wears the stay's two-part badge and sits
beside it, because both answer the same question about a stop — one in time,
one in a walk.
This commit is contained in:
Maurice
2026-09-04 22:12:53 +02:00
committed by Maurice
parent dfb038d90f
commit e79a2591a1
25 changed files with 159 additions and 66 deletions
@@ -1,17 +1,24 @@
import React from 'react'
import { Clock, Fuel, CalendarClock } from 'lucide-react'
import React, { useState } from 'react'
import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight } from 'lucide-react'
import Modal from '../shared/Modal'
import { useTranslation } from '../../i18n/TranslationContext'
import { useSettingsStore } from '../../store/settingsStore'
import { convertDistance } from '../../utils/units'
import { convertDistance, formatDistance } from '../../utils/units'
import { formatDurationShort } from './roadtripModel'
import type { DistanceUnit } from '../../types'
import { FS } from './typeScale'
/**
* The three numbers that decide when the rail speaks up about the driving.
*
* Behind a button rather than laid out in the column, because this is set once and then
* read never: a permanent card costs three rows of a narrow sidebar for something most
* travellers touch on the first day of planning and leave alone after. The button carries
* the current answer, so what is set stays visible without the form being.
*
* 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.
* They are 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
@@ -33,9 +40,9 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, onChange }: {
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>
<label className="flex items-center gap-3">
<Icon size={16} className="shrink-0 text-content-faint" aria-hidden />
<span className="min-w-0 flex-1 text-body text-content-secondary">{label}</span>
<input
type="number"
inputMode="numeric"
@@ -43,72 +50,109 @@ function LimitRow({ icon: Icon, label, suffix, value, placeholder, onChange }: {
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 }}
className="w-20 rounded-lg border border-edge bg-surface px-2 py-1 text-end text-body tabular-nums text-content focus:border-accent focus:outline-none"
/>
<span className="w-6 text-content-faint" style={{ fontSize: FS.label }}>{suffix}</span>
<span className="w-7 text-caption text-content-faint">{suffix}</span>
</label>
)
}
export default function RoadtripLimitsCard({ onSave }: {
/** Persists one setting. Absent leaves the card read-only. */
/** Persists one setting. Absent leaves the dialog read-only. */
onSave?: (key: string, value: number) => void
}): React.ReactElement {
const { t } = useTranslation()
const settings = useSettingsStore(s => s.settings)
const [open, setOpen] = useState(false)
const distanceUnit: DistanceUnit = settings.distance_unit === 'imperial' ? 'imperial' : 'metric'
const legMinutes = settings.roadtrip_leg_minutes
const dayMinutes = settings.roadtrip_day_minutes
const rangeKm = settings.roadtrip_range_km
// 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 rangeShown = rangeKm ? Math.round(convertDistance(rangeKm, distanceUnit)) : undefined
const setRange = (shown: number) => {
const km = distanceUnit === 'imperial' ? shown / 0.621371 : shown
onSave?.('roadtrip_range_km', Math.round(km))
}
// What is set, on the button itself, so the form does not have to be open to see it.
// Nothing set reads as "off" rather than as an empty string, which would look broken.
const summary = [
legMinutes ? formatDurationShort(legMinutes * 60) : null,
dayMinutes ? formatDurationShort(dayMinutes * 60) : null,
rangeKm ? formatDistance(rangeKm, distanceUnit) : null,
].filter(Boolean).join(' · ')
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>
<>
{/* Full width and card-sized, the same weight as the search controls above it: this
is one of three things this column does, not a footnote under the other two.
The current answer rides on the button, so what is set stays visible without the
form being open. */}
<button
type="button"
onClick={() => setOpen(true)}
className="flex w-full items-center gap-3 rounded-xl border border-edge-faint bg-surface-card px-3.5 py-3 text-start transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-surface-tertiary text-content-secondary">
<SlidersHorizontal size={16} aria-hidden />
</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="truncate text-body font-semibold text-content">
{t('roadtrip.limit.title')}
</span>
<span className={`truncate tabular-nums ${summary ? 'text-content-secondary' : 'text-content-faint'}`} style={{ fontSize: FS.label }}>
{summary || t('roadtrip.limit.none')}
</span>
</span>
<ChevronRight size={16} className="shrink-0 text-content-faint" aria-hidden />
</button>
{open ? (
<Modal isOpen onClose={() => setOpen(false)} size="sm" title={
<span className="flex items-center gap-2">
<SlidersHorizontal size={15} className="text-content-faint" aria-hidden />
{t('roadtrip.limit.title')}
</span>
}>
<div className="flex flex-col gap-4">
<LimitRow
icon={Clock}
label={t('roadtrip.limit.legLabel')}
suffix={t('roadtrip.limit.minutes')}
value={legMinutes}
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={dayMinutes}
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}
/>
{/* 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="text-caption text-content-faint">{t('roadtrip.limit.hint')}</p>
</div>
</Modal>
) : null}
</>
)
}
@@ -96,6 +96,37 @@ const DAY_BADGE = 'inline-flex h-[20px] items-center rounded-lg px-2 font-geist
* Read-only for someone who cannot edit the trip: then it is a label, and a stop without
* a stay shows nothing at all rather than an invitation that leads nowhere.
*/
/**
* How far the road stops short of the place, in the same two-part shell the stay wears.
*
* Beside the stay rather than under it: both answer "what does this stop cost you", one
* in time and one in a walk, and two badges on one line read as one fact about the stop
* instead of two unrelated notes stacked up.
*/
function OffRoadBadge({ meters }: { meters: number }): React.ReactElement {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
return (
<span
className="inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border border-edge"
title={t('roadtrip.stop.offRoad', { distance: formatDistance(meters / 1000, distanceUnit) })}
>
<span
className="flex items-center bg-surface-tertiary px-1 text-content-faint"
style={{ fontSize: FS.micro }}
>
<Footprints size={9} aria-hidden />
</span>
<span
className="flex items-center border-s border-edge bg-surface-card px-1.5 font-semibold tabular-nums text-content-secondary"
style={{ fontSize: FS.label }}
>
{formatDistance(meters / 1000, distanceUnit)}
</span>
</span>
)
}
function StayBadge({ minutes, onEdit }: { minutes: number | null; onEdit?: () => void }): React.ReactElement | null {
const { t } = useTranslation()
const text = minutes ? formatDurationShort(minutes * 60) : null
@@ -591,19 +622,14 @@ function Stop({ stop, number, entry, late, selected, continues, onSelect, onMove
</span>
{/* Two halves under one border: the word says what the number means, so the
number needs no unit of explanation beside it. */}
<StayBadge minutes={stop.dwellMinutes} onEdit={onEditStay} />
{/* Only when the walk is far enough to change the plan. A dashed line on the
map already says there is a gap; the number is for the case where the gap
means luggage, a gate or a track a hire car should not be on. */}
{spurWorthLabelling(stop.offRoadMeters) ? (
<span
className="mt-0.5 inline-flex w-fit items-center gap-1 self-start text-content-faint"
style={{ fontSize: FS.label }}
>
<Footprints size={10} className="shrink-0" aria-hidden />
{t('roadtrip.stop.offRoad', { distance: formatDistance((stop.offRoadMeters ?? 0) / 1000, distanceUnit) })}
</span>
) : null}
{/* One row, wrapping: the stay and the walk from the road are both answers to
"what does this stop cost", and the second only appears when the gap is far
enough to change the plan. The dashed line on the map already says there is
one; the number is for luggage, a gate, a track a hire car should not be on. */}
<span className="flex flex-wrap items-center gap-1">
<StayBadge minutes={stop.dwellMinutes} onEdit={onEditStay} />
{spurWorthLabelling(stop.offRoadMeters) ? <OffRoadBadge meters={stop.offRoadMeters ?? 0} /> : null}
</span>
{lateText ? (
<span
dir="ltr"
+1
View File
@@ -29,6 +29,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legOver': '{time} فوق أطول قيادة لديك',
'roadtrip.limit.dayOver': '{time} زيادة',
'roadtrip.limit.range': '{distance} منذ آخر تعبئة',
'roadtrip.limit.none': 'غير محددة',
'roadtrip.poi.rest': 'استراحة',
'roadtrip.poi.campsite': 'مخيم',
'roadtrip.poi.food': 'طعام',
+1
View File
@@ -29,6 +29,7 @@ const roadtrip: TranslationStrings = {
'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.limit.none': 'Nenhum definido',
'roadtrip.poi.rest': 'Área de descanso',
'roadtrip.poi.campsite': 'Camping',
'roadtrip.poi.food': 'Comida',
+1
View File
@@ -29,6 +29,7 @@ const roadtrip: TranslationStrings = {
'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.limit.none': 'Cap definit',
'roadtrip.poi.rest': 'Àrea de descans',
'roadtrip.poi.campsite': 'Càmping',
'roadtrip.poi.food': 'Menjar',
+1
View File
@@ -29,6 +29,7 @@ const roadtrip: TranslationStrings = {
'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.limit.none': 'Nenastaveno',
'roadtrip.poi.rest': 'Odpočívadlo',
'roadtrip.poi.campsite': 'Kemp',
'roadtrip.poi.food': 'Jídlo',
+1
View File
@@ -29,6 +29,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legOver': '{time} über deiner längsten Fahrt',
'roadtrip.limit.dayOver': '{time} drüber',
'roadtrip.limit.range': '{distance} seit dem letzten Tanken',
'roadtrip.limit.none': 'Keine gesetzt',
'roadtrip.poi.rest': 'Rastplatz',
'roadtrip.poi.campsite': 'Campingplatz',
'roadtrip.poi.food': 'Essen',
+1
View File
@@ -29,6 +29,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legOver': '{time} over your longest drive',
'roadtrip.limit.dayOver': '{time} over',
'roadtrip.limit.range': '{distance} since the last fill-up',
'roadtrip.limit.none': 'None set',
'roadtrip.poi.rest': 'Rest area',
'roadtrip.poi.campsite': 'Campsite',
'roadtrip.poi.food': 'Food',
+1
View File
@@ -29,6 +29,7 @@ const roadtrip: TranslationStrings = {
'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.limit.none': 'Ninguno definido',
'roadtrip.poi.rest': 'Área de descanso',
'roadtrip.poi.campsite': 'Camping',
'roadtrip.poi.food': 'Comida',
+1
View File
@@ -29,6 +29,7 @@ const roadtrip: TranslationStrings = {
'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.limit.none': 'Aucune définie',
'roadtrip.poi.rest': 'Aire de repos',
'roadtrip.poi.campsite': 'Camping',
'roadtrip.poi.food': 'Restauration',
+1
View File
@@ -29,6 +29,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legOver': '{time} πάνω από τη μέγιστη οδήγησή σας',
'roadtrip.limit.dayOver': '{time} πάνω',
'roadtrip.limit.range': '{distance} από τον τελευταίο ανεφοδιασμό',
'roadtrip.limit.none': 'Δεν έχουν οριστεί',
'roadtrip.poi.rest': 'Χώρος ανάπαυσης',
'roadtrip.poi.campsite': 'Κάμπινγκ',
'roadtrip.poi.food': 'Φαγητό',
+1
View File
@@ -29,6 +29,7 @@ const roadtrip: TranslationStrings = {
'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.limit.none': 'Nincs beállítva',
'roadtrip.poi.rest': 'Pihenőhely',
'roadtrip.poi.campsite': 'Kemping',
'roadtrip.poi.food': 'Étkezés',
+1
View File
@@ -29,6 +29,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legOver': '{time} lebih dari perjalanan terlama',
'roadtrip.limit.dayOver': 'lebih {time}',
'roadtrip.limit.range': '{distance} sejak pengisian terakhir',
'roadtrip.limit.none': 'Belum diatur',
'roadtrip.poi.rest': 'Tempat istirahat',
'roadtrip.poi.campsite': 'Perkemahan',
'roadtrip.poi.food': 'Makanan',
+1
View File
@@ -29,6 +29,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legOver': '{time} oltre il tuo tratto massimo',
'roadtrip.limit.dayOver': '{time} in più',
'roadtrip.limit.range': '{distance} dall’ultimo rifornimento',
'roadtrip.limit.none': 'Nessuno impostato',
'roadtrip.poi.rest': 'Area di sosta',
'roadtrip.poi.campsite': 'Campeggio',
'roadtrip.poi.food': 'Ristoro',
+1
View File
@@ -29,6 +29,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legOver': '連続運転の上限を{time}超過',
'roadtrip.limit.dayOver': '{time}超過',
'roadtrip.limit.range': '前回の給油から{distance}',
'roadtrip.limit.none': '未設定',
'roadtrip.poi.rest': 'サービスエリア',
'roadtrip.poi.campsite': 'キャンプ場',
'roadtrip.poi.food': '食事',
+1
View File
@@ -29,6 +29,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legOver': '연속 운전 한도 {time} 초과',
'roadtrip.limit.dayOver': '{time} 초과',
'roadtrip.limit.range': '마지막 주유 이후 {distance}',
'roadtrip.limit.none': '설정 안 됨',
'roadtrip.poi.rest': '휴게소',
'roadtrip.poi.campsite': '캠핑장',
'roadtrip.poi.food': '식사',
+1
View File
@@ -29,6 +29,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legOver': '{time} boven je langste rit',
'roadtrip.limit.dayOver': '{time} te veel',
'roadtrip.limit.range': '{distance} sinds de laatste tankbeurt',
'roadtrip.limit.none': 'Geen ingesteld',
'roadtrip.poi.rest': 'Rustplaats',
'roadtrip.poi.campsite': 'Camping',
'roadtrip.poi.food': 'Eten',
+1
View File
@@ -29,6 +29,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legOver': '{time} ponad twoją najdłuższą jazdę',
'roadtrip.limit.dayOver': '{time} ponad',
'roadtrip.limit.range': '{distance} od ostatniego tankowania',
'roadtrip.limit.none': 'Brak ustawionych',
'roadtrip.poi.rest': 'Miejsce odpoczynku',
'roadtrip.poi.campsite': 'Kemping',
'roadtrip.poi.food': 'Jedzenie',
+1
View File
@@ -29,6 +29,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legOver': 'на {time} дольше вашего перегона',
'roadtrip.limit.dayOver': 'на {time} больше',
'roadtrip.limit.range': '{distance} с последней заправки',
'roadtrip.limit.none': 'Не заданы',
'roadtrip.poi.rest': 'Зона отдыха',
'roadtrip.poi.campsite': 'Кемпинг',
'roadtrip.poi.food': 'Еда',
+1
View File
@@ -29,6 +29,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legOver': '{time} över din längsta körning',
'roadtrip.limit.dayOver': '{time} över',
'roadtrip.limit.range': '{distance} sedan senaste tankningen',
'roadtrip.limit.none': 'Inga angivna',
'roadtrip.poi.rest': 'Rastplats',
'roadtrip.poi.campsite': 'Campingplats',
'roadtrip.poi.food': 'Mat',
+1
View File
@@ -29,6 +29,7 @@ const roadtrip: TranslationStrings = {
'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.limit.none': 'Ayarlanmadı',
'roadtrip.poi.rest': 'Dinlenme alanı',
'roadtrip.poi.campsite': 'Kamp alanı',
'roadtrip.poi.food': 'Yemek',
+1
View File
@@ -29,6 +29,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legOver': 'на {time} довше вашого перегону',
'roadtrip.limit.dayOver': 'на {time} більше',
'roadtrip.limit.range': '{distance} від останньої заправки',
'roadtrip.limit.none': 'Не задано',
'roadtrip.poi.rest': 'Зона відпочинку',
'roadtrip.poi.campsite': 'Кемпінг',
'roadtrip.poi.food': 'Їжа',
+1
View File
@@ -29,6 +29,7 @@ const roadtrip: TranslationStrings = {
'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.limit.none': 'Chưa đặt',
'roadtrip.poi.rest': 'Trạm dừng nghỉ',
'roadtrip.poi.campsite': 'Khu cắm trại',
'roadtrip.poi.food': 'Đồ ăn',
+1
View File
@@ -29,6 +29,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legOver': '超出最長駕駛 {time}',
'roadtrip.limit.dayOver': '超出 {time}',
'roadtrip.limit.range': '距上次加油 {distance}',
'roadtrip.limit.none': '未設定',
'roadtrip.poi.rest': '休息站',
'roadtrip.poi.campsite': '露營地',
'roadtrip.poi.food': '餐飲',
+1
View File
@@ -29,6 +29,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.limit.legOver': '超出最长驾驶 {time}',
'roadtrip.limit.dayOver': '超出 {time}',
'roadtrip.limit.range': '距上次加油 {distance}',
'roadtrip.limit.none': '未设置',
'roadtrip.poi.rest': '服务区',
'roadtrip.poi.campsite': '露营地',
'roadtrip.poi.food': '餐饮',