diff --git a/public/manifest-repairs-app.json b/public/manifest-repairs-app.json new file mode 100644 index 0000000..a8dc573 --- /dev/null +++ b/public/manifest-repairs-app.json @@ -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" + } + ] +} diff --git a/public/sw-repairs-app.js b/public/sw-repairs-app.js new file mode 100644 index 0000000..f5890e9 --- /dev/null +++ b/public/sw-repairs-app.js @@ -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")) + ) + ); +}); diff --git a/src/app/(repairs-app)/repairs-app/layout.tsx b/src/app/(repairs-app)/repairs-app/layout.tsx new file mode 100644 index 0000000..579cb5f --- /dev/null +++ b/src/app/(repairs-app)/repairs-app/layout.tsx @@ -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 ( + +
+ {children} + +
+
+ ); +} diff --git a/src/app/(repairs-app)/repairs-app/tab-bar.test.tsx b/src/app/(repairs-app)/repairs-app/tab-bar.test.tsx new file mode 100644 index 0000000..9acdd04 --- /dev/null +++ b/src/app/(repairs-app)/repairs-app/tab-bar.test.tsx @@ -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(); + 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(); + expect(screen.queryAllByRole("button")).toHaveLength(0); + }); +}); diff --git a/src/app/(repairs-app)/repairs-app/tab-bar.tsx b/src/app/(repairs-app)/repairs-app/tab-bar.tsx new file mode 100644 index 0000000..2f2dfcc --- /dev/null +++ b/src/app/(repairs-app)/repairs-app/tab-bar.tsx @@ -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 ( + + ); +} diff --git a/src/components/service-worker-registration.tsx b/src/components/service-worker-registration.tsx index 33dd9a1..2c901f9 100644 --- a/src/components/service-worker-registration.tsx +++ b/src/components/service-worker-registration.tsx @@ -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"; }