Compare commits

..

24 Commits

Author SHA1 Message Date
Flea Flicker 9894a86717 feat(GRO-2160): route nav export buttons + offline map polish (#66)
CI / Test (pull_request) Successful in 21s
CI / Lint & Typecheck (pull_request) Successful in 26s
CI / Build & Push Docker Image (pull_request) Successful in 25s
2026-06-09 04:32:23 +00:00
Flea Flicker e93017b279 Promote dev → uat: GRO-2159 drag-to-reorder + re-optimize (#64)
CI / Test (push) Successful in 35s
CI / Lint & Typecheck (push) Successful in 44s
CI / Build & Push Docker Image (push) Successful in 11s
CI / Test (pull_request) Successful in 21s
CI / Lint & Typecheck (pull_request) Successful in 27s
CI / Build & Push Docker Image (pull_request) Successful in 12s
2026-06-09 03:10:17 +00:00
Flea Flicker db11e5f2bd Merge pull request 'Promote dev → uat: GRO-2236 portal Book New service cards price + duration' (#58) from flea/dev-to-uat-gro-2236 into uat
CI / Test (push) Successful in 24s
CI / Lint & Typecheck (push) Successful in 30s
CI / Build & Push Docker Image (push) Successful in 48s
2026-06-09 02:13:08 +00:00
Flea Flicker 980615b8e6 Promote dev → uat: GRO-2158 route planner page (#61)
CI / Test (push) Successful in 18s
CI / Lint & Typecheck (push) Successful in 27s
CI / Build & Push Docker Image (push) Successful in 14s
CI / Test (pull_request) Successful in 20s
CI / Lint & Typecheck (pull_request) Successful in 30s
CI / Build & Push Docker Image (pull_request) Successful in 41s
2026-06-09 02:00:55 +00:00
The Dogfather f549101962 fix(GRO-2236): portal Book New service cards show price + duration (#57)
CI / Test (pull_request) Successful in 20s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Image (pull_request) Successful in 14s
Co-authored-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net>
Co-committed-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net>
2026-06-08 23:32:19 +00:00
Flea Flicker 62dc85b560 Promote dev → uat: GRO-2211/2218/2207 + GRO-2234 portal Book New (cumulative) (#56)
CI / Lint & Typecheck (push) Successful in 28s
CI / Test (push) Successful in 28s
CI / Build & Push Docker Image (push) Successful in 41s
CI / Test (pull_request) Successful in 21s
CI / Lint & Typecheck (pull_request) Successful in 27s
CI / Build & Push Docker Image (pull_request) Successful in 47s
2026-06-08 19:58:43 +00:00
Flea Flicker bc21d6de09 Promote dev → uat: GRO-2213 portal booking preferredTime HH:MM:SS fix (#52)
CI / Test (push) Successful in 21s
CI / Test (pull_request) Successful in 22s
CI / Lint & Typecheck (push) Successful in 26s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Image (push) Successful in 25s
CI / Build & Push Docker Image (pull_request) Successful in 20s
2026-06-08 17:36:16 +00:00
Flea Flicker 32ef3bca4d Merge pull request 'Promote dev → uat: GRO-2180 portal Appointments ISO startTime fix' (#50) from dev into uat
CI / Lint & Typecheck (pull_request) Failing after 10m47s
CI / Test (push) Failing after 10m51s
CI / Lint & Typecheck (push) Failing after 10m52s
CI / Build & Push Docker Image (push) Has been skipped
CI / Test (pull_request) Failing after 15m38s
CI / Build & Push Docker Image (pull_request) Has been skipped
Merge PR #50: fix(GRO-2180) portal Appointments ISO startTime (dev → uat)

QA-approved (gb_lint); PR CI green after transient runner re-run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 10:28:50 +00:00
Flea Flicker 47c29ecbc2 Promote to UAT: GRO-2105 BookingFlow/RescheduleFlow availability fix (#47)
CI / Test (push) Successful in 17s
CI / Lint & Typecheck (push) Successful in 23s
CI / Build & Push Docker Image (push) Successful in 19s
CI / Test (pull_request) Failing after 10m34s
CI / Lint & Typecheck (pull_request) Failing after 10m34s
CI / Build & Push Docker Image (pull_request) Has been skipped
2026-06-02 19:17:03 +00:00
The Dogfather de7386e47a Promote to UAT: GRO-2094 React bootstrap error instrumentation (#45)
CI / Test (push) Successful in 23s
CI / Lint & Typecheck (push) Successful in 30s
CI / Build & Push Docker Image (push) Successful in 13s
Co-authored-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net>
Co-committed-by: The Dogfather <20+gb_dogfather@noreply.git.farh.net>
2026-06-02 18:42:25 +00:00
The Dogfather ec29f71974 Merge pull request 'Promote to UAT: GRO-2012 RescheduleFlow portalSessionId fallback' (#39) from dev into uat
CI / Test (push) Successful in 21s
CI / Lint & Typecheck (push) Successful in 30s
CI / Build & Push Docker Image (push) Successful in 10s
CI / Test (pull_request) Successful in 21s
CI / Lint & Typecheck (pull_request) Successful in 28s
CI / Build & Push Docker Image (pull_request) Successful in 13s
2026-06-01 17:46:35 +00:00
The Dogfather bd2a0d9516 Merge pull request 'Promote dev -> uat: GRO-2011 login-blank fix (+ GRO-1867)' (#37) from dev into uat
CI / Test (push) Successful in 19s
CI / Lint & Typecheck (push) Successful in 23s
CI / Build & Push Docker Image (push) Successful in 10s
2026-06-01 16:38:14 +00:00
The Dogfather 0e5e9d1f16 Merge pull request 'chore: promote dev → uat (GRO-1829 SW fix)' (#32) from dev into uat
CI / Test (push) Successful in 13s
CI / Lint & Typecheck (push) Successful in 23s
CI / Build & Push Docker Image (push) Successful in 15s
Merge: promote dev → uat (GRO-1829 SW fix)
2026-05-27 02:27:32 +00:00
The Dogfather 3b4d0f15f6 Merge pull request 'chore: promote dev → uat (GRO-1795 StatusBadge)' (#28) from dev into uat
CI / Lint & Typecheck (push) Successful in 17s
CI / Test (push) Successful in 13s
CI / Build & Push Docker Image (push) Successful in 34s
Merge PR #28: promote dev → uat (GRO-1795 StatusBadge)
2026-05-26 13:23:52 +00:00
The Dogfather 87939e5413 Merge pull request 'chore: promote dev → uat (GRO-1794 booking analytics)' (#27) from dev into uat
CI / Test (push) Successful in 19s
CI / Lint & Typecheck (push) Successful in 22s
CI / Build & Push Docker Image (push) Successful in 12s
Merge dev → uat: GRO-1794 booking funnel analytics events
2026-05-26 13:16:39 +00:00
The Dogfather 4e3a038bf3 Merge pull request 'Promote dev → uat (GRO-1793: dynamic time slots)' (#25) from dev into uat
CI / Test (push) Successful in 14s
CI / Lint & Typecheck (push) Successful in 16s
CI / Build & Push Docker Image (push) Failing after 6s
Promote dev → uat: GRO-1793 dynamic portal time slots (#25)
2026-05-26 13:02:16 +00:00
Lint Roller 8349ea00de Merge pull request 'promote: dev → uat (GRO-1757 SSO auto-provision fix)' (#19) from dev into uat
CI / Test (push) Successful in 13s
CI / Lint & Typecheck (push) Successful in 33s
CI / Build & Push Docker Image (push) Successful in 14s
CI / Test (pull_request) Successful in 19s
CI / Lint & Typecheck (pull_request) Successful in 24s
CI / Build & Push Docker Image (pull_request) Successful in 15s
promote: dev → uat (GRO-1757 SSO auto-provision fix)
2026-05-25 23:48:10 +00:00
The Dogfather 0306c7fbd9 Merge pull request 'chore(GRO-1592): promote dev→uat SSO session cookie fix' (#16) from promote-uat-gro1592 into uat
CI / Test (push) Successful in 12s
CI / Lint & Typecheck (push) Successful in 18s
CI / Build & Push Docker Image (push) Failing after 39s
2026-05-23 14:13:43 +00:00
Chris Farhood 93da2f1dd8 chore: promote dev→uat for GRO-1592 SSO session cookie fix
CI / Lint & Typecheck (pull_request) Successful in 17s
CI / Test (pull_request) Successful in 18s
CI / Build & Push Docker Image (pull_request) Failing after 41s
- Fixed frontend auth client baseURL fallback to use window.location.origin
- Added UAT test coverage (TC-AUTH-5.3.4)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-23 14:13:12 +00:00
The Dogfather 62cbfe4e43 Merge pull request 'promote: dev → uat (GRO-1173 buffer rules + GRO-1470 pet save persistence)' (#14) from dev into uat
CI / Test (push) Successful in 14s
CI / Lint & Typecheck (push) Successful in 19s
CI / Build & Push Docker Image (push) Successful in 9s
promote: dev → uat (GRO-1173 buffer rules + GRO-1470 pet save persistence) (#14)

Merged-By: The Dogfather (CTO)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 19:46:41 +00:00
The Dogfather db6a2a1bbf Merge pull request 'promote: dev → uat (Renovate config, GRO-1081)' (#11) from dev into uat
promote: dev → uat (Renovate config, GRO-1081)

Merge PR #11: dev → uat promotion
Includes: chore: add Renovate config (GRO-1081)
2026-05-20 12:42:04 +00:00
The Dogfather 032a3796ba Merge pull request 'chore: promote dev to uat (CI Docker registry fix)' (#10) from dev into uat
chore: promote dev to uat (CI Docker registry fix) (#10)

Promotes GRO-1348 CI registry fix to UAT.
2026-05-20 11:17:21 +00:00
the-dogfather-cto[bot] cac8fc947e chore(GRO-1289): promote dev to uat — add UAT_PLAYBOOK.md
chore(GRO-1289): promote dev to uat — add UAT_PLAYBOOK.md
2026-05-14 21:13:56 +00:00
the-dogfather-cto[bot] 592be1301c chore: promote dev to uat (#3)
chore: promote dev to uat
2026-05-11 13:19:33 +00:00
4 changed files with 305 additions and 3 deletions
+15
View File
@@ -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 1214, 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:**
+39
View File
@@ -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(<RoutesPage />);
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(<RoutesPage />);
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();
});
});
+234 -3
View File
@@ -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<RouteStatus, { bg: string; fg: string; label: string }> = {
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<NavigationPlatform | null>(null);
const [error, setError] = useState<string | null>(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 = (
<button
key="google"
type="button"
onClick={() => openIn("google-maps")}
disabled={busy !== null}
style={platform === "ios" ? secondaryBtn : primaryBtn}
>
{label("google-maps")}
</button>
);
const apple = (
<button
key="apple"
type="button"
onClick={() => openIn("apple-maps")}
disabled={busy !== null}
style={platform === "ios" ? primaryBtn : secondaryBtn}
>
{label("apple-maps")}
</button>
);
// Prominent (filled) button first; secondary second.
const ordered = platform === "ios" ? [apple, google] : [google, apple];
return (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
<span style={{ fontSize: 12, color: "#4b5563", fontWeight: 600, marginRight: 4 }}>
Navigate
</span>
{ordered}
</div>
{error && <div style={{ fontSize: 12, color: "#991b1b" }}>{error}</div>}
</div>
);
}
// ─── Page ───────────────────────────────────────────────────────────────────────
export function RoutesPage() {
@@ -241,6 +457,7 @@ export function RoutesPage() {
const [error, setError] = useState<string | null>(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() {
<Summary label="Total distance" value={route?.totalDistanceKm != null ? `${route.totalDistanceKm} km` : "—"} />
</div>
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.5fr) minmax(280px, 1fr)", gap: 16, alignItems: "stretch" }}>
{/* Navigation export — open the route in the device's maps app */}
{route && stops.length > 0 && (
<div style={{ marginBottom: "1rem", padding: "0.8rem 1rem", background: "#fff", borderRadius: 8, border: "1px solid #e2e8f0" }}>
<NavExportButtons routeId={route.id} primaryColor={primaryColor} fullWidth={isMobile} />
</div>
)}
<div style={{ display: "grid", gridTemplateColumns: isMobile ? "1fr" : "minmax(0, 1.5fr) minmax(280px, 1fr)", gap: 16, alignItems: "stretch" }}>
{/* Map */}
<div style={{ height: 540, background: "#e5e7eb", borderRadius: 8, overflow: "hidden", border: "1px solid #e2e8f0" }}>
<div style={{ height: isMobile ? 340 : 540, background: "#e5e7eb", borderRadius: 8, overflow: "hidden", border: "1px solid #e2e8f0" }}>
{mapStops.length > 0 ? (
<Suspense fallback={<Centered>Loading map</Centered>}>
<RouteMap stops={mapStops} primaryColor={primaryColor} />
@@ -481,7 +712,7 @@ export function RoutesPage() {
</div>
{/* Stop list panel — drag-to-reorder */}
<div style={{ display: "flex", flexDirection: "column", gap: 10, maxHeight: 540, overflowY: "auto" }}>
<div style={{ display: "flex", flexDirection: "column", gap: 10, maxHeight: isMobile ? "none" : 540, overflowY: isMobile ? "visible" : "auto" }}>
{stops.length === 0 && !loading && (
<div style={{ color: "#6b7280", fontSize: 14, padding: "1rem" }}>No stops for this day.</div>
)}
+17
View File
@@ -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],
},
},
},
],
},
}),