diff --git a/app/ui/src/app/driver_assist/page.tsx b/app/ui/src/app/driver_assist/page.tsx index 7b5228c12..58b01c5f8 100644 --- a/app/ui/src/app/driver_assist/page.tsx +++ b/app/ui/src/app/driver_assist/page.tsx @@ -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"; @@ -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 @@ -51,7 +48,6 @@ function openPhone(stop: DeliveryStop) { export default function DriverAssistPwaPage() { const router = useRouter(); - const inputRef = useRef(null); const topRef = useRef(null); const remainingRef = useRef(null); const deliveredRef = useRef(null); @@ -63,45 +59,49 @@ export default function DriverAssistPwaPage() { "Customer unavailable", ); const [reportDetails, setReportDetails] = useState(""); - const [error, setError] = useState(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(() => { @@ -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) => { // Keep stop updates narrow so notes, status, and failure reasons can share // one path without rebuilding the whole route by hand. @@ -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
; } - if (!route) { - return ( -
-
-

driver_assist

- { - const file = event.target.files?.[0]; - if (file) void importRoute(file); - }} - /> - - {error ?

{error}

: null} -
-
- ); - } - return (
diff --git a/app/ui/src/app/driver_assist/styles.ts b/app/ui/src/app/driver_assist/styles.ts index a16f4c78a..1d821f7d2 100644 --- a/app/ui/src/app/driver_assist/styles.ts +++ b/app/ui/src/app/driver_assist/styles.ts @@ -12,41 +12,12 @@ export const styles: Record = { 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", diff --git a/app/ui/src/app/upload-route/page.tsx b/app/ui/src/app/upload-route/page.tsx index cb5f1f02f..055de1e12 100644 --- a/app/ui/src/app/upload-route/page.tsx +++ b/app/ui/src/app/upload-route/page.tsx @@ -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; @@ -20,6 +25,14 @@ export default function UploadRoutePage() { const dragDepth = useRef(0); const inputRef = useRef(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")) { @@ -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 }), diff --git a/app/ui/src/app/upload-route/routeUploadValidation.ts b/app/ui/src/app/upload-route/routeUploadValidation.ts new file mode 100644 index 000000000..683a59516 --- /dev/null +++ b/app/ui/src/app/upload-route/routeUploadValidation.ts @@ -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); +} diff --git a/app/ui/src/lib/driver-route/importSession.ts b/app/ui/src/lib/driver-route/importSession.ts index 9c3ab7cf6..01bb13dc7 100644 --- a/app/ui/src/lib/driver-route/importSession.ts +++ b/app/ui/src/lib/driver-route/importSession.ts @@ -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, @@ -37,6 +38,24 @@ const persistedRouteStateSchema = z.object({ type PersistedRouteState = z.infer; +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, @@ -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.", + ); } } @@ -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, +): 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, + }; +} diff --git a/app/ui/src/tests/driverRouteImport.test.ts b/app/ui/src/tests/driverRouteImport.test.ts index b811e4db5..b621062cc 100644 --- a/app/ui/src/tests/driverRouteImport.test.ts +++ b/app/ui/src/tests/driverRouteImport.test.ts @@ -1,12 +1,13 @@ import { describe, expect, it } from "vitest"; +import { parseRouteUploadText } from "@/app/upload-route/routeUploadValidation"; import { loadSessionFromText } from "@/lib/driver-route/importSession"; import { transformSessionToDriverRoute } from "@/lib/driver-route/transformSession"; import { buildSessionSave } from "@/lib/session/exportSession"; describe("driver route import", () => { it("loads a saved route-manager session into the driver_assist route shape", () => { - const session = loadSessionFromText( + const route = parseRouteUploadText( JSON.stringify( buildSessionSave( { @@ -35,7 +36,7 @@ describe("driver route import", () => { ), ); - expect(transformSessionToDriverRoute(session)).toEqual({ + expect(route).toEqual({ driverName: "driver1", routeLabel: "Route 7 - 1 stops", stops: [ @@ -105,7 +106,83 @@ describe("driver route import", () => { ); }); - it("also accepts the same session data shape without the save envelope", () => { + it("rejects invalid upload-route files before handing them to driver_assist", () => { + expect(() => parseRouteUploadText(JSON.stringify({ version: 1 }))).toThrow( + 'Invalid save file format at "savedAt".', + ); + }); + + it("loads a Results page route export into the driver_assist route shape", () => { + const route = parseRouteUploadText( + JSON.stringify({ + vehicleId: "vehicle-7", + driverName: "Driver Export", + stops: [ + { + id: "stop-later", + address: "200 Second St", + lat: 38.55, + lng: -121.75, + sequence: 2, + capacityUsed: 4, + timeWindow: { kind: "by", time: "13:00" }, + note: "Ring bell", + addresseeName: "Second Customer", + phoneNumber: "555-555-0202", + }, + { + id: "stop-first", + address: "100 First St", + lat: 38.54, + lng: -121.74, + sequence: 1, + capacityUsed: 2, + timeWindow: { kind: "from", time: "09:00" }, + note: "Leave at front desk", + addresseeName: "First Customer", + phoneNumber: "555-555-0101", + }, + ], + }), + ); + + expect(route).toEqual({ + driverName: "Driver Export", + routeLabel: "Route vehicle-7 - 2 stops", + stops: [ + { + id: "stop-first", + stopNumber: 1, + address: "100 First St", + customerName: "First Customer", + phoneNumber: "555-555-0101", + packageCount: 2, + notes: "Leave at front desk", + status: "pending", + lat: 38.54, + lng: -121.74, + completedAt: undefined, + failureReason: undefined, + }, + { + id: "stop-later", + stopNumber: 2, + address: "200 Second St", + customerName: "Second Customer", + phoneNumber: "555-555-0202", + packageCount: 4, + notes: "Ring bell", + status: "pending", + lat: 38.55, + lng: -121.75, + completedAt: undefined, + failureReason: undefined, + }, + ], + }); + }); + + it("also accepts a direct optimize request JSON file", () => { const session = loadSessionFromText( JSON.stringify({ deliveries: [