Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 23 additions & 75 deletions app/ui/src/app/driver_assist/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";

import {
loadSessionFromFile,
loadSessionFromText,
} from "@/lib/driver-route/importSession";
import { transformSessionToDriverRoute } from "@/lib/driver-route/transformSession";
import { loadDriverRouteFromText } from "@/lib/driver-route/importSession";
import type { DeliveryStop, DriverRoute } from "@/lib/driver-route/types";

import DriverFooter from "./components/DriverFooter";
Expand All @@ -24,6 +20,7 @@ import {
readUploadedRouteFile,
} from "./storage";
import { styles } from "./styles";
import { ROUTE_UPLOAD_ERROR_KEY } from "@/app/upload-route/routeUploadValidation";

function openNavigation(stop: DeliveryStop) {
// Prefer exact coordinates from the route file; fall back to the address if
Expand Down Expand Up @@ -51,7 +48,6 @@ function openPhone(stop: DeliveryStop) {

export default function DriverAssistPwaPage() {
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const topRef = useRef<HTMLDivElement>(null);
const remainingRef = useRef<HTMLDivElement>(null);
const deliveredRef = useRef<HTMLDivElement>(null);
Expand All @@ -63,45 +59,49 @@ export default function DriverAssistPwaPage() {
"Customer unavailable",
);
const [reportDetails, setReportDetails] = useState("");
const [error, setError] = useState<string | null>(null);
const [isImporting, setIsImporting] = useState(false);
const [hasCheckedRoute, setHasCheckedRoute] = useState(false);

useEffect(() => {
// The upload page hands off the raw JSON through sessionStorage so this
// page can import once, save the driver shape, and avoid bouncing back.
const uploadedRoute = readUploadedRouteFile();
let importFailed = false;

if (uploadedRoute) {
try {
const session = loadSessionFromText(uploadedRoute.content);
const nextRoute = transformSessionToDriverRoute(session);
const nextRoute = loadDriverRouteFromText(uploadedRoute.content);
persistRoute(nextRoute);
clearUploadedRouteFile();
setRoute(nextRoute);
setOpenId(nextRoute.stops[0]?.id || null);
setHasCheckedRoute(true);
queueMicrotask(() => {
setRoute(nextRoute);
setOpenId(nextRoute.stops[0]?.id || null);
setHasCheckedRoute(true);
});
return;
} catch (importError) {
importFailed = true;
setError(
const message =
importError instanceof Error
? importError.message
: "Please upload a valid JSON file.",
);
: "Please upload a valid JSON file.";
clearUploadedRouteFile();
sessionStorage.setItem(ROUTE_UPLOAD_ERROR_KEY, message);
router.replace("/upload-route");
return;
}
}

// Reloading the PWA should keep the driver exactly where they left off.
const savedRoute = readSavedRoute();
setRoute(savedRoute);
setOpenId(savedRoute?.stops[0]?.id || null);
setHasCheckedRoute(true);

if (!savedRoute && !importFailed) {
if (!savedRoute) {
router.replace("/upload-route");
return;
}

queueMicrotask(() => {
setRoute(savedRoute);
setOpenId(savedRoute.stops[0]?.id || null);
setHasCheckedRoute(true);
});
}, [router]);

useEffect(() => {
Expand All @@ -127,29 +127,6 @@ export default function DriverAssistPwaPage() {
};
}, [route]);

const importRoute = async (file: File) => {
setError(null);
setIsImporting(true);

try {
// Direct upload is kept here too, so /driver_assist works even if a
// driver lands on it without going through /upload-route first.
const session = await loadSessionFromFile(file);
const nextRoute = transformSessionToDriverRoute(session);
persistRoute(nextRoute);
setRoute(nextRoute);
setOpenId(nextRoute.stops[0]?.id || null);
} catch (importError) {
setError(
importError instanceof Error
? importError.message
: "Please upload a valid JSON file.",
);
} finally {
setIsImporting(false);
}
};

const updateStop = (stopId: string, changes: Partial<DeliveryStop>) => {
// Keep stop updates narrow so notes, status, and failure reasons can share
// one path without rebuilding the whole route by hand.
Expand Down Expand Up @@ -209,41 +186,12 @@ export default function DriverAssistPwaPage() {
const reportedStops =
route?.stops.filter((stop) => stop.status === "failed") || [];

if (!hasCheckedRoute || (!route && !error)) {
if (!hasCheckedRoute || !route) {
// Empty shell prevents the black-and-white upload screen from flashing
// while local/session storage is being checked.
return <main style={styles.loadingScreen} aria-label="Loading route" />;
}

if (!route) {
return (
<main style={styles.safeArea}>
<section style={styles.uploadScreen}>
<h1 style={styles.appHeader}>driver_assist</h1>
<input
ref={inputRef}
type="file"
accept="application/json,.json"
style={styles.hiddenInput}
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void importRoute(file);
}}
/>
<button
type="button"
style={styles.uploadButton}
onClick={() => inputRef.current?.click()}
disabled={isImporting}
>
{isImporting ? "Uploading..." : "Upload JSON"}
</button>
{error ? <p style={styles.errorText}>{error}</p> : null}
</section>
</main>
);
}

return (
<main style={styles.safeArea}>
<section ref={topRef} style={styles.container}>
Expand Down
29 changes: 0 additions & 29 deletions app/ui/src/app/driver_assist/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,41 +12,12 @@ export const styles: Record<string, CSSProperties> = {
minHeight: "100dvh",
backgroundColor: "#ffffff",
},
uploadScreen: {
minHeight: "100dvh",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
padding: 24,
backgroundColor: "#ffffff",
},
hiddenInput: {
display: "none",
},
appHeader: {
fontSize: 14,
fontWeight: 700,
color: "#202020",
margin: 0,
},
uploadButton: {
backgroundColor: "#111827",
border: 0,
borderRadius: 8,
color: "#ffffff",
cursor: "pointer",
fontSize: 16,
fontWeight: 600,
padding: "14px 20px",
},
errorText: {
color: "#b91c1c",
fontSize: 14,
marginTop: 14,
maxWidth: 300,
textAlign: "center",
},
container: {
width: "100%",
maxWidth: "none",
Expand Down
16 changes: 15 additions & 1 deletion app/ui/src/app/upload-route/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@

export const dynamic = "force-dynamic";

import { useCallback, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import ShellNavbar from "@/app/components/ShellNavbar";
import { formatSize } from "@/app/utils/routeUtils";

import {
parseRouteUploadText,
ROUTE_UPLOAD_ERROR_KEY,
} from "./routeUploadValidation";

const MAX_FILE_MB = 10;
const MAX_FILE_BYTES = MAX_FILE_MB * 1024 * 1024;

Expand All @@ -20,6 +25,14 @@ export default function UploadRoutePage() {
const dragDepth = useRef(0);
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
const uploadError = sessionStorage.getItem(ROUTE_UPLOAD_ERROR_KEY);
if (!uploadError) return;

sessionStorage.removeItem(ROUTE_UPLOAD_ERROR_KEY);
queueMicrotask(() => setError(uploadError));
}, []);

const handleFile = (f: File) => {
setError(null);
if (!f.name.endsWith(".json") && !f.name.endsWith(".csv")) {
Expand Down Expand Up @@ -60,6 +73,7 @@ export default function UploadRoutePage() {

try {
const text = await file.text();
parseRouteUploadText(text);
sessionStorage.setItem(
"routeFile",
JSON.stringify({ name: file.name, content: text }),
Expand Down
7 changes: 7 additions & 0 deletions app/ui/src/app/upload-route/routeUploadValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { loadDriverRouteFromText } from "@/lib/driver-route/importSession";

export const ROUTE_UPLOAD_ERROR_KEY = "routeUploadError";

export function parseRouteUploadText(text: string) {
return loadDriverRouteFromText(text);
}
102 changes: 91 additions & 11 deletions app/ui/src/lib/driver-route/importSession.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ZodError, z } from "zod";

import type { DriverRoute, OptimizeRequestLike } from "./types";
import type { DeliveryStop, DriverRoute, OptimizeRequestLike } from "./types";
import { transformSessionToDriverRoute } from "./transformSession";
import {
migrateSessionSaveFile,
sessionSaveDataSchema,
Expand Down Expand Up @@ -37,6 +38,24 @@ const persistedRouteStateSchema = z.object({

type PersistedRouteState = z.infer<typeof persistedRouteStateSchema>;

const resultsRouteStopSchema = z.object({
id: z.union([z.string(), z.number()]),
address: z.string().optional(),
lat: z.number(),
lng: z.number(),
sequence: z.number().int().nonnegative(),
capacityUsed: z.number().positive().optional(),
note: z.string().optional(),
addresseeName: z.string().optional(),
phoneNumber: z.string().optional(),
});

const resultsRouteSchema = z.object({
vehicleId: z.union([z.string(), z.number()]),
driverName: z.string().optional(),
stops: z.array(resultsRouteStopSchema).min(1),
});

// Guard the browser import path before we spend time parsing a file.
export async function loadSessionFromFile(
file: Pick<File, "name" | "size" | "type" | "text">,
Expand Down Expand Up @@ -70,17 +89,38 @@ export function loadSessionFromText(text: string): OptimizeRequestLike {
}

try {
// Preferred route-manager save file shape.
return migrateSessionSaveFile(parsed).data;
return loadSessionFromParsedJson(parsed);
} catch (error) {
try {
// Also accept the same data shape without the version/savedAt envelope.
return sessionSaveDataSchema.parse(parsed);
} catch {
throw new Error(
formatValidationError(error) ?? "Invalid save file format.",
);
}
throw new Error(
formatValidationError(error) ?? "Invalid save file format.",
);
}
}

export function loadDriverRouteFromText(text: string): DriverRoute {
if (text.length === 0) {
throw new Error("Invalid file contents.");
}

let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
throw new Error("This file is not valid JSON.");
}

const exportedRoute = resultsRouteSchema.safeParse(parsed);
if (exportedRoute.success) {
return transformResultsRouteToDriverRoute(exportedRoute.data);
}

try {
return transformSessionToDriverRoute(loadSessionFromParsedJson(parsed));
} catch (error) {
throw new Error(
formatValidationError(error) ??
"This file is not a recognized route or session JSON file.",
);
}
}

Expand Down Expand Up @@ -115,3 +155,43 @@ function formatValidationError(error: unknown): string | null {

return `Invalid save file format at "${path}".`;
}

function loadSessionFromParsedJson(parsed: unknown): OptimizeRequestLike {
try {
// Preferred route-manager save file shape.
return migrateSessionSaveFile(parsed).data;
} catch (error) {
try {
// Also accept the same data shape without the version/savedAt envelope.
return sessionSaveDataSchema.parse(parsed);
} catch {
throw error;
}
}
}

function transformResultsRouteToDriverRoute(
route: z.infer<typeof resultsRouteSchema>,
): DriverRoute {
const orderedStops = [...route.stops].sort((a, b) => a.sequence - b.sequence);
const stops: DeliveryStop[] = orderedStops.map((stop, index) => ({
id: String(stop.id),
stopNumber: index + 1,
address: stop.address || "No address provided",
customerName: stop.addresseeName || `Stop ${index + 1}`,
phoneNumber: stop.phoneNumber,
packageCount: stop.capacityUsed ?? 1,
notes: stop.note || "",
status: "pending",
lat: stop.lat,
lng: stop.lng,
completedAt: undefined,
failureReason: undefined,
}));

return {
driverName: route.driverName || "driver_assist",
routeLabel: `Route ${route.vehicleId} - ${stops.length} stops`,
stops,
};
}
Loading
Loading