feat(repairs-app): the phone app shell, manifest and service worker

This commit is contained in:
Dave 2026-11-06 15:59:39 +02:00
parent 745593ee35
commit 10810142b0
6 changed files with 210 additions and 0 deletions

View file

@ -0,0 +1,25 @@
{
"name": "WFDASH — Repairs",
"short_name": "Repairs",
"description": "Track repair jobs and book machines in.",
"start_url": "/repairs-app",
"scope": "/repairs-app",
"display": "standalone",
"orientation": "portrait",
"background_color": "#FDF6F5",
"theme_color": "#E85D4C",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
}
]
}

50
public/sw-repairs-app.js Normal file
View file

@ -0,0 +1,50 @@
// Service worker for the /repairs-app PWA.
//
// A shell cache, NOT an offline app. The repairs person always has
// signal, so live data must never be served stale: the fetch handler
// ignores /api/ entirely and only the shell is cached.
const CACHE_NAME = "wfdash-repairs-app-v1";
const PRECACHE_ASSETS = [
"/repairs-app",
"/manifest-repairs-app.json",
"/offline",
"/android-chrome-192x192.png",
"/android-chrome-512x512.png",
"/apple-touch-icon.png",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_ASSETS))
);
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
)
)
);
self.clients.claim();
});
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
// Never serve API data from the cache — a stale repair list is worse
// than no repair list.
if (url.pathname.startsWith("/api/")) return;
event.respondWith(
fetch(request).catch(() =>
caches.match(request).then((cached) => cached || caches.match("/offline"))
)
);
});

View file

@ -0,0 +1,62 @@
import type { Metadata, Viewport } from "next";
import type { ReactNode } from "react";
import { MobileAuthGate } from "@/app/(mobile)/m/auth-gate";
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.
*
* Deliberately not in the sidebar — matching `/m`, `/counter` and `/rep`.
* The repairs person installs this on their home screen and never
* navigates to it from a menu.
*/
export const metadata: Metadata = {
title: "WFDASH — Repairs",
description: "Track repair jobs and book machines in.",
manifest: "/manifest-repairs-app.json",
icons: { apple: "/apple-touch-icon.png" },
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "Repairs",
},
other: {
// iOS only honours the apple-prefixed name. Without this tag the
// home-screen icon opens as a plain Safari bookmark instead of a
// standalone app.
"apple-mobile-web-app-capable": "yes",
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
themeColor: "#E85D4C",
};
export default function RepairsAppLayout({
children,
}: {
children: ReactNode;
}) {
return (
<MobileAuthGate>
<main
className="min-h-screen bg-background text-foreground antialiased"
style={{
paddingTop: "env(safe-area-inset-top)",
// Room for the fixed tab bar plus the home indicator.
paddingBottom: "calc(env(safe-area-inset-bottom) + 72px)",
}}
>
{children}
<RepairsTabBar />
</main>
</MobileAuthGate>
);
}

View file

@ -0,0 +1,20 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { RepairsTabBar } from "./tab-bar";
vi.mock("next/navigation", () => ({
usePathname: () => "/repairs-app",
}));
describe("RepairsTabBar", () => {
it("shows the Repairs tab as a labelled link to the board", () => {
render(<RepairsTabBar />);
const link = screen.getByRole("link", { name: /repairs/i });
expect(link).toHaveAttribute("href", "/repairs-app");
});
it("has no dead controls — every element is a link", () => {
render(<RepairsTabBar />);
expect(screen.queryAllByRole("button")).toHaveLength(0);
});
});

View file

@ -0,0 +1,46 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
/**
* The phone app's only navigation. There is no sidebar and no office
* chrome in here — this is a standalone app for one job.
*
* Phase 2 ships the Repairs tab only. Phase 4 adds the "Ask Giellie"
* tab; the tabs array is the single place that changes. A tab that does
* nothing yet would be an unexplained control, and this app's whole
* point is that every control means something.
*/
const TABS = [{ href: "/repairs-app", label: "Repairs" }];
export function RepairsTabBar() {
const pathname = usePathname();
return (
<nav
aria-label="Sections"
className="fixed inset-x-0 bottom-0 z-40 border-t bg-background"
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
>
<ul className="flex">
{TABS.map((tab) => {
const active = pathname.startsWith(tab.href);
return (
<li key={tab.href} className="flex-1">
<Link
href={tab.href}
aria-current={active ? "page" : undefined}
className={
"flex min-h-[56px] items-center justify-center text-base font-medium " +
(active ? "text-primary" : "text-muted-foreground")
}
>
{tab.label}
</Link>
</li>
);
})}
</ul>
</nav>
);
}

View file

@ -9,6 +9,7 @@ import { useEffect } from "react";
* - /new-order → /sw-counter.js (same counter PWA shell)
* - /manager → /sw-manager.js (manager orders-tracking PWA)
* - /rep → /sw-rep.js (rep visit-logging PWA, offline-first)
* - /repairs-app → /sw-repairs-app.js (repairs phone-app PWA)
* - everything else → /sw.js (main WFDASH app)
*
* All SWs are scoped to `/` so co-existence is fine — they each
@ -26,6 +27,12 @@ function pickSwPath(): string {
if (path === "/manager" || path.startsWith("/manager/")) {
return "/sw-manager.js";
}
// Above the /rep check on purpose. "/repairs-app" does NOT match
// "/rep/" — the slash is what separates them — but keeping the more
// specific path first means nobody has to work that out later.
if (path === "/repairs-app" || path.startsWith("/repairs-app/")) {
return "/sw-repairs-app.js";
}
if (path === "/rep" || path.startsWith("/rep/")) {
return "/sw-rep.js";
}