Skip to content

Commit bebac36

Browse files
committed
fix(simulator,ui): enforce driving cap and correct rest boundary display
Prevent driving segments from overrunning 11-hour capacity by clamping at write-time in the simulator loop, including split/whole-leg paths. Update timeline/logsheet duration rendering to hh mm format and compute continuous rest pills only from midnight-anchored contiguous off-duty/sleeper blocks with 34h restart precedence.
1 parent 6f9dc42 commit bebac36

4 files changed

Lines changed: 177 additions & 59 deletions

File tree

backend/simulator/engine.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,17 @@ def _simulate_leg(
227227

228228
if remaining_leg.duration_minutes <= minutes_available:
229229
# Consume whole remaining leg
230-
drive_hours = remaining_leg.duration_minutes / 60.0
230+
# Defensive clamp: enforce 11h cap at write-time as well, not only
231+
# via precomputed availability, so no segment can overrun due to
232+
# rounding or stale availability in edge paths.
233+
drive_minutes = min(
234+
remaining_leg.duration_minutes,
235+
minutes_available,
236+
minutes_until_11h_limit(state),
237+
)
238+
if drive_minutes <= 0.001:
239+
continue
240+
drive_hours = drive_minutes / 60.0
231241
end_time = _add_event(
232242
timeline, DutyStatus.DRIVING, current_time,
233243
drive_hours, remaining_leg.end_location,
@@ -236,14 +246,25 @@ def _simulate_leg(
236246
coords=remaining_leg.end_coords,
237247
)
238248
state.driving_hours_today += drive_hours
239-
state.driving_minutes_since_break += int(remaining_leg.duration_minutes)
249+
state.driving_minutes_since_break += int(drive_minutes)
240250
state.cycle_hours_used += drive_hours
241-
state.miles_since_fuel += remaining_leg.distance_miles
251+
# Scale miles to the actual driven minutes when clamped.
252+
if remaining_leg.duration_minutes > 0:
253+
state.miles_since_fuel += remaining_leg.distance_miles * (
254+
drive_minutes / remaining_leg.duration_minutes
255+
)
242256
current_time = end_time
243-
break
257+
# If we clamped to a partial segment, continue with remainder.
258+
if drive_minutes >= remaining_leg.duration_minutes - 0.001:
259+
break
260+
_, remainder = split_leg(remaining_leg, drive_minutes)
261+
remaining_leg = remainder
244262
else:
245263
# Split at tightest constraint
246-
partial, remainder = split_leg(remaining_leg, minutes_available)
264+
drive_minutes = min(minutes_available, minutes_until_11h_limit(state))
265+
if drive_minutes <= 0.001:
266+
continue
267+
partial, remainder = split_leg(remaining_leg, drive_minutes)
247268
drive_hours = partial.duration_minutes / 60.0
248269
end_time = _add_event(
249270
timeline, DutyStatus.DRIVING, current_time,

frontend/src/components/LogSheet.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { DutyStatus, LogSheet as LogSheetType } from "../types/api";
2+
import { formatHM } from "../utils/formatDuration";
23

34
interface LogSheetProps {
45
logSheet: LogSheetType;
@@ -203,7 +204,7 @@ export function LogSheet({ logSheet }: LogSheetProps) {
203204
{"Mid-\nnght"}
204205
</div>
205206
</div>
206-
<div className="flex w-14 shrink-0 items-center justify-center border-l border-gray-600 px-0.5 py-1">
207+
<div className="flex w-20 shrink-0 items-center justify-center border-l border-gray-600 px-0.5 py-1">
207208
Hrs
208209
</div>
209210
</div>
@@ -250,8 +251,8 @@ export function LogSheet({ logSheet }: LogSheetProps) {
250251
24 hour columns stay perfectly aligned with the labels above. */}
251252
<div className="w-10 shrink-0 bg-white" aria-hidden="true" />
252253
</div>
253-
<div className="flex w-14 shrink-0 items-center justify-center border-l border-gray-300 px-0.5 py-0.5 text-[11px] font-semibold tabular-nums">
254-
{totals[row.key].toFixed(2)}
254+
<div className="flex w-20 shrink-0 items-center justify-center border-l border-gray-300 px-0.5 py-0.5 text-[10px] font-semibold">
255+
{formatHM(totals[row.key])}
255256
</div>
256257
</div>
257258
))}
Lines changed: 141 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import type { DutyStatus, LogSheet } from "../types/api";
1+
import type { DutyStatus, LogSheet, TimelineEvent } from "../types/api";
2+
import { formatHM } from "../utils/formatDuration";
23

34
interface TimelineViewProps {
45
logSheets: LogSheet[];
@@ -18,6 +19,10 @@ const statusLabels: Record<DutyStatus, string> = {
1819
on_duty: "On-Duty",
1920
};
2021

22+
const REST_STATUSES: DutyStatus[] = ["off_duty", "sleeper_berth"];
23+
const DAY_MINUTES = 24 * 60;
24+
const MIN_VISUAL_MINUTES = 15;
25+
2126
function minuteOfDay(isoString: string): number {
2227
const match = isoString.match(/T(\d{2}):(\d{2})/);
2328
if (match) {
@@ -42,12 +47,78 @@ function formatDuration(minutes: number): string {
4247
return `${hrs}h ${mins}m`;
4348
}
4449

50+
function isRestStatus(status: DutyStatus): boolean {
51+
return REST_STATUSES.includes(status);
52+
}
53+
54+
function eventRange(event: TimelineEvent): { start: number; end: number } {
55+
const start = minuteOfDay(event.start_time);
56+
const endRaw = minuteOfDay(event.end_time);
57+
const end = endRaw <= start ? DAY_MINUTES : endRaw;
58+
return { start, end };
59+
}
60+
61+
function trailingMidnightRestMinutes(sheet: LogSheet): number {
62+
let total = 0;
63+
let expectedEnd = DAY_MINUTES;
64+
65+
for (let index = sheet.events.length - 1; index >= 0; index -= 1) {
66+
const event = sheet.events[index];
67+
const { start, end } = eventRange(event);
68+
69+
// Only count a single contiguous block that directly touches midnight.
70+
if (end !== expectedEnd || !isRestStatus(event.status)) break;
71+
72+
total += end - start;
73+
expectedEnd = start;
74+
}
75+
76+
return total;
77+
}
78+
79+
function leadingMidnightRestMinutes(sheet: LogSheet): number {
80+
let total = 0;
81+
let expectedStart = 0;
82+
83+
for (const event of sheet.events) {
84+
const { start, end } = eventRange(event);
85+
86+
// Only count a single contiguous block that begins at midnight.
87+
if (start !== expectedStart || !isRestStatus(event.status)) break;
88+
89+
total += end - start;
90+
expectedStart = end;
91+
}
92+
93+
return total;
94+
}
95+
96+
function boundaryContinuousRestMinutes(current: LogSheet, next: LogSheet): number {
97+
const trailing = trailingMidnightRestMinutes(current);
98+
const leading = leadingMidnightRestMinutes(next);
99+
100+
// A boundary-spanning continuous rest requires rest touching midnight
101+
// on BOTH sides of the day boundary.
102+
if (trailing === 0 || leading === 0) return 0;
103+
return trailing + leading;
104+
}
105+
106+
function restPillClasses(totalRestMinutes: number): string {
107+
if (totalRestMinutes >= 34 * 60) {
108+
return "border-green-200 bg-green-50 text-green-700 dark:border-green-500/40 dark:bg-green-500/10 dark:text-green-300";
109+
}
110+
if (totalRestMinutes >= 10 * 60) {
111+
return "border-green-200 bg-green-50 text-green-700 dark:border-green-500/40 dark:bg-green-500/10 dark:text-green-300";
112+
}
113+
return "border-red-200 bg-red-50 text-red-700 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-300";
114+
}
115+
45116
function dayRangePercent(start: string, end: string): { left: number; width: number } {
46117
const startMinutes = minuteOfDay(start);
47118
const endRaw = minuteOfDay(end);
48-
const endMinutes = endRaw <= startMinutes ? 24 * 60 : endRaw;
49-
const left = (startMinutes / (24 * 60)) * 100;
50-
const width = (Math.max(endMinutes - startMinutes, 15) / (24 * 60)) * 100;
119+
const endMinutes = endRaw <= startMinutes ? DAY_MINUTES : endRaw;
120+
const left = (startMinutes / DAY_MINUTES) * 100;
121+
const width = (Math.max(endMinutes - startMinutes, MIN_VISUAL_MINUTES) / DAY_MINUTES) * 100;
51122
return { left, width };
52123
}
53124

@@ -77,55 +148,74 @@ export function TimelineView({ logSheets }: TimelineViewProps) {
77148
</div>
78149

79150
<div className="space-y-6">
80-
{logSheets.map((sheet) => (
81-
<article
82-
key={sheet.date}
83-
className="rounded-lg border border-gray-200 bg-gray-50/40 p-4 dark:border-gray-700 dark:bg-gray-900/40"
84-
>
85-
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
86-
{sheet.date}
87-
</h3>
88-
89-
<div className="relative h-14 rounded-md border border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-900">
90-
{sheet.events.map((event, index) => {
91-
const { left, width } = dayRangePercent(event.start_time, event.end_time);
92-
const start = minuteOfDay(event.start_time);
93-
const endRaw = minuteOfDay(event.end_time);
94-
const end = endRaw <= start ? 24 * 60 : endRaw;
95-
const startLabel = start === 0 ? "Midnight" : formatClock(start);
96-
const endLabel = end === 24 * 60 ? "Midnight" : formatClock(end);
97-
return (
98-
<div
99-
key={`${event.start_time}-${index}`}
100-
className={`absolute top-2 h-10 cursor-pointer rounded-sm transition-all duration-200 hover:-translate-y-0.5 hover:scale-y-110 hover:shadow-md ${statusColors[event.status]}`}
101-
style={{ left: `${left}%`, width: `${width}%` }}
102-
title={`${statusLabels[event.status]}: ${startLabel} - ${endLabel} (${formatDuration(end - start)})`}
103-
/>
104-
);
105-
})}
106-
</div>
151+
{logSheets.map((sheet, index) => {
152+
const nextSheet = logSheets[index + 1];
153+
const restAcrossBoundary = nextSheet ? boundaryContinuousRestMinutes(sheet, nextSheet) : 0;
154+
const restart34Met = restAcrossBoundary >= 34 * 60;
155+
const restResetMet = restAcrossBoundary >= 10 * 60;
156+
157+
return (
158+
<div key={sheet.date} className="space-y-3">
159+
<article className="rounded-lg border border-gray-200 bg-gray-50/40 p-4 dark:border-gray-700 dark:bg-gray-900/40">
160+
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
161+
{sheet.date}
162+
</h3>
107163

108-
<div className="mt-3 grid grid-cols-2 gap-2 text-xs text-gray-700 md:grid-cols-4 dark:text-gray-300">
109-
<p>
110-
<span className="font-semibold text-gray-900 dark:text-gray-100">Driving:</span>{" "}
111-
{(sheet.totals.driving ?? 0).toFixed(2)}h
112-
</p>
113-
<p>
114-
<span className="font-semibold text-gray-900 dark:text-gray-100">On-duty:</span>{" "}
115-
{(sheet.totals.on_duty ?? 0).toFixed(2)}h
116-
</p>
117-
<p>
118-
<span className="font-semibold text-gray-900 dark:text-gray-100">Off-duty:</span>{" "}
119-
{(sheet.totals.off_duty ?? 0).toFixed(2)}h
120-
</p>
121-
<p>
122-
<span className="font-semibold text-gray-900 dark:text-gray-100">Sleeper:</span>{" "}
123-
{(sheet.totals.sleeper_berth ?? 0).toFixed(2)}h
124-
</p>
164+
<div className="relative h-14 rounded-md border border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-900">
165+
{sheet.events.map((event, eventIndex) => {
166+
const { left, width } = dayRangePercent(event.start_time, event.end_time);
167+
const { start, end } = eventRange(event);
168+
const startLabel = start === 0 ? "Midnight" : formatClock(start);
169+
const endLabel = end === DAY_MINUTES ? "Midnight" : formatClock(end);
170+
return (
171+
<div
172+
key={`${event.start_time}-${eventIndex}`}
173+
className={`absolute top-2 h-10 cursor-pointer rounded-sm transition-all duration-200 hover:-translate-y-0.5 hover:scale-y-110 hover:shadow-md ${statusColors[event.status]}`}
174+
style={{ left: `${left}%`, width: `${width}%` }}
175+
title={`${statusLabels[event.status]}: ${startLabel} - ${endLabel} (${formatDuration(end - start)})`}
176+
/>
177+
);
178+
})}
179+
</div>
180+
181+
<div className="mt-3 grid grid-cols-2 gap-2 text-xs text-gray-700 md:grid-cols-4 dark:text-gray-300">
182+
<p>
183+
<span className="font-semibold text-gray-900 dark:text-gray-100">Driving:</span>{" "}
184+
{formatHM(sheet.totals.driving ?? 0)}
185+
</p>
186+
<p>
187+
<span className="font-semibold text-gray-900 dark:text-gray-100">On-duty:</span>{" "}
188+
{formatHM(sheet.totals.on_duty ?? 0)}
189+
</p>
190+
<p>
191+
<span className="font-semibold text-gray-900 dark:text-gray-100">Off-duty:</span>{" "}
192+
{formatHM(sheet.totals.off_duty ?? 0)}
193+
</p>
194+
<p>
195+
<span className="font-semibold text-gray-900 dark:text-gray-100">Sleeper:</span>{" "}
196+
{formatHM(sheet.totals.sleeper_berth ?? 0)}
197+
</p>
198+
</div>
199+
</article>
200+
201+
{nextSheet ? (
202+
<div className="flex justify-center">
203+
<div
204+
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${restPillClasses(
205+
restAcrossBoundary,
206+
)}`}
207+
>
208+
{formatHM(restAcrossBoundary / 60)} continuous rest ·{" "}
209+
{restart34Met
210+
? "34h restart ✓"
211+
: `10h reset ${restResetMet ? "✓" : "×"}`}
212+
</div>
213+
</div>
214+
) : null}
125215
</div>
126-
</article>
127-
))}
216+
);
217+
})}
128218
</div>
129219
</section>
130220
);
131-
}
221+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export function formatHM(decimalHours: number): string {
2+
const totalMinutes = Math.round(decimalHours * 60);
3+
const h = Math.floor(totalMinutes / 60);
4+
const m = totalMinutes % 60;
5+
return `${h}h ${m.toString().padStart(2, "0")}m`;
6+
}

0 commit comments

Comments
 (0)