fix(repairs-app): make /offline a real route so the service worker can install

This commit is contained in:
Dave 2026-11-06 16:37:22 +02:00
parent 10810142b0
commit 43aa1a96bb
6 changed files with 112 additions and 8 deletions

View file

@ -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.
*/

View file

@ -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(<RepairsTabBar />);
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();
});
});

View file

@ -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 (
<nav
aria-label="Sections"
@ -24,7 +52,7 @@ export function RepairsTabBar() {
>
<ul className="flex">
{TABS.map((tab) => {
const active = pathname.startsWith(tab.href);
const active = activeHref === tab.href;
return (
<li key={tab.href} className="flex-1">
<Link

View file

@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { pickSwPath } from "./service-worker-registration";
/**
* The picker decides which worker a phone installs for a given route.
* The risk it must never have is one route shadowing another —
* `/repairs-app` and `/rep` share their first four characters — so the
* mapping is pinned here rather than left to a comment.
*/
function swFor(pathname: string): string {
window.history.pushState({}, "", pathname);
return pickSwPath();
}
describe("pickSwPath", () => {
it("picks the repairs-app worker for every route inside /repairs-app", () => {
for (const path of [
"/repairs-app",
"/repairs-app/",
"/repairs-app/book-in",
"/repairs-app/giellie",
]) {
expect(swFor(path)).toBe("/sw-repairs-app.js");
}
});
it("picks the rep worker for /rep and its sub-routes", () => {
expect(swFor("/rep")).toBe("/sw-rep.js");
expect(swFor("/rep/visits")).toBe("/sw-rep.js");
});
it("never lets one of the two shadow the other", () => {
expect(swFor("/rep")).not.toBe("/sw-repairs-app.js");
expect(swFor("/repairs-app")).not.toBe("/sw-rep.js");
expect(swFor("/repairs")).toBe("/sw.js");
});
it("leaves the other PWA routes on their own workers", () => {
expect(swFor("/m")).toBe("/sw-mobile.js");
expect(swFor("/counter")).toBe("/sw-counter.js");
expect(swFor("/new-order")).toBe("/sw-counter.js");
expect(swFor("/manager")).toBe("/sw-manager.js");
});
it("falls back to the main app worker everywhere else", () => {
expect(swFor("/")).toBe("/sw.js");
expect(swFor("/quotes")).toBe("/sw.js");
});
});

View file

@ -15,7 +15,7 @@ import { useEffect } from "react";
* All SWs are scoped to `/` so co-existence is fine — they each
* cache their own URLs and ignore `/api/`.
*/
function pickSwPath(): string {
export function pickSwPath(): string {
if (typeof window === "undefined") return "/sw.js";
const path = window.location.pathname;
if (path === "/m" || path.startsWith("/m/")) {