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";
}