Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bfcf0b970 | |||
| 7ef270312c | |||
| f58a0e569b | |||
| 2a401a4584 | |||
| e93017b279 | |||
| 27c59113e2 | |||
| 95c688764b | |||
| 5bb8fbcb7d | |||
| fdff0977ad | |||
| 2aad7cb6a0 | |||
| 0c41640f59 |
@@ -217,6 +217,25 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
|
||||
| TC-WEB-5.12.16 | Badge status from data | Compare badge label to appointment.status field | Badge label matches the API appointment status exactly |
|
||||
| TC-WEB-5.12.17 | Unknown status fallback | Render badge with unknown status value | Badge renders with the raw status string as label and fallback CSS class |
|
||||
|
||||
#### 5.12f Live StatusBadge palette — no-show / pending / waitlisted (GRO-2319)
|
||||
|
||||
These cases exercise the full StatusBadge palette as it is now produced live by
|
||||
the seeded UAT customer (`uat-customer@groombook.dev`), not just unit-rendered.
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
|---|----------|-------|----------|
|
||||
| TC-WEB-5.12.26 | No-show badge (item 1) | Sign in as `uat-customer@groombook.dev`, open `Appointments` → **Past** tab, find the seeded `no_show` appointment | A styled yellow **"No-show"** badge renders (`bg-yellow-100 text-yellow-700`) — **not** a raw gray `no_show` label. The DB `no_show` (underscore) status is normalized to the `no-show` palette key. |
|
||||
| TC-WEB-5.12.27 | Pending derivation (item 2) | On the **Upcoming** tab, find the seeded upcoming appointment whose `confirmationStatus` is `pending` (groomer-unconfirmed) | The card's top-row badge reads amber **"Pending"** (derived from `confirmationStatus`), even though the raw appointment status is `scheduled`. |
|
||||
| TC-WEB-5.12.28 | Confirmed not overridden | On the **Upcoming** tab, find the seeded confirmed appointment (`confirmationStatus = confirmed`) | Badge still reads green **"Confirmed"** — the pending derivation does not override a confirmed appointment. |
|
||||
| TC-WEB-5.12.29 | Waitlisted card (item 2) | On the **Upcoming** tab, find the seeded waitlist entry for the customer | A card renders with a blue **"Waitlisted"** badge, a **dashed muted border**, and the subtext _"You're on the waitlist — we'll let you know if a spot opens."_ The Confirm / Reschedule / Cancel / Notes actions are **not** shown for this entry (it is not a booked appointment). |
|
||||
|
||||
> **GRO-2319 note:** the DB `appointment_status` enum cannot represent `pending`
|
||||
> or `waitlisted`, so those badges are derived in the portal: `pending` from an
|
||||
> upcoming appointment's `confirmationStatus`, and `waitlisted` from active
|
||||
> `waitlist_entries` surfaced by `GET /api/portal/appointments` as synthetic
|
||||
> cards. The `no_show` → `no-show` key normalization fixes the cosmetic badge
|
||||
> mismatch (item 1).
|
||||
|
||||
#### 5.12d Appointment API Shape Normalization (GRO-2180)
|
||||
|
||||
| # | Scenario | Steps | Expected |
|
||||
@@ -505,6 +524,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:**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { parseTimeTo24Hour, isUpcoming, normalizeAppointment, normalizeService, formatServicePrice, CustomerNotesSection, ConfirmationSection, StatusBadge, formatSlotLabel, slotToTime, BookingFlow } from "../portal/sections/Appointments.tsx";
|
||||
import { parseTimeTo24Hour, isUpcoming, normalizeAppointment, normalizeService, formatServicePrice, CustomerNotesSection, ConfirmationSection, StatusBadge, normalizeStatusKey, deriveDisplayStatus, formatSlotLabel, slotToTime, BookingFlow } from "../portal/sections/Appointments.tsx";
|
||||
|
||||
const UPCOMING_APPT = {
|
||||
id: "appt-1",
|
||||
@@ -517,6 +517,52 @@ describe("StatusBadge", () => {
|
||||
expect(badge?.className).toContain("bg-stone-100");
|
||||
expect(badge?.className).toContain("text-stone-600");
|
||||
});
|
||||
|
||||
// GRO-2319 item 1: DB stores `no_show` (underscore) but the palette key is
|
||||
// `no-show` (hyphen) — without normalization it rendered raw gray text.
|
||||
it("renders the styled No-show badge for DB `no_show` status", () => {
|
||||
render(<StatusBadge status="no_show" />);
|
||||
const badge = screen.getByText("No-show").closest('span');
|
||||
expect(badge?.className).toContain("bg-yellow-100");
|
||||
expect(badge?.className).toContain("text-yellow-700");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeStatusKey (GRO-2319 item 1)", () => {
|
||||
it("maps underscore status keys to the hyphen palette key", () => {
|
||||
expect(normalizeStatusKey("no_show")).toBe("no-show");
|
||||
});
|
||||
|
||||
it("leaves already-hyphenated / single-word keys unchanged", () => {
|
||||
expect(normalizeStatusKey("no-show")).toBe("no-show");
|
||||
expect(normalizeStatusKey("confirmed")).toBe("confirmed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveDisplayStatus (GRO-2319 item 2)", () => {
|
||||
it("derives Pending for an upcoming, unconfirmed appointment", () => {
|
||||
expect(
|
||||
deriveDisplayStatus({ ...UPCOMING_APPT, status: "scheduled", confirmationStatus: "pending" }),
|
||||
).toBe("pending");
|
||||
});
|
||||
|
||||
it("keeps the raw status when the appointment is confirmed", () => {
|
||||
expect(
|
||||
deriveDisplayStatus({ ...UPCOMING_APPT, status: "confirmed", confirmationStatus: "confirmed" }),
|
||||
).toBe("confirmed");
|
||||
});
|
||||
|
||||
it("does not derive Pending for a past appointment", () => {
|
||||
expect(
|
||||
deriveDisplayStatus({ ...PAST_APPT, status: "completed", confirmationStatus: "pending" }),
|
||||
).toBe("completed");
|
||||
});
|
||||
|
||||
it("always shows Waitlisted for a waitlist-backed entry", () => {
|
||||
expect(
|
||||
deriveDisplayStatus({ ...UPCOMING_APPT, status: "waitlisted", confirmationStatus: undefined }),
|
||||
).toBe("waitlisted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RescheduleFlow dynamic time slots", () => {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -315,9 +315,19 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
scheduled: 'Scheduled',
|
||||
};
|
||||
|
||||
// The DB `appointment_status` enum stores `no_show` (underscore), but the badge
|
||||
// palette is keyed on `no-show` (hyphen). Without normalization a no-show
|
||||
// appointment renders as a raw gray `no_show` label instead of the styled
|
||||
// "No-show" badge (GRO-2319 item 1). Map underscore status keys to the hyphen
|
||||
// palette key so DB-sourced statuses resolve to their intended badge style.
|
||||
export function normalizeStatusKey(status: string): string {
|
||||
return status.replace(/_/g, '-');
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
const label = STATUS_LABELS[status] ?? status;
|
||||
const colorClass = STATUS_COLORS[status] ?? 'bg-stone-100 text-stone-600';
|
||||
const key = normalizeStatusKey(status);
|
||||
const label = STATUS_LABELS[key] ?? status;
|
||||
const colorClass = STATUS_COLORS[key] ?? 'bg-stone-100 text-stone-600';
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${colorClass}`}>
|
||||
{label}
|
||||
@@ -325,6 +335,19 @@ export function StatusBadge({ status }: { status: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Derives the badge state shown on an Upcoming/Past card from the appointment's
|
||||
// raw status plus its confirmationStatus (GRO-2319 item 2, CMPO-approved):
|
||||
// - a synthetic waitlist entry (status `waitlisted`) always shows Waitlisted
|
||||
// - an upcoming appointment the groomer has not yet confirmed
|
||||
// (`confirmationStatus === 'pending'`) shows Pending — semantically honest
|
||||
// and reduces anxiety-driven follow-up messages
|
||||
// - otherwise the raw status drives the badge
|
||||
export function deriveDisplayStatus(appt: Appointment): string {
|
||||
if (appt.status === 'waitlisted') return 'waitlisted';
|
||||
if (isUpcoming(appt) && appt.confirmationStatus === 'pending') return 'pending';
|
||||
return appt.status;
|
||||
}
|
||||
|
||||
const CONFIRMATION_STATUS_COLORS: Record<string, string> = {
|
||||
confirmed: 'bg-green-100 text-green-700',
|
||||
pending: 'bg-amber-100 text-amber-700',
|
||||
@@ -508,11 +531,24 @@ function AppointmentCard({
|
||||
sessionId: string | null;
|
||||
onReschedule: (appt: Appointment) => void;
|
||||
}) {
|
||||
// A waitlist-backed entry (GRO-2319 item 2, CMPO UX spec GRO-2328) is not a
|
||||
// confirmed appointment: it gets a muted, dashed-border card and a subtext
|
||||
// line so the customer can tell it apart from booked appointments, and the
|
||||
// appointment-only actions (confirm / notes / reschedule / cancel) are hidden.
|
||||
const isWaitlist = appt.status === 'waitlisted';
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-stone-200 shadow-sm overflow-hidden">
|
||||
<div
|
||||
className={
|
||||
isWaitlist
|
||||
? 'bg-stone-50/60 rounded-xl border border-dashed border-stone-300 shadow-sm overflow-hidden'
|
||||
: 'bg-white rounded-xl border border-stone-200 shadow-sm overflow-hidden'
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-4 p-4 text-left hover:bg-stone-50"
|
||||
className={`w-full flex items-center gap-4 p-4 text-left ${
|
||||
isWaitlist ? 'hover:bg-stone-100/60' : 'hover:bg-stone-50'
|
||||
}`}
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center text-lg shrink-0">
|
||||
{appt.petName?.charAt(0) || 'P'}
|
||||
@@ -532,8 +568,13 @@ function AppointmentCard({
|
||||
</span>
|
||||
<span>with {appt.groomerName || 'First Available'}</span>
|
||||
</div>
|
||||
{isWaitlist && (
|
||||
<p className="text-xs text-stone-400 mt-1">
|
||||
You're on the waitlist — we'll let you know if a spot opens.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<StatusBadge status={appt.status} />
|
||||
<StatusBadge status={deriveDisplayStatus(appt)} />
|
||||
{expanded ? (
|
||||
<ChevronDown size={16} className="text-stone-400" />
|
||||
) : (
|
||||
@@ -567,11 +608,14 @@ function AppointmentCard({
|
||||
{appt.notes}
|
||||
</p>
|
||||
)}
|
||||
{isUpcoming(appt) && !readOnly && (
|
||||
{!isWaitlist && isUpcoming(appt) && !readOnly && (
|
||||
<CustomerNotesSection appointment={appt} sessionId={sessionId} />
|
||||
)}
|
||||
{isUpcoming(appt) && <ConfirmationSection appointment={appt} sessionId={sessionId} />}
|
||||
{appt.status !== 'completed' &&
|
||||
{!isWaitlist && isUpcoming(appt) && (
|
||||
<ConfirmationSection appointment={appt} sessionId={sessionId} />
|
||||
)}
|
||||
{!isWaitlist &&
|
||||
appt.status !== 'completed' &&
|
||||
appt.status !== 'cancelled' &&
|
||||
!readOnly && (
|
||||
<div className="flex gap-2 mt-3">
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user