diff --git a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx
index 715746b74..aef384da9 100644
--- a/client/src/components/Roadtrip/RoadtripSidebar.test.tsx
+++ b/client/src/components/Roadtrip/RoadtripSidebar.test.tsx
@@ -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()
+
+ 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 }),
diff --git a/client/src/components/Roadtrip/RoadtripSidebar.tsx b/client/src/components/Roadtrip/RoadtripSidebar.tsx
index c68e90026..5b8906147 100644
--- a/client/src/components/Roadtrip/RoadtripSidebar.tsx
+++ b/client/src/components/Roadtrip/RoadtripSidebar.tsx
@@ -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
{spurWorthLabelling(stop.offRoadMeters) ? : null}
{(driveFindings ?? []).map(w => )}
+
{entry?.arrival ? : 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 (
-
-
-
-
+
+
-
{t('roadtrip.warn.overnight')}
+ {/* 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. */}
+
+ z
+ z
+ z
+
)
}
@@ -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 (
{spurWorthLabelling(stop.offRoadMeters) ? : null}
{(driveFindings ?? []).map(w => )}
+
- {lateText ? (
-
-
-
- {`+${formatDurationShort((late?.minutes ?? 0) * 60)}`}
-
-
- ) : null}
{entry?.arrival ? : null}
@@ -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 (
+
+
+
+
+
+
+ {`+${formatDurationShort((late.minutes ?? 0) * 60)}`}
+
+
+
+ )
+}
+
/**
* 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
w.code === 'late')}
driveFindings={day.driveWarnings.filter(w => w.index === i)}
selected={selectedAssignmentId === stop.assignmentId}
onSelect={onSelectStop ? () => onSelectStop(stop.placeId, stop.assignmentId) : undefined}
diff --git a/client/src/components/Roadtrip/RoadtripStayModal.test.tsx b/client/src/components/Roadtrip/RoadtripStayModal.test.tsx
index 138a1af1b..4619321bf 100644
--- a/client/src/components/Roadtrip/RoadtripStayModal.test.tsx
+++ b/client/src/components/Roadtrip/RoadtripStayModal.test.tsx
@@ -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()
+ })
})
diff --git a/client/src/components/Roadtrip/RoadtripStayModal.tsx b/client/src/components/Roadtrip/RoadtripStayModal.tsx
index 66c00a7d6..abfbd03e3 100644
--- a/client/src/components/Roadtrip/RoadtripStayModal.tsx
+++ b/client/src/components/Roadtrip/RoadtripStayModal.tsx
@@ -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
{t('roadtrip.stay.leave')}
- {times.leave}
+
+ {times.leave}
+ {times.leaveCarry > 0 ? (
+
+ {`+${times.leaveCarry}`}
+ {` ${t('roadtrip.warn.overnight')}`}
+
+ ) : null}
+
) : null}
diff --git a/client/src/index.css b/client/src/index.css
index a0ec5300e..9d09c5205 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -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 {