fix(repairs-app): make /offline a real route so the service worker can install
This commit is contained in:
parent
10810142b0
commit
43aa1a96bb
6 changed files with 112 additions and 8 deletions
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
49
src/components/service-worker-registration.test.ts
Normal file
49
src/components/service-worker-registration.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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/")) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue