From 43aa1a96bb0067abed978d98afc216510d182ec4 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 6 Nov 2026 16:37:22 +0200 Subject: [PATCH] fix(repairs-app): make /offline a real route so the service worker can install --- src/app/(repairs-app)/repairs-app/layout.tsx | 10 ++-- .../repairs-app/tab-bar.test.tsx | 29 ++++++++++- src/app/(repairs-app)/repairs-app/tab-bar.tsx | 30 +++++++++++- src/app/{offline.tsx => offline/page.tsx} | 0 .../service-worker-registration.test.ts | 49 +++++++++++++++++++ .../service-worker-registration.tsx | 2 +- 6 files changed, 112 insertions(+), 8 deletions(-) rename src/app/{offline.tsx => offline/page.tsx} (100%) create mode 100644 src/components/service-worker-registration.test.ts diff --git a/src/app/(repairs-app)/repairs-app/layout.tsx b/src/app/(repairs-app)/repairs-app/layout.tsx index 579cb5f..f628158 100644 --- a/src/app/(repairs-app)/repairs-app/layout.tsx +++ b/src/app/(repairs-app)/repairs-app/layout.tsx @@ -6,11 +6,13 @@ import { RepairsTabBar } from "./tab-bar"; /** * Repairs phone-app shell. * - * The fourth instance of the per-route-group PWA pattern already running - * here (`/m` boss phone, `/counter` till, `/rep` rep visits), so it - * reuses `MobileAuthGate` and gets its own manifest + service worker. + * The fifth instance of the per-route-group PWA pattern already running + * here (`/m` boss phone, `/counter` till, `/manager` orders, `/rep` rep + * visits), so it reuses `MobileAuthGate` and gets its own manifest + + * service worker. * - * Deliberately not in the sidebar — matching `/m`, `/counter` and `/rep`. + * Deliberately not in the sidebar — matching `/m`, `/counter`, `/manager` + * and `/rep`. * The repairs person installs this on their home screen and never * navigates to it from a menu. */ diff --git a/src/app/(repairs-app)/repairs-app/tab-bar.test.tsx b/src/app/(repairs-app)/repairs-app/tab-bar.test.tsx index 9acdd04..6b5517c 100644 --- a/src/app/(repairs-app)/repairs-app/tab-bar.test.tsx +++ b/src/app/(repairs-app)/repairs-app/tab-bar.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen } from "@testing-library/react"; -import { RepairsTabBar } from "./tab-bar"; +import { RepairsTabBar, pickActiveTabHref } from "./tab-bar"; vi.mock("next/navigation", () => ({ usePathname: () => "/repairs-app", @@ -13,8 +13,33 @@ describe("RepairsTabBar", () => { expect(link).toHaveAttribute("href", "/repairs-app"); }); - it("has no dead controls — every element is a link", () => { + it("has no dead controls — every control is a link with a real destination", () => { render(); expect(screen.queryAllByRole("button")).toHaveLength(0); + const links = screen.getAllByRole("link"); + expect(links.length).toBeGreaterThan(0); + for (const link of links) { + const href = link.getAttribute("href"); + expect(href).toBeTruthy(); + expect(href).not.toBe("#"); + } + }); + + it("marks only the most specific tab as the current page", () => { + // The Phase-4 shape, proven without shipping the Phase-4 tab. + const tabs = [ + { href: "/repairs-app", label: "Repairs" }, + { href: "/repairs-app/giellie", label: "Ask Giellie" }, + ]; + expect(pickActiveTabHref("/repairs-app", tabs)).toBe("/repairs-app"); + expect(pickActiveTabHref("/repairs-app/book-in", tabs)).toBe("/repairs-app"); + expect(pickActiveTabHref("/repairs-app/giellie", tabs)).toBe("/repairs-app/giellie"); + expect(pickActiveTabHref("/repairs-app/giellie/chat", tabs)).toBe("/repairs-app/giellie"); + }); + + it("does not claim a lookalike route as the Repairs tab", () => { + const tabs = [{ href: "/repairs-app" }]; + expect(pickActiveTabHref("/repairs-approval", tabs)).toBeUndefined(); + expect(pickActiveTabHref("/repairs", tabs)).toBeUndefined(); }); }); diff --git a/src/app/(repairs-app)/repairs-app/tab-bar.tsx b/src/app/(repairs-app)/repairs-app/tab-bar.tsx index 2f2dfcc..47a1c6a 100644 --- a/src/app/(repairs-app)/repairs-app/tab-bar.tsx +++ b/src/app/(repairs-app)/repairs-app/tab-bar.tsx @@ -14,8 +14,36 @@ import { usePathname } from "next/navigation"; */ const TABS = [{ href: "/repairs-app", label: "Repairs" }]; +/** + * The active tab is the one whose href is the LONGEST match for the + * current path. + * + * Phase 4 puts the chat tab at `/repairs-app/giellie`, which also starts + * with `/repairs-app` — so a plain `startsWith` would let both tabs claim to be + * the current page. Two elements carrying `aria-current="page"` is a broken + * state, and colour would be the only thing telling them apart, which + * this app must never rely on. + * + * A path matches a tab when it is the tab's href exactly, or when it + * continues past that href on a segment boundary: `/repairs-app/book-in` matches + * `/repairs-app`, `/repairs-approval` does not. + */ +export function pickActiveTabHref( + pathname: string, + tabs: { href: string }[] = TABS +): string | undefined { + let best: string | undefined; + for (const tab of tabs) { + const { href } = tab; + if (pathname !== href && !pathname.startsWith(href + "/")) continue; + if (best === undefined || href.length > best.length) best = href; + } + return best; +} + export function RepairsTabBar() { const pathname = usePathname(); + const activeHref = pickActiveTabHref(pathname, TABS); return (