Skip to content

Commit 6f9dc42

Browse files
committed
feat(ui): add guided planning flow and duty hover interactions
Improve trip-planning clarity by guiding drivers through each required action in order (map points, cycle hours, then submit), while making timeline and log duty periods easier to interpret via animated hover emphasis and human-readable time-range tooltips.
1 parent ae5ede5 commit 6f9dc42

5 files changed

Lines changed: 270 additions & 26 deletions

File tree

frontend/src/components/LogSheet.tsx

Lines changed: 69 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ interface LogSheetProps {
55
}
66

77
const statusRows: Array<{ key: DutyStatus; label: string; color: string }> = [
8-
{ key: "off_duty", label: "Off Duty", color: "bg-gray-300" },
9-
{ key: "sleeper_berth", label: "Sleeper Berth", color: "bg-purple-400" },
8+
{ key: "off_duty", label: "Off Duty", color: "bg-slate-400" },
9+
{ key: "sleeper_berth", label: "Sleeper Berth", color: "bg-purple-500" },
1010
{ key: "driving", label: "Driving", color: "bg-blue-500" },
11-
{ key: "on_duty", label: "On Duty (Not Driving)", color: "bg-yellow-400" },
11+
{ key: "on_duty", label: "On Duty (Not Driving)", color: "bg-amber-400" },
1212
];
1313

1414
/**
@@ -27,6 +27,42 @@ function minuteOfDay(isoString: string): number {
2727
return d.getHours() * 60 + d.getMinutes();
2828
}
2929

30+
interface DaySegment {
31+
status: DutyStatus;
32+
start: number;
33+
end: number;
34+
}
35+
36+
function formatClock(minute: number): string {
37+
const hours24 = Math.floor(minute / 60) % 24;
38+
const minutes = minute % 60;
39+
const period = hours24 >= 12 ? "PM" : "AM";
40+
const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
41+
return `${hours12}:${minutes.toString().padStart(2, "0")} ${period}`;
42+
}
43+
44+
function formatDuration(minutes: number): string {
45+
const hrs = Math.floor(minutes / 60);
46+
const mins = minutes % 60;
47+
if (mins === 0) return `${hrs}h`;
48+
return `${hrs}h ${mins}m`;
49+
}
50+
51+
function segmentTitle(label: string, start: number, end: number): string {
52+
const startLabel = start === 0 ? "Midnight" : formatClock(start);
53+
const endLabel = end === 24 * 60 ? "Midnight" : formatClock(end);
54+
return `${label}: ${startLabel} - ${endLabel} (${formatDuration(end - start)})`;
55+
}
56+
57+
function buildDaySegments(logSheet: LogSheetType): DaySegment[] {
58+
return logSheet.events.map((event) => {
59+
const start = minuteOfDay(event.start_time);
60+
const rawEnd = minuteOfDay(event.end_time);
61+
const end = rawEnd <= start ? 24 * 60 : Math.max(start + 1, rawEnd);
62+
return { status: event.status, start, end };
63+
});
64+
}
65+
3066
function buildDaySlots(logSheet: LogSheetType): DutyStatus[] {
3167
const slots: DutyStatus[] = Array.from({ length: 96 }, () => "off_duty");
3268

@@ -79,6 +115,7 @@ const HOUR_LABELS: string[] = [
79115
];
80116

81117
export function LogSheet({ logSheet }: LogSheetProps) {
118+
const segments = buildDaySegments(logSheet);
82119
const slots = buildDaySlots(logSheet);
83120
const totals = totalsFromSlots(slots);
84121
const remarks = logSheet.events.map((event) => `${event.remark} (${event.location})`);
@@ -177,22 +214,38 @@ export function LogSheet({ logSheet }: LogSheetProps) {
177214
{row.label}
178215
</div>
179216
<div className="flex min-w-0 flex-1">
180-
{HOURS.map((hour) => (
181-
<div key={hour} className="flex min-w-0 flex-1 border-r border-gray-300">
182-
{[0, 1, 2, 3].map((quarter) => {
183-
const slot = hour * 4 + quarter;
184-
const active = slots[slot] === row.key;
185-
return (
217+
<div className="relative min-w-0 flex-1">
218+
<div className="flex min-w-0 flex-1">
219+
{HOURS.map((hour) => (
220+
<div key={hour} className="flex min-w-0 flex-1 border-r border-gray-300">
221+
{[0, 1, 2, 3].map((quarter) => (
222+
<div
223+
key={quarter}
224+
className="min-h-[14px] flex-1 border-r border-gray-200 bg-gray-100 last:border-r-0"
225+
/>
226+
))}
227+
</div>
228+
))}
229+
</div>
230+
231+
{/* Duty periods rendered as continuous overlays so each entire
232+
section can be hovered and emphasized as one visual block. */}
233+
<div className="pointer-events-none absolute inset-0">
234+
{segments
235+
.filter((segment) => segment.status === row.key)
236+
.map((segment, index) => (
186237
<div
187-
key={quarter}
188-
className={`min-h-[14px] flex-1 border-r border-gray-200 last:border-r-0 ${
189-
active ? row.color : "bg-gray-100"
190-
}`}
238+
key={`${row.key}-${segment.start}-${segment.end}-${index}`}
239+
className={`pointer-events-auto absolute bottom-0 top-0 cursor-pointer rounded-[1px] transition-all duration-200 hover:z-20 hover:scale-y-125 hover:shadow-sm ${row.color}`}
240+
style={{
241+
left: `${(segment.start / (24 * 60)) * 100}%`,
242+
width: `${((segment.end - segment.start) / (24 * 60)) * 100}%`,
243+
}}
244+
title={segmentTitle(row.label, segment.start, segment.end)}
191245
/>
192-
);
193-
})}
246+
))}
194247
</div>
195-
))}
248+
</div>
196249
{/* Empty spacer matching the right "Mid-nght" header cell so the
197250
24 hour columns stay perfectly aligned with the labels above. */}
198251
<div className="w-10 shrink-0 bg-white" aria-hidden="true" />

frontend/src/components/RouteMap.tsx

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ interface RouteMapProps {
2626
onModeChange?: (mode: LocationType | null) => void;
2727
onLocationPicked?: (type: LocationType, lat: number, lng: number) => void;
2828
viewResetKey?: number;
29+
guidedButton?: LocationType | null;
30+
guideMapClick?: boolean;
2931
}
3032

3133
const PICKED_COLORS: Record<LocationType, string> = {
@@ -148,6 +150,8 @@ export function RouteMap({
148150
onModeChange,
149151
onLocationPicked,
150152
viewResetKey = 0,
153+
guidedButton = null,
154+
guideMapClick = false,
151155
}: RouteMapProps) {
152156
const pickerEnabled = Boolean(onModeChange && onLocationPicked);
153157
const pickedEntries = pickedLocations
@@ -205,6 +209,7 @@ export function RouteMap({
205209
<div className="flex flex-wrap items-center gap-2">
206210
{(["current", "pickup", "dropoff"] as LocationType[]).map((type) => {
207211
const active = pickingMode === type;
212+
const guided = guidedButton === type;
208213
return (
209214
<button
210215
key={type}
@@ -214,6 +219,10 @@ export function RouteMap({
214219
active
215220
? "border-blue-600 bg-blue-600 text-white shadow-sm dark:border-blue-400 dark:bg-blue-500"
216221
: "border-gray-300 bg-white text-gray-700 hover:border-blue-400 hover:bg-blue-50/40 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:hover:border-blue-400 dark:hover:bg-blue-500/10"
222+
} ${
223+
guided
224+
? "animate-pulse ring-2 ring-blue-400 ring-offset-2 ring-offset-white dark:ring-blue-500/70 dark:ring-offset-gray-800"
225+
: ""
217226
}`}
218227
>
219228
{type === "current"
@@ -240,9 +249,34 @@ export function RouteMap({
240249
) : null}
241250

242251
<div
243-
className="h-96 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700"
252+
className={`relative h-96 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700 ${
253+
guideMapClick
254+
? "animate-pulse ring-2 ring-blue-500 ring-offset-2 ring-offset-white dark:ring-blue-500/70 dark:ring-offset-gray-800"
255+
: ""
256+
}`}
244257
style={{ cursor: pickingMode ? "crosshair" : "default" }}
245258
>
259+
{guideMapClick ? (
260+
<div className="pointer-events-none absolute inset-0 z-[500] flex items-center justify-center">
261+
<div className="animate-bounce rounded-full border-2 border-blue-400 bg-blue-500/20 p-3 text-blue-700 shadow-md backdrop-blur-[1px] dark:border-blue-300 dark:bg-blue-400/20 dark:text-blue-200">
262+
<svg
263+
xmlns="http://www.w3.org/2000/svg"
264+
fill="none"
265+
viewBox="0 0 24 24"
266+
strokeWidth={2}
267+
stroke="currentColor"
268+
className="h-7 w-7"
269+
aria-hidden="true"
270+
>
271+
<path
272+
strokeLinecap="round"
273+
strokeLinejoin="round"
274+
d="M15.75 15.75L12 20.25m0 0l-3.75-4.5M12 20.25V3.75"
275+
/>
276+
</svg>
277+
</div>
278+
</div>
279+
) : null}
246280
<MapContainer center={[39.5, -98.35]} zoom={4} className="h-full w-full">
247281
<TileLayer
248282
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'

frontend/src/components/TimelineView.tsx

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,41 @@ const statusColors: Record<DutyStatus, string> = {
1111
on_duty: "bg-yellow-400",
1212
};
1313

14+
const statusLabels: Record<DutyStatus, string> = {
15+
off_duty: "Off-Duty",
16+
sleeper_berth: "Sleeper Berth",
17+
driving: "Driving",
18+
on_duty: "On-Duty",
19+
};
20+
21+
function minuteOfDay(isoString: string): number {
22+
const match = isoString.match(/T(\d{2}):(\d{2})/);
23+
if (match) {
24+
return parseInt(match[1], 10) * 60 + parseInt(match[2], 10);
25+
}
26+
const d = new Date(isoString);
27+
return d.getHours() * 60 + d.getMinutes();
28+
}
29+
30+
function formatClock(minute: number): string {
31+
const hours24 = Math.floor(minute / 60) % 24;
32+
const minutes = minute % 60;
33+
const period = hours24 >= 12 ? "PM" : "AM";
34+
const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
35+
return `${hours12}:${minutes.toString().padStart(2, "0")} ${period}`;
36+
}
37+
38+
function formatDuration(minutes: number): string {
39+
const hrs = Math.floor(minutes / 60);
40+
const mins = minutes % 60;
41+
if (mins === 0) return `${hrs}h`;
42+
return `${hrs}h ${mins}m`;
43+
}
44+
1445
function dayRangePercent(start: string, end: string): { left: number; width: number } {
15-
const startDate = new Date(start);
16-
const endDate = new Date(end);
17-
const startMinutes = startDate.getHours() * 60 + startDate.getMinutes();
18-
const endMinutes = endDate.getHours() * 60 + endDate.getMinutes();
46+
const startMinutes = minuteOfDay(start);
47+
const endRaw = minuteOfDay(end);
48+
const endMinutes = endRaw <= startMinutes ? 24 * 60 : endRaw;
1949
const left = (startMinutes / (24 * 60)) * 100;
2050
const width = (Math.max(endMinutes - startMinutes, 15) / (24 * 60)) * 100;
2151
return { left, width };
@@ -59,12 +89,17 @@ export function TimelineView({ logSheets }: TimelineViewProps) {
5989
<div className="relative h-14 rounded-md border border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-900">
6090
{sheet.events.map((event, index) => {
6191
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);
6297
return (
6398
<div
6499
key={`${event.start_time}-${index}`}
65-
className={`absolute top-2 h-10 rounded-sm ${statusColors[event.status]}`}
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]}`}
66101
style={{ left: `${left}%`, width: `${width}%` }}
67-
title={`${event.status}: ${event.location}`}
102+
title={`${statusLabels[event.status]}: ${startLabel} - ${endLabel} (${formatDuration(end - start)})`}
68103
/>
69104
);
70105
})}

frontend/src/components/TripInputForm.tsx

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ interface TripInputFormProps {
1919
errorMessage?: string;
2020
pickedLocations?: Partial<Record<LocationType, PickedLocation>>;
2121
resetKey?: number;
22+
highlightPlanTrip?: boolean;
23+
highlightCycleHours?: boolean;
24+
onCycleHoursInteracted?: () => void;
2225
}
2326

2427
const LOCATION_FIELD: Record<LocationType, keyof FormValues> = {
@@ -33,6 +36,9 @@ export function TripInputForm({
3336
errorMessage,
3437
pickedLocations,
3538
resetKey,
39+
highlightPlanTrip = false,
40+
highlightCycleHours = false,
41+
onCycleHoursInteracted,
3642
}: TripInputFormProps) {
3743
const {
3844
register,
@@ -72,6 +78,9 @@ export function TripInputForm({
7278
"w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 outline-none transition-colors focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100 dark:placeholder:text-gray-500 dark:focus:border-blue-400 dark:focus:ring-blue-400/30";
7379
const labelClass =
7480
"mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400";
81+
const shouldGuidePlanTrip = highlightPlanTrip && !isLoading;
82+
const shouldGuideCycleHours = highlightCycleHours && !isLoading;
83+
const cycleHoursField = register("cycle_hours_used", { valueAsNumber: true });
7584

7685
return (
7786
<div className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-800">
@@ -118,14 +127,25 @@ export function TripInputForm({
118127
)}
119128
</div>
120129

121-
<div>
130+
<div
131+
className={
132+
shouldGuideCycleHours
133+
? "animate-pulse rounded-lg ring-2 ring-blue-400/80 ring-offset-2 ring-offset-white dark:ring-blue-500/70 dark:ring-offset-gray-800"
134+
: ""
135+
}
136+
>
122137
<label className={labelClass}>Cycle Hours Used</label>
123138
<input
124139
type="number"
125140
step="0.1"
126141
min={0}
127142
max={70}
128-
{...register("cycle_hours_used", { valueAsNumber: true })}
143+
{...cycleHoursField}
144+
onFocus={() => onCycleHoursInteracted?.()}
145+
onChange={(event) => {
146+
cycleHoursField.onChange(event);
147+
onCycleHoursInteracted?.();
148+
}}
129149
className={inputClass}
130150
/>
131151
<p className="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
@@ -139,7 +159,11 @@ export function TripInputForm({
139159
<button
140160
type="submit"
141161
disabled={isLoading}
142-
className="group flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-700 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-70 disabled:hover:shadow-sm dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-offset-gray-800"
162+
className={`group flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-700 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-70 disabled:hover:shadow-sm dark:bg-blue-500 dark:hover:bg-blue-600 dark:focus:ring-offset-gray-800 ${
163+
shouldGuidePlanTrip
164+
? "animate-pulse ring-4 ring-blue-300/70 ring-offset-2 ring-offset-white dark:ring-blue-500/40 dark:ring-offset-gray-800"
165+
: ""
166+
}`}
143167
>
144168
{isLoading ? (
145169
<>

0 commit comments

Comments
 (0)