From ef974f1a5ea2217f0b1808ed60c6408100ea8206 Mon Sep 17 00:00:00 2001 From: gb_flea Date: Tue, 9 Jun 2026 04:26:25 +0000 Subject: [PATCH] feat(GRO-2160): route nav export buttons + offline map polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.3 of the Mobile Groomer Route Optimization work (groombook/web). - Add "Open in Google Maps" / "Open in Apple Maps" export buttons that fetch the route deep-link from /api/routes/:routeId/export/:platform and open it. Device OS is detected (iOS/Android/desktop); the relevant app's button is prominent and the other is offered as a secondary option. - Pre-warm OSM map tiles for the route's bounding box (zooms 12-14, capped at 80 tiles) on every route load and after each optimize/reorder so the map is viewable offline; add a Workbox CacheFirst rule (osm-tiles, 7d) alongside the existing NetworkFirst /api/* rule (24h) that already caches route data. - Responsive polish: single-column stacking, shorter map, and full-width export buttons below 768px for phone use during the day. - Tests for export button rendering + deep-link open; UAT_PLAYBOOK.md §5.30 added. Co-Authored-By: Paperclip --- UAT_PLAYBOOK.md | 15 +++ src/__tests__/Routes.test.tsx | 39 ++++++ src/pages/Routes.tsx | 237 +++++++++++++++++++++++++++++++++- vite.config.ts | 17 +++ 4 files changed, 305 insertions(+), 3 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index f2f0e3b..4011e10 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -505,6 +505,21 @@ The stop-list panel is drag-sortable (`@dnd-kit`). Each stop card has a grab han | TC-WEB-5.29.6 | Touch / mobile drag | On a touch device (or mobile emulation), press-and-hold a stop's handle (~200ms) then drag. | The stop lifts and can be dropped in a new position; page scroll is not hijacked by a quick swipe. Reorder persists as in 5.29.2. | | TC-WEB-5.29.7 | Groomer reorders own route | Sign in as a groomer, reorder stops on the own route. | Reorder succeeds (groomer is authorized for their own route). | +### 5.30 Route Planner — Navigation Export & Offline (GRO-2160) + +When a route has stops, an export panel offers **Open in Google Maps** and **Open in Apple Maps** buttons. Each fetches `GET /api/routes/:routeId/export/google-maps` (or `/apple-maps`) and opens the returned deep-link URL in the device's maps app (Google Maps `https://www.google.com/maps/dir/?...`, Apple Maps `maps://...`). The page detects the device OS (iOS / Android / desktop) and renders the most relevant button prominently (filled) with the other as a secondary outline button; on iOS Apple Maps leads, otherwise Google Maps leads. Offline support: the existing Workbox `NetworkFirst` rule caches `/api/routes/*` responses (24h TTL) so a previously-loaded route still renders without network; a `CacheFirst` rule (`osm-tiles`, 7-day TTL, 400 entries) caches OpenStreetMap tiles. On every route load and after each optimize/reorder, the page pre-warms the OSM tiles covering the route's bounding box (zooms 12–14, capped at 80 tiles) so the map is viewable offline. The layout is responsive: below 768px the map/stop-list stack to one column, the map shrinks, and the export buttons go full-width. + +| Test Case | Description | Steps | Expected Result | +|-----------|-------------|-------|-----------------| +| TC-WEB-5.30.1 | Export buttons render | Open `/admin/routes` for a route with ≥1 stop. | An export panel shows both **Open in Google Maps** and **Open in Apple Maps** buttons. Buttons are absent when there are no stops. | +| TC-WEB-5.30.2 | Google Maps deep link | Click **Open in Google Maps**. | A `GET /api/routes/:routeId/export/google-maps` fires and the returned `https://www.google.com/maps/dir/?...` URL opens (new tab / Google Maps app) with origin, destination, and waypoints in route order. | +| TC-WEB-5.30.3 | Apple Maps deep link | On iOS (or emulation), click **Open in Apple Maps**. | A `GET /api/routes/:routeId/export/apple-maps` fires and the returned `maps://...` URL opens Apple Maps with the route chained `+to:`. | +| TC-WEB-5.30.4 | Platform-aware prominence | Open the page on an iPhone (or iOS UA emulation) vs Android/desktop. | On iOS the **Apple Maps** button is the prominent (filled) one and Google Maps is the secondary (outline); on Android/desktop **Google Maps** is prominent and Apple Maps secondary. Both buttons are always available. | +| TC-WEB-5.30.5 | Export error handling | Trigger an export that errors (e.g. route exceeds the platform waypoint cap). | The pre-opened tab is closed and an inline error message is shown; no silent failure. | +| TC-WEB-5.30.6 | Offline route data | Load a route online, then in DevTools → Network set **Offline** and reload `/admin/routes` for the same groomer/date. | The route data still loads from the `api-cache` (NetworkFirst fallback); stops, summary, and badge render without network. | +| TC-WEB-5.30.7 | Offline map tiles | After viewing/optimizing a route online, go **Offline** and view the same route. | The OSM map tiles for the route area render from the `osm-tiles` CacheFirst cache (pre-warmed); the map is not blank in the route's vicinity. | +| TC-WEB-5.30.8 | Responsive mobile layout | Open the page at a phone width (≤768px, e.g. 390px). | Map and stop-list stack into a single column, the map height shrinks, and the export buttons span full width. No horizontal scroll; controls remain usable with a thumb. | + ## 6. Pass/Fail Criteria **Pass:** diff --git a/src/__tests__/Routes.test.tsx b/src/__tests__/Routes.test.tsx index a20c49d..ce0f6ac 100644 --- a/src/__tests__/Routes.test.tsx +++ b/src/__tests__/Routes.test.tsx @@ -82,6 +82,18 @@ function mockFetch(meRole: "manager" | "groomer") { if (/^\/api\/routes\/[^/]+\/reorder$/.test(url) && opts?.method === "PATCH") { return Promise.resolve({ ok: true, json: () => Promise.resolve(ROUTE_RESPONSE) } as Response); } + if (/^\/api\/routes\/[^/]+\/export\/google-maps$/.test(url)) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ platform: "google-maps", url: "https://www.google.com/maps/dir/?api=1", stopCount: 2, waypointCount: 0 }), + } as Response); + } + if (/^\/api\/routes\/[^/]+\/export\/apple-maps$/.test(url)) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ platform: "apple-maps", url: "maps://?saddr=51.5,-0.1&daddr=51.52,-0.12", stopCount: 2, waypointCount: 0 }), + } as Response); + } return Promise.resolve({ ok: true, json: () => Promise.resolve({}) } as Response); }); } @@ -169,4 +181,31 @@ describe("RoutesPage", () => { ) ); }); + + it("renders both navigation export buttons when the route has stops", async () => { + global.fetch = mockFetch("manager") as unknown as typeof fetch; + render(); + + await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument()); + expect(screen.getByRole("button", { name: "Open in Google Maps" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open in Apple Maps" })).toBeInTheDocument(); + }); + + it("fetches the export deep link and opens it when Open in Google Maps is clicked", async () => { + const fetchMock = mockFetch("manager"); + global.fetch = fetchMock as unknown as typeof fetch; + const openSpy = vi + .spyOn(window, "open") + .mockReturnValue({ location: { href: "" }, close: vi.fn() } as unknown as Window); + + render(); + await waitFor(() => expect(screen.getByText("Alice")).toBeInTheDocument()); + + fireEvent.click(screen.getByRole("button", { name: "Open in Google Maps" })); + + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith("/api/routes/r1/export/google-maps") + ); + expect(openSpy).toHaveBeenCalled(); + }); }); diff --git a/src/pages/Routes.tsx b/src/pages/Routes.tsx index d0185e0..2db13f8 100644 --- a/src/pages/Routes.tsx +++ b/src/pages/Routes.tsx @@ -94,6 +94,111 @@ function fmtTime(iso: string): string { return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } +// ─── Navigation export ──────────────────────────────────────────────────────── + +/** Navigation target platforms supported by the API export endpoints. */ +type NavigationPlatform = "google-maps" | "apple-maps"; + +type DevicePlatform = "ios" | "android" | "other"; + +/** + * Best-effort mobile-OS detection so we can surface the most useful navigation + * app first. Apple Maps deep links (`maps://`) only resolve on iOS; everywhere + * else Google Maps is the safe default. iPadOS 13+ reports a desktop UA, so we + * also treat a touch-capable "MacIntel" device as iOS. + */ +function detectPlatform(): DevicePlatform { + if (typeof navigator === "undefined") return "other"; + const ua = navigator.userAgent || ""; + if (/iphone|ipad|ipod/i.test(ua)) return "ios"; + if (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1) return "ios"; + if (/android/i.test(ua)) return "android"; + return "other"; +} + +// ─── Offline map-tile pre-warming ──────────────────────────────────────────── + +/** OSM tile zoom levels pre-fetched around a route so the map renders offline. */ +const PREWARM_ZOOM_LEVELS = [12, 13, 14] as const; +/** Hard cap on tiles fetched per pre-warm pass — keeps us friendly to OSM. */ +const MAX_PREWARM_TILES = 80; +/** Subdomains Leaflet's default OSM TileLayer rotates through (`{s}`). */ +const TILE_SUBDOMAINS = ["a", "b", "c"] as const; + +/** Web-Mercator longitude → tile X index at the given zoom. */ +function lonToTileX(lon: number, z: number): number { + return Math.floor(((lon + 180) / 360) * 2 ** z); +} + +/** Web-Mercator latitude → tile Y index at the given zoom. */ +function latToTileY(lat: number, z: number): number { + const rad = (lat * Math.PI) / 180; + return Math.floor( + ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * 2 ** z + ); +} + +/** + * Warm the browser/service-worker cache with the OSM tiles covering the route's + * bounding box (plus a one-tile margin) across a few zoom levels. Tiles are + * fetched via `new Image()` so they hit the same URLs Leaflet later requests and + * land in the CacheFirst tile cache, making the map viewable offline. Bounded by + * MAX_PREWARM_TILES so a sprawling route never floods the network. + */ +function prewarmRouteTiles( + stops: Array<{ latitude: number; longitude: number }> +): void { + if (typeof window === "undefined" || stops.length === 0) return; + const lats = stops.map((s) => s.latitude); + const lons = stops.map((s) => s.longitude); + const minLat = Math.min(...lats); + const maxLat = Math.max(...lats); + const minLon = Math.min(...lons); + const maxLon = Math.max(...lons); + + const urls: string[] = []; + for (const z of PREWARM_ZOOM_LEVELS) { + const x0 = lonToTileX(minLon, z) - 1; + const x1 = lonToTileX(maxLon, z) + 1; + // Tile Y grows as latitude decreases, so maxLat → smaller Y. + const y0 = latToTileY(maxLat, z) - 1; + const y1 = latToTileY(minLat, z) + 1; + for (let x = x0; x <= x1; x++) { + for (let y = y0; y <= y1; y++) { + if (x < 0 || y < 0 || x >= 2 ** z || y >= 2 ** z) continue; + const s = TILE_SUBDOMAINS[(x + y) % TILE_SUBDOMAINS.length]; + urls.push(`https://${s}.tile.openstreetmap.org/${z}/${x}/${y}.png`); + } + } + } + + for (const url of urls.slice(0, MAX_PREWARM_TILES)) { + const img = new Image(); + img.src = url; + } +} + +// ─── Responsive layout ──────────────────────────────────────────────────────── + +/** Tracks a `max-width` media query so the page can adapt to phone widths. */ +function useIsMobile(maxWidthPx = 768): boolean { + const query = `(max-width: ${maxWidthPx}px)`; + const [isMobile, setIsMobile] = useState( + () => typeof window !== "undefined" && typeof window.matchMedia === "function" + ? window.matchMedia(query).matches + : false + ); + useEffect(() => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") return; + const mq = window.matchMedia(query); + const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches); + setIsMobile(mq.matches); + mq.addEventListener("change", onChange); + return () => mq.removeEventListener("change", onChange); + }, [query]); + return isMobile; +} + const STATUS_STYLES: Record = { draft: { bg: "#f1f5f9", fg: "#475569", label: "Draft" }, optimized: { bg: "#ecfdf5", fg: "#047857", label: "Optimized" }, @@ -221,6 +326,117 @@ function SortableStop({ ); } +/** + * Navigation export controls. Fetches a platform deep-link from the API and opens + * it. The button matching the detected device OS is shown prominently (filled); + * the other is offered as a secondary outline button. On desktop both are + * secondary and Google Maps leads. + */ +function NavExportButtons({ + routeId, + primaryColor, + fullWidth, +}: { + routeId: string; + primaryColor: string; + fullWidth: boolean; +}) { + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + const platform = useMemo(detectPlatform, []); + + const openIn = useCallback( + async (target: NavigationPlatform) => { + setBusy(target); + setError(null); + // Pre-open a tab synchronously: mobile Safari/Chrome block window.open() + // calls that happen after an await (no longer in the user-gesture turn). + const win = window.open("", "_blank"); + try { + const r = await fetch(`/api/routes/${encodeURIComponent(routeId)}/export/${target}`); + if (!r.ok) { + const body = await r.json().catch(() => ({})); + throw new Error(body.error || `Export failed (${r.status})`); + } + const { url } = (await r.json()) as { url: string }; + if (win) win.location.href = url; + else window.location.href = url; + } catch (e) { + win?.close(); + setError(e instanceof Error ? e.message : "Export failed"); + } finally { + setBusy(null); + } + }, + [routeId] + ); + + const baseBtn: React.CSSProperties = { + padding: "0.55rem 1rem", + borderRadius: 6, + fontWeight: 600, + fontSize: 14, + cursor: busy ? "wait" : "pointer", + flex: fullWidth ? "1 1 0" : "0 0 auto", + }; + const primaryBtn: React.CSSProperties = { + ...baseBtn, + border: "none", + background: primaryColor, + color: "#fff", + }; + const secondaryBtn: React.CSSProperties = { + ...baseBtn, + border: `1px solid ${primaryColor}`, + background: "#fff", + color: primaryColor, + }; + + const label = (p: NavigationPlatform) => + busy === p + ? "Opening…" + : p === "google-maps" + ? "Open in Google Maps" + : "Open in Apple Maps"; + + const google = ( + + ); + const apple = ( + + ); + // Prominent (filled) button first; secondary second. + const ordered = platform === "ios" ? [apple, google] : [google, apple]; + + return ( +
+
+ + Navigate + + {ordered} +
+ {error &&
{error}
} +
+ ); +} + // ─── Page ─────────────────────────────────────────────────────────────────────── export function RoutesPage() { @@ -241,6 +457,7 @@ export function RoutesPage() { const [error, setError] = useState(null); const isGroomer = me?.role === "groomer"; + const isMobile = useIsMobile(); // Resolve the current staff member; groomers are pinned to their own route. useEffect(() => { @@ -393,6 +610,13 @@ export function RoutesPage() { [data] ); + // Pre-warm OSM map tiles for the route area whenever a route (re)loads or is + // re-optimized, so the map stays viewable offline. Runs after today's route is + // fetched on page load and after every optimize/reorder that yields new stops. + useEffect(() => { + if (mapStops.length > 0) prewarmRouteTiles(mapStops); + }, [mapStops]); + const stops = data?.stops ?? []; const route = data?.route ?? null; @@ -468,9 +692,16 @@ export function RoutesPage() { -
+ {/* Navigation export — open the route in the device's maps app */} + {route && stops.length > 0 && ( +
+ +
+ )} + +
{/* Map */} -
+
{mapStops.length > 0 ? ( Loading map…}> @@ -481,7 +712,7 @@ export function RoutesPage() {
{/* Stop list panel — drag-to-reorder */} -
+
{stops.length === 0 && !loading && (
No stops for this day.
)} diff --git a/vite.config.ts b/vite.config.ts index d2c7811..eaabdb4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -57,6 +57,23 @@ export default defineConfig({ }, }, }, + { + // OpenStreetMap raster tiles for the Route Planner map. CacheFirst so + // tiles pre-warmed for a route render offline during the day. Capped + // entries + 7-day TTL keep the cache bounded. + urlPattern: /^https:\/\/[abc]\.tile\.openstreetmap\.org\/.*\.png$/i, + handler: "CacheFirst", + options: { + cacheName: "osm-tiles", + expiration: { + maxEntries: 400, + maxAgeSeconds: 60 * 60 * 24 * 7, // 7 days + }, + cacheableResponse: { + statuses: [0, 200], + }, + }, + }, ], }, }),