Skip to content

Commit ae5ede5

Browse files
committed
feat(ui): polish visual design and add light/dark theme
Visual polish: - Consistent card styling (subtle borders, soft shadows, dividers) - Empty-state placeholder before a trip is planned - FMCSA-style identification fields on log sheet header (Driver / Co-Driver / Vehicle No / Trailer No) - Duty-status legend on timeline view - Better form label hierarchy, focus rings, and a more prominent "Plan Trip" button with arrow icon and loading spinner - "Plan New Trip" promoted to top of results with icon - More breathing room between sections; mobile-friendly padding - Right midnight cell on log grid widened with a matching data spacer so the 24 hour columns stay aligned with their labels Dark mode: - Class-based Tailwind dark mode with localStorage persistence - Theme applied pre-mount to avoid flash; syncs with OS preference - Sun/moon toggle in page header with aria-label - FMCSA grid stays paper-white inside a dark card frame - Map tiles intentionally kept light; surrounding chrome themed
1 parent 9afb595 commit ae5ede5

10 files changed

Lines changed: 507 additions & 97 deletions

File tree

frontend/src/components/LogSheet.tsx

Lines changed: 83 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -85,40 +85,84 @@ export function LogSheet({ logSheet }: LogSheetProps) {
8585
const totalHours = statusRows.reduce((sum, row) => sum + totals[row.key], 0);
8686

8787
return (
88-
<section className="rounded-xl border border-gray-300 bg-white p-3 shadow-lg">
89-
<h3 className="mb-2 text-lg font-semibold text-gray-900">Driver&apos;s Daily Log</h3>
90-
91-
<div className="mb-2 flex flex-wrap gap-x-4 gap-y-1 rounded border border-gray-300 px-2 py-1.5 text-xs text-gray-800">
92-
<span>
93-
<span className="font-semibold">Date:</span> {logSheet.date}
94-
</span>
95-
<span>
96-
<span className="font-semibold">Carrier:</span> Spotter ELD
97-
</span>
98-
<span>
99-
<span className="font-semibold">Total Miles:</span> {logSheet.total_miles.toFixed(1)}
100-
</span>
88+
<section className="rounded-xl border border-gray-300 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800">
89+
<div className="mb-3 flex flex-wrap items-end justify-between gap-2 border-b border-gray-200 pb-2 dark:border-gray-700">
90+
<div>
91+
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">
92+
Driver&apos;s Daily Log
93+
</h3>
94+
<p className="text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400">
95+
(One per 24-hour period)
96+
</p>
97+
</div>
98+
<div className="flex flex-wrap gap-x-4 gap-y-0.5 text-xs text-gray-800 dark:text-gray-200">
99+
<span>
100+
<span className="font-semibold">Date:</span> {logSheet.date}
101+
</span>
102+
<span>
103+
<span className="font-semibold">Carrier:</span> Spotter ELD
104+
</span>
105+
<span>
106+
<span className="font-semibold">Total Miles:</span>{" "}
107+
{logSheet.total_miles.toFixed(1)}
108+
</span>
109+
</div>
101110
</div>
102111

103-
{/* Fluid layout: 24 hour bands × 4 fifteen-minute ticks — fits container width, no horizontal scroll */}
104-
<div className="w-full max-w-full overflow-hidden rounded border border-gray-300">
105-
<div className="flex min-h-[22px] border-b border-gray-300 bg-black text-[9px] font-semibold leading-none text-white">
112+
{/* FMCSA-style identification fields. Blank lines preserve the look of the
113+
paper form; values are intentionally empty in this app. */}
114+
<div className="mb-3 grid gap-2 text-[11px] text-gray-800 sm:grid-cols-2 dark:text-gray-200">
115+
<div className="flex items-end gap-2 border-b border-gray-300 pb-0.5 dark:border-gray-600">
116+
<span className="font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">
117+
Driver:
118+
</span>
119+
<span className="flex-1">&nbsp;</span>
120+
</div>
121+
<div className="flex items-end gap-2 border-b border-gray-300 pb-0.5 dark:border-gray-600">
122+
<span className="font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">
123+
Co-Driver:
124+
</span>
125+
<span className="flex-1 text-gray-500 dark:text-gray-500">N/A</span>
126+
</div>
127+
<div className="flex items-end gap-2 border-b border-gray-300 pb-0.5 dark:border-gray-600">
128+
<span className="font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">
129+
Vehicle No:
130+
</span>
131+
<span className="flex-1">&nbsp;</span>
132+
</div>
133+
<div className="flex items-end gap-2 border-b border-gray-300 pb-0.5 dark:border-gray-600">
134+
<span className="font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-400">
135+
Trailer No:
136+
</span>
137+
<span className="flex-1">&nbsp;</span>
138+
</div>
139+
</div>
140+
141+
{/* Fluid layout: 24 hour bands × 4 fifteen-minute ticks — fits container
142+
width, no horizontal scroll. The grid is intentionally anchored to a
143+
white "paper" surface even in dark mode, matching the FMCSA paper
144+
form — it sits inside the dark card like a printed sheet. */}
145+
<div className="w-full max-w-full overflow-hidden rounded border border-gray-300 bg-white text-gray-900 dark:border-gray-600">
146+
<div className="flex min-h-[24px] border-b border-gray-300 bg-black text-[10px] font-semibold leading-none text-white">
106147
<div className="flex w-[5.25rem] shrink-0 items-center justify-center border-r border-gray-600 px-0.5 py-1">
107148
Status
108149
</div>
150+
{/* 24 hour-label cells matching the 24 hour columns in the data
151+
rows below, plus a fixed-width "Mid-nght" boundary marker on the
152+
right. The data rows have a matching empty spacer of the same
153+
width so the 24 hour columns stay aligned with their labels. */}
109154
<div className="flex min-w-0 flex-1">
110155
{HOURS.map((hour) => (
111156
<div
112157
key={hour}
113158
className={`flex min-w-0 flex-1 items-center justify-center whitespace-pre-line border-r border-gray-600 py-0.5 text-center leading-tight ${
114-
hour === 0 || hour === 12 ? "text-[8px] font-bold text-yellow-300" : ""
159+
hour === 0 || hour === 12 ? "text-[9px] font-bold text-yellow-300" : ""
115160
}`}
116161
>
117162
{HOUR_LABELS[hour]}
118163
</div>
119164
))}
120-
{/* Right midnight boundary marker */}
121-
<div className="flex w-[2.2ch] shrink-0 items-center justify-center whitespace-pre-line border-l border-gray-600 py-0.5 text-center text-[8px] font-bold leading-tight text-yellow-300">
165+
<div className="flex w-10 shrink-0 items-center justify-center whitespace-pre-line py-0.5 text-center text-[9px] font-bold leading-tight text-yellow-300">
122166
{"Mid-\nnght"}
123167
</div>
124168
</div>
@@ -134,7 +178,7 @@ export function LogSheet({ logSheet }: LogSheetProps) {
134178
</div>
135179
<div className="flex min-w-0 flex-1">
136180
{HOURS.map((hour) => (
137-
<div key={hour} className="flex min-w-0 flex-1 border-r border-gray-200 last:border-r-0">
181+
<div key={hour} className="flex min-w-0 flex-1 border-r border-gray-300">
138182
{[0, 1, 2, 3].map((quarter) => {
139183
const slot = hour * 4 + quarter;
140184
const active = slots[slot] === row.key;
@@ -149,6 +193,9 @@ export function LogSheet({ logSheet }: LogSheetProps) {
149193
})}
150194
</div>
151195
))}
196+
{/* Empty spacer matching the right "Mid-nght" header cell so the
197+
24 hour columns stay perfectly aligned with the labels above. */}
198+
<div className="w-10 shrink-0 bg-white" aria-hidden="true" />
152199
</div>
153200
<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">
154201
{totals[row.key].toFixed(2)}
@@ -158,17 +205,27 @@ export function LogSheet({ logSheet }: LogSheetProps) {
158205
</div>
159206

160207
<div className="mt-2 grid gap-2 md:grid-cols-[1fr_auto] md:items-start">
161-
<div className="rounded border border-gray-300 px-2 py-1.5">
162-
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-gray-700">Remarks</p>
163-
<ul className="space-y-0.5 text-[11px] leading-snug text-gray-700">
208+
<div className="rounded border border-gray-300 px-2 py-1.5 dark:border-gray-600 dark:bg-gray-900/40">
209+
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
210+
Remarks
211+
</p>
212+
<ul className="space-y-0.5 text-[11px] leading-snug text-gray-700 dark:text-gray-200">
164213
{remarks.map((remark, index) => (
165214
<li key={`${remark}-${index}`}>{remark}</li>
166215
))}
167216
</ul>
168217
</div>
169-
<div className="rounded border border-gray-300 px-2 py-1.5 text-[11px] md:min-w-[11rem]">
170-
<p className="mb-1 font-semibold uppercase tracking-wide text-gray-700">Daily Check</p>
171-
<p className={Math.abs(totalHours - 24) <= 0.25 ? "font-semibold text-green-700" : "font-semibold text-red-700"}>
218+
<div className="rounded border border-gray-300 px-2 py-1.5 text-[11px] md:min-w-[11rem] dark:border-gray-600 dark:bg-gray-900/40">
219+
<p className="mb-1 font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
220+
Daily Check
221+
</p>
222+
<p
223+
className={
224+
Math.abs(totalHours - 24) <= 0.25
225+
? "font-semibold text-green-700 dark:text-green-400"
226+
: "font-semibold text-red-700 dark:text-red-400"
227+
}
228+
>
172229
Sum: {totalHours.toFixed(2)} / 24.00 h
173230
</p>
174231
</div>

frontend/src/components/RouteMap.tsx

Lines changed: 47 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -186,43 +186,61 @@ export function RouteMap({
186186
// Auto-fit only after a trip is planned. Pin drops must never move the map.
187187
const fitEnabled = events.length > 0;
188188

189+
const noPinsSet = pickedEntries.length === 0;
190+
const noRoute = events.length === 0;
191+
189192
return (
190-
<div className="rounded-xl bg-white p-4 shadow-lg">
191-
<h2 className="mb-3 text-xl font-semibold text-gray-900">Route Map</h2>
193+
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-800">
194+
<div className="mb-3 flex items-center justify-between border-b border-gray-100 pb-3 dark:border-gray-700">
195+
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Route Map</h2>
196+
{events.length > 0 ? (
197+
<span className="rounded-full bg-blue-50 px-2.5 py-0.5 text-[11px] font-medium text-blue-700 dark:bg-blue-500/15 dark:text-blue-300">
198+
Route planned
199+
</span>
200+
) : null}
201+
</div>
192202

193203
{pickerEnabled ? (
194-
<div className="mb-2 flex flex-wrap items-center gap-2">
195-
{(["current", "pickup", "dropoff"] as LocationType[]).map((type) => {
196-
const active = pickingMode === type;
197-
return (
198-
<button
199-
key={type}
200-
type="button"
201-
onClick={() => onModeChange?.(active ? null : type)}
202-
className={`rounded border px-3 py-1 text-xs font-medium transition-colors ${
203-
active
204-
? "border-blue-600 bg-blue-600 text-white"
205-
: "border-gray-300 bg-white text-gray-700 hover:border-blue-400"
206-
}`}
207-
>
208-
{type === "current"
209-
? "📍 Set Start"
210-
: type === "pickup"
211-
? "🟢 Set Pickup"
212-
: "🔴 Set Dropoff"}
213-
</button>
214-
);
215-
})}
216-
{pickingMode ? (
217-
<span className="self-center text-xs text-blue-600">
218-
Click the map to set {pickingMode} location
219-
</span>
204+
<div className="mb-3 space-y-2">
205+
<div className="flex flex-wrap items-center gap-2">
206+
{(["current", "pickup", "dropoff"] as LocationType[]).map((type) => {
207+
const active = pickingMode === type;
208+
return (
209+
<button
210+
key={type}
211+
type="button"
212+
onClick={() => onModeChange?.(active ? null : type)}
213+
className={`rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${
214+
active
215+
? "border-blue-600 bg-blue-600 text-white shadow-sm dark:border-blue-400 dark:bg-blue-500"
216+
: "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"
217+
}`}
218+
>
219+
{type === "current"
220+
? "📍 Set Start"
221+
: type === "pickup"
222+
? "🟢 Set Pickup"
223+
: "🔴 Set Dropoff"}
224+
</button>
225+
);
226+
})}
227+
{pickingMode ? (
228+
<span className="self-center text-xs font-medium text-blue-600 dark:text-blue-300">
229+
Click the map to set {pickingMode} location
230+
</span>
231+
) : null}
232+
</div>
233+
{!pickingMode && noPinsSet && noRoute ? (
234+
<p className="text-xs text-gray-500 dark:text-gray-400">
235+
Click the buttons above to set locations on the map, or type city
236+
names in the form.
237+
</p>
220238
) : null}
221239
</div>
222240
) : null}
223241

224242
<div
225-
className="h-96 overflow-hidden rounded-lg border border-gray-200"
243+
className="h-96 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700"
226244
style={{ cursor: pickingMode ? "crosshair" : "default" }}
227245
>
228246
<MapContainer center={[39.5, -98.35]} zoom={4} className="h-full w-full">
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { useTheme } from "../lib/theme";
2+
3+
/**
4+
* Header icon button that flips between light and dark themes.
5+
* Shows a moon when light mode is active (click to go dark) and a sun when
6+
* dark mode is active (click to go light).
7+
*/
8+
export function ThemeToggle() {
9+
const { theme, toggleTheme } = useTheme();
10+
const isDark = theme === "dark";
11+
const label = isDark ? "Switch to light mode" : "Switch to dark mode";
12+
13+
return (
14+
<button
15+
type="button"
16+
onClick={toggleTheme}
17+
aria-label={label}
18+
title={label}
19+
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-300 bg-white text-gray-700 shadow-sm transition-colors hover:border-blue-400 hover:bg-blue-50 hover:text-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500/40 focus:ring-offset-2 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:hover:border-blue-400 dark:hover:bg-blue-500/10 dark:hover:text-blue-300 dark:focus:ring-offset-gray-800"
20+
>
21+
{isDark ? (
22+
<svg
23+
xmlns="http://www.w3.org/2000/svg"
24+
fill="none"
25+
viewBox="0 0 24 24"
26+
strokeWidth={1.8}
27+
stroke="currentColor"
28+
className="h-5 w-5"
29+
aria-hidden="true"
30+
>
31+
<path
32+
strokeLinecap="round"
33+
strokeLinejoin="round"
34+
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
35+
/>
36+
</svg>
37+
) : (
38+
<svg
39+
xmlns="http://www.w3.org/2000/svg"
40+
fill="none"
41+
viewBox="0 0 24 24"
42+
strokeWidth={1.8}
43+
stroke="currentColor"
44+
className="h-5 w-5"
45+
aria-hidden="true"
46+
>
47+
<path
48+
strokeLinecap="round"
49+
strokeLinejoin="round"
50+
d="M21.752 15.002A9.72 9.72 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
51+
/>
52+
</svg>
53+
)}
54+
</button>
55+
);
56+
}

frontend/src/components/TimelineView.tsx

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,42 @@ function dayRangePercent(start: string, end: string): { left: number; width: num
2121
return { left, width };
2222
}
2323

24+
const legendItems: Array<{ status: DutyStatus; label: string }> = [
25+
{ status: "driving", label: "Driving" },
26+
{ status: "on_duty", label: "On-Duty" },
27+
{ status: "off_duty", label: "Off-Duty" },
28+
{ status: "sleeper_berth", label: "Sleeper" },
29+
];
30+
2431
export function TimelineView({ logSheets }: TimelineViewProps) {
2532
return (
26-
<section className="rounded-xl bg-white p-6 shadow-lg">
27-
<h2 className="mb-4 text-2xl font-semibold text-gray-900">Timeline View</h2>
33+
<section className="rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-800">
34+
<div className="mb-5 flex flex-wrap items-center justify-between gap-3 border-b border-gray-100 pb-4 dark:border-gray-700">
35+
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Timeline View</h2>
36+
<ul className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-gray-600 dark:text-gray-300">
37+
{legendItems.map((item) => (
38+
<li key={item.status} className="flex items-center gap-1.5">
39+
<span
40+
className={`inline-block h-3 w-3 rounded-sm border border-gray-300 dark:border-gray-600 ${statusColors[item.status]}`}
41+
aria-hidden="true"
42+
/>
43+
<span className="font-medium">{item.label}</span>
44+
</li>
45+
))}
46+
</ul>
47+
</div>
2848

2949
<div className="space-y-6">
3050
{logSheets.map((sheet) => (
31-
<article key={sheet.date} className="rounded-lg border border-gray-200 p-4">
32-
<h3 className="mb-3 text-lg font-medium text-gray-800">{sheet.date}</h3>
51+
<article
52+
key={sheet.date}
53+
className="rounded-lg border border-gray-200 bg-gray-50/40 p-4 dark:border-gray-700 dark:bg-gray-900/40"
54+
>
55+
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-300">
56+
{sheet.date}
57+
</h3>
3358

34-
<div className="relative h-14 rounded-md border border-gray-300 bg-gray-50">
59+
<div className="relative h-14 rounded-md border border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-900">
3560
{sheet.events.map((event, index) => {
3661
const { left, width } = dayRangePercent(event.start_time, event.end_time);
3762
return (
@@ -45,11 +70,23 @@ export function TimelineView({ logSheets }: TimelineViewProps) {
4570
})}
4671
</div>
4772

48-
<div className="mt-3 grid grid-cols-2 gap-2 text-sm text-gray-700 md:grid-cols-4">
49-
<p>Driving: {(sheet.totals.driving ?? 0).toFixed(2)}h</p>
50-
<p>On-duty: {(sheet.totals.on_duty ?? 0).toFixed(2)}h</p>
51-
<p>Off-duty: {(sheet.totals.off_duty ?? 0).toFixed(2)}h</p>
52-
<p>Sleeper: {(sheet.totals.sleeper_berth ?? 0).toFixed(2)}h</p>
73+
<div className="mt-3 grid grid-cols-2 gap-2 text-xs text-gray-700 md:grid-cols-4 dark:text-gray-300">
74+
<p>
75+
<span className="font-semibold text-gray-900 dark:text-gray-100">Driving:</span>{" "}
76+
{(sheet.totals.driving ?? 0).toFixed(2)}h
77+
</p>
78+
<p>
79+
<span className="font-semibold text-gray-900 dark:text-gray-100">On-duty:</span>{" "}
80+
{(sheet.totals.on_duty ?? 0).toFixed(2)}h
81+
</p>
82+
<p>
83+
<span className="font-semibold text-gray-900 dark:text-gray-100">Off-duty:</span>{" "}
84+
{(sheet.totals.off_duty ?? 0).toFixed(2)}h
85+
</p>
86+
<p>
87+
<span className="font-semibold text-gray-900 dark:text-gray-100">Sleeper:</span>{" "}
88+
{(sheet.totals.sleeper_berth ?? 0).toFixed(2)}h
89+
</p>
5390
</div>
5491
</article>
5592
))}

0 commit comments

Comments
 (0)