feat(roadtrip): give the day boundary its own band, and flag a late pause

Three things the rail was quietly getting wrong at a day boundary.

A stop reached later than the time set on it showed a warning only when it carried
a number. The schedule restarts its chain at any anchor, whatever kind of stop holds
it, so the finding was computed for a fuel or charging halt too and then thrown away.
Both branches now draw it, from one component, and it sits in the badge row beside
the stay rather than on a line of its own.

The midnight crossing was a small pill floating in the middle of the rail, which read
as a badge belonging to the stop underneath. It is now a band across the full width
that breaks the dashed line instead of letting it run through, with the mascot asleep
in the gap. Both its animations stop under prefers-reduced-motion.

And the stay dialog said 'leave 01:00' for a departure the chain places on the next
day, because the clock wraps modulo 24 h. It now carries the same day marker the rail
has always shown beside such a time.
This commit is contained in:
Maurice
2026-09-09 14:44:10 +02:00
committed by Maurice
parent 15beb0ea40
commit 149b87fd47
5 changed files with 142 additions and 23 deletions
@@ -302,6 +302,23 @@ describe('RoadtripSidebar', () => {
expect(onEditStay).toHaveBeenCalledWith({ placeId: 20, name: 'Berlin', minutes: null, arrival: null })
})
it('FE-ROADTRIP-SIDEBAR-034: a pause reached late says so, the same as a numbered stop', () => {
// The schedule restarts its chain at any pinned time, whatever kind of stop carries
// it, so it computes the finding for a fuel halt too. The rail used to hand `late`
// only to the numbered branch and throw the pause's own away.
const stops = [
stop({ assignmentId: 1, name: 'Hamburg' }),
stop({ assignmentId: 2, name: 'Aral Autohof', stopType: 'fuel', dwellMinutes: 15 }),
]
const schedule = {
entries: stops.map(() => ({ arrival: '09:00', departure: '09:15', anchored: true, dayOffset: 0 })),
warnings: [{ index: 1, code: 'late' as const, minutes: 45 }],
}
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule, legs: [leg()] })] })} />)
expect(screen.getByLabelText(/45/)).toBeInTheDocument()
})
it('FE-ROADTRIP-SIDEBAR-033: without the right to edit, a stop with no stay shows nothing', () => {
const stops = [
stop({ assignmentId: 1, name: 'Hamburg', dwellMinutes: 90 }),
@@ -1,9 +1,10 @@
import React, { useState } from 'react'
import {
CarFront, Footprints, Bike, Zap, AlertTriangle, Moon,
CarFront, Footprints, Bike, Zap, AlertTriangle,
ParkingSquare, Shuffle, Fuel, Clock, Spline,
type LucideIcon,
} from 'lucide-react'
import MDancingTrek from '../../mobile/components/MDancingTrek'
import { useTranslation } from '../../i18n/TranslationContext'
import { Tooltip } from '../shared/Tooltip'
import { useSettingsStore } from '../../store/settingsStore'
@@ -377,9 +378,12 @@ function DriveBand({ leg, onAskAlternatives, alternativesOpen }: {
* the trip is actually for. Its own icon on one flat disc: three kinds of pause that all
* mean "we are still driving", and the icon is what tells them apart.
*/
function ServiceStop({ stop, entry, driveFindings, selected, onSelect, onEditStay, onPickKind }: {
function ServiceStop({ stop, entry, late, driveFindings, selected, onSelect, onEditStay, onPickKind }: {
stop: RoadtripStop
entry: ScheduleEntry | undefined
/** How late the drive reaches a time pinned on this pause, the same finding a numbered
* stop shows. A fuel or charging halt can carry a pinned time like anything else. */
late: ScheduleWarning | undefined
/** Findings about the drive that ARRIVES here. A charging halt is a stop like any other
* as far as the tank is concerned, so it carries them the same way a numbered one does. */
driveFindings?: ScheduleWarning[]
@@ -459,6 +463,7 @@ function ServiceStop({ stop, entry, driveFindings, selected, onSelect, onEditSta
<StayBadge minutes={stop.dwellMinutes} onEdit={onEditStay} />
{spurWorthLabelling(stop.offRoadMeters) ? <OffRoadBadge meters={stop.offRoadMeters ?? 0} /> : null}
{(driveFindings ?? []).map(w => <DriveFindingBadge key={w.code} warning={w} />)}
<LateBadge late={late} />
</span>
</span>
{entry?.arrival ? <Arrival entry={entry} /> : null}
@@ -566,20 +571,42 @@ function DriveFindingBadge({ warning }: { warning: ScheduleWarning }): React.Rea
* The schedule has carried this since it was written and nothing ever drew it: a day
* running past midnight showed "02:30" as if it were tonight.
*/
/**
* Where the chain crosses midnight.
*
* The one band in the rail that breaks the line instead of hanging off it, and the only
* thing here that moves: a night is not a place and not a drive, it is the day running
* out, and the rail should look like it stops. So it spans the marker column too, the
* dashes end above it and start again below, and the mascot dozes in the gap with three
* z's drifting off it.
*
* The ground is mixed rather than the soft info token: that token is translucent in the
* dark theme, and the mascot cuts its eyes out in the colour behind it, which would show
* the body through them. Mixing against the card keeps it opaque in both themes.
*/
function OvernightBreak(): React.ReactElement {
const { t } = useTranslation()
const night = 'color-mix(in srgb, var(--info) 13%, var(--bg-card))'
return (
<div className="grid items-center" style={RAIL_GRID}>
<span className="flex flex-col items-center" aria-hidden>
<span className="h-full" style={RAIL_DASH} />
</span>
<div
className="my-1 flex w-full items-center gap-2 rounded-xl py-1 pe-2.5 ps-1.5 text-info"
style={{ background: night, '--m-bg': night, '--m-ink': 'var(--info)' } as React.CSSProperties}
>
<MDancingTrek scene="idle" mood="sleepy" size={26} />
<span
className="my-1 inline-flex w-fit items-center gap-1 rounded-full bg-info-soft px-1.5 py-0.5 font-medium text-info"
className="font-geist font-semibold uppercase tracking-[0.16em]"
style={{ fontSize: FS.label }}
>
<Moon size={10} className="shrink-0" aria-hidden />
{t('roadtrip.warn.overnight')}
</span>
{/* Three z's on one baseline, each starting a third of the loop after the last, so
one is always on its way up. Purely decorative: the word beside it already says
what this band is. */}
<span className="trek-doze ms-auto flex items-end gap-[3px]" aria-hidden>
<span style={{ fontSize: FS.micro }}>z</span>
<span style={{ fontSize: FS.label }}>z</span>
<span style={{ fontSize: FS.meta }}>z</span>
</span>
</div>
)
}
@@ -651,8 +678,6 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
onEditStay?: () => void
}): React.ReactElement {
const { t } = useTranslation()
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
const lateText = late ? t('roadtrip.warn.late', { minutes: late.minutes ?? 0 }) : null
return (
<button
type="button"
@@ -735,19 +760,8 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
<StayBadge minutes={stop.dwellMinutes} onEdit={onEditStay} />
{spurWorthLabelling(stop.offRoadMeters) ? <OffRoadBadge meters={stop.offRoadMeters ?? 0} /> : null}
{(driveFindings ?? []).map(w => <DriveFindingBadge key={w.code} warning={w} />)}
<LateBadge late={late} />
</span>
{lateText ? (
<Tooltip label={lateText}>
<span
dir="ltr"
className="mt-0.5 inline-flex w-fit items-center gap-1 self-start rounded-full bg-warning-soft px-1.5 py-0.5 font-semibold tabular-nums text-warning"
style={{ fontSize: FS.label }}
>
<AlertTriangle size={10} className="shrink-0" aria-label={lateText} />
{`+${formatDurationShort((late?.minutes ?? 0) * 60)}`}
</span>
</Tooltip>
) : null}
</span>
{entry?.arrival ? <Arrival entry={entry} /> : null}
</span>
@@ -755,6 +769,43 @@ function Stop({ stop, number, entry, late, driveFindings, selected, continues, s
)
}
/**
* How far past the time you set this stop is reached. Its own component because a pause
* runs late exactly like a numbered stop does: the schedule computes the finding for both
* (roadtripModel restarts the chain at any anchor, whatever kind of stop carries it), and
* drawing it in only one of them threw the other one's away.
*
* It sits in the badge row beside the stay and the drive findings rather than on a line of
* its own: they are all answers to "what does this stop cost", and a warning on its own row
* pushed every following stop down for a finding that fits in a pill.
*/
function LateBadge({ late }: { late: ScheduleWarning | undefined }): React.ReactElement | null {
const { t } = useTranslation()
if (!late) return null
const label = t('roadtrip.warn.late', { minutes: late.minutes ?? 0 })
return (
<Tooltip label={label}>
<span
dir="ltr"
// The same two-part shell the stay and the drive findings wear, so a row of badges
// reads as one set instead of a pill among boxes. Warning-coloured edge like the
// drive finding: at this size a tinted edge disappears into the card.
className="inline-flex h-[16px] items-stretch self-start overflow-hidden rounded border border-warning"
>
<span className="flex items-center bg-warning-soft px-1 text-warning" style={{ fontSize: FS.micro }}>
<AlertTriangle size={9} aria-label={label} />
</span>
<span
className="flex items-center border-s border-warning bg-surface-card px-1.5 font-semibold tabular-nums text-warning"
style={{ fontSize: FS.label }}
>
{`+${formatDurationShort((late.minutes ?? 0) * 60)}`}
</span>
</span>
</Tooltip>
)
}
/**
* One day, one card: its own numbers in its own head, its stops chained below.
*
@@ -905,6 +956,7 @@ function DaySection({ day, selectedAssignmentId, onSelectStop, onReorderStop, on
<ServiceStop
stop={stop}
entry={day.schedule.entries[i]}
late={marks.find(w => w.code === 'late')}
driveFindings={day.driveWarnings.filter(w => w.index === i)}
selected={selectedAssignmentId === stop.assignmentId}
onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined}
@@ -140,4 +140,21 @@ describe('RoadtripStayModal', () => {
expect(onSave).not.toHaveBeenCalled()
expect(onClose).toHaveBeenCalled()
})
it('FE-STAYMODAL-013: a stay running past midnight says which day it leaves on', () => {
// The clock wraps modulo 24 h, so without the carry this reads "01:00" for a
// departure the chain places on the next day — the rail says +1 beside it and
// the dialog used to say nothing.
open({ minutes: 180, arrival: '22:00' })
expect(screen.getByText('01:00')).toBeInTheDocument()
expect(screen.getByText('+1')).toBeInTheDocument()
})
it('FE-STAYMODAL-014: a stay inside one day carries no day marker', () => {
open({ minutes: 60, arrival: '12:00' })
expect(screen.getByText('13:00')).toBeInTheDocument()
expect(screen.queryByText('+1')).not.toBeInTheDocument()
})
})
@@ -81,6 +81,11 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta
return {
arrive: formatClockTime(formatClock(arrival), is12h),
leave: formatClockTime(formatClock(arrival + minutes), is12h),
// `formatClock` wraps modulo 24 h, so a stay that runs past midnight reads as a
// small number again. The rail carries the day it belongs to along with the time;
// without the same carry here the dialog would quietly say "leave 01:00" for a
// stop the chain places on the next day.
leaveCarry: Math.floor((arrival + minutes) / (24 * 60)) - Math.floor(arrival / (24 * 60)),
}
}, [stop?.arrival, minutes, is12h])
@@ -164,7 +169,15 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
{t('roadtrip.stay.leave')}
</span>
<span dir="ltr" className="text-body font-semibold tabular-nums text-content">{times.leave}</span>
<span dir="ltr" className="text-body font-semibold tabular-nums text-content">
{times.leave}
{times.leaveCarry > 0 ? (
<span className="ms-0.5">
{`+${times.leaveCarry}`}
<span className="sr-only">{` ${t('roadtrip.warn.overnight')}`}</span>
</span>
) : null}
</span>
</span>
</div>
) : null}
+20
View File
@@ -523,6 +523,20 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
will-change: transform, opacity;
}
/* Roadtrip: die Zzz am Tageswechsel im Streckenstrang. Drei Buchstaben auf einer
Grundlinie, jeder ein Drittel des Laufs nach dem vorigen, damit immer einer
unterwegs ist. Rein dekorativ — das Wort daneben sagt schon, was das Band ist. */
@keyframes trek-doze {
0% { opacity: 0; transform: translate(0, 3px) scale(0.7); }
22% { opacity: 0.9; }
100% { opacity: 0; transform: translate(6px, -10px) scale(1.15); }
}
.trek-doze > * {
animation: trek-doze 3s ease-out infinite;
}
.trek-doze > *:nth-child(2) { animation-delay: 1s; }
.trek-doze > *:nth-child(3) { animation-delay: 2s; }
/* Stagger-Helpers für Listen — Enter-Animation mit Offset */
@keyframes trek-fade-up {
from { opacity: 0; transform: translateY(6px); }
@@ -551,6 +565,12 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
animation: none;
background: var(--bg-tertiary);
}
/* Die Zzz stehen still statt zu treiben — der Tageswechsel bleibt lesbar. */
.trek-doze > * {
animation: none;
opacity: 0.6;
transform: none;
}
button:not(:disabled):not([data-no-press]):active,
[role="button"]:not([aria-disabled="true"]):not([data-no-press]):active,
[data-press]:active {