From c8b7e1f51b697d47a0d5ee3eb5df9bb7f5048995 Mon Sep 17 00:00:00 2001 From: Dave Date: Sun, 6 Sep 2026 21:09:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(rep):=20visit=20check-in=20PWA=20=E2=80=94?= =?UTF-8?q?=20phone=20GPS,=20offline=20outbox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rep app: open it on site, it finds who's around you, tick who you saw, done. Third instance of the per-route-group PWA pattern already running here (/m boss phone, /counter), so it reuses MobileAuthGate and gets its own manifest + service worker. Phone GPS is the primary position source, not the vehicle tracker. The tracker knows where the bakkie is; the phone knows where the rep is, and those diverge by a few hundred metres once the rep walks into the plant. GPS also needs no network, which matters because mine shafts have poor signal. The tracker is the secondary path (GET /api/v1/visits/position) for when location is denied or unavailable. The independently-collected vehicle trail corroborates check-ins afterwards, so honesty doesn't depend on the phone. Matching runs on the phone, not the server. The app caches a compact customer snapshot and runs rankNearbyCustomers locally, so there's one code path that behaves identically online and off — rather than a server path plus an offline fallback that only gets exercised in the places it's hardest to debug. - (rep)/rep/ layout, page, check-in view, outbox - visits-service.ts getCustomerSnapshot, recordVisits - POST /api/v1/visits record a check-in - GET /api/v1/visits/customers snapshot for the offline cache - GET /api/v1/visits/position tracker fallback - public/manifest-rep.json, public/sw-rep.js, pickSwPath() branch These routes authenticate with the browser session, not AGENT_API_KEY — the caller is a logged-in rep. The rep id comes from the validated session rather than the request body, so a client can't log a visit as someone else. The outbox uses localStorage rather than IndexedDB deliberately: the payloads are tiny and localStorage is synchronous, which matters when the rep hits Save and immediately pockets the phone. Entries are only removed once the server acknowledges them, so a failed flush retries instead of dropping a visit — duplicates are preferred over loss, and surfaced rather than merged. Permanently-rejected entries (4xx other than 401/408/429) are dropped so one bad row can't wedge the queue. recordVisits writes each visit independently: a stale customer id shouldn't cost the other four visits in a queued batch. BLOCKED until one PB admin change: rep_visits.position_source does not yet allow "phone" — verified against the live instance, PB returns 400 "Invalid value phone". Add it to the select. Separately, accuracy_m is silently accepted and discarded (PB ignores unknown fields), so GPS accuracy is thrown away until that number field is added; not blocking. 47 new tests. Verified on :4001: /rep serves 200 with the right metadata, manifest-rep.json and sw-rep.js are served. Co-Authored-By: Claude Opus 5 --- public/manifest-rep.json | 25 + public/sw-rep.js | 102 ++++ src/app/(rep)/rep/check-in-view.tsx | 492 ++++++++++++++++++ src/app/(rep)/rep/layout.tsx | 54 ++ src/app/(rep)/rep/outbox.test.ts | 201 +++++++ src/app/(rep)/rep/outbox.ts | 188 +++++++ src/app/(rep)/rep/page.tsx | 16 + src/app/api/v1/visits/customers/route.ts | 42 ++ src/app/api/v1/visits/position/route.ts | 82 +++ src/app/api/v1/visits/route.ts | 115 ++++ .../service-worker-registration.tsx | 4 + src/lib/server/visits-service.test.ts | 270 ++++++++++ src/lib/server/visits-service.ts | 211 ++++++++ 13 files changed, 1802 insertions(+) create mode 100644 public/manifest-rep.json create mode 100644 public/sw-rep.js create mode 100644 src/app/(rep)/rep/check-in-view.tsx create mode 100644 src/app/(rep)/rep/layout.tsx create mode 100644 src/app/(rep)/rep/outbox.test.ts create mode 100644 src/app/(rep)/rep/outbox.ts create mode 100644 src/app/(rep)/rep/page.tsx create mode 100644 src/app/api/v1/visits/customers/route.ts create mode 100644 src/app/api/v1/visits/position/route.ts create mode 100644 src/app/api/v1/visits/route.ts create mode 100644 src/lib/server/visits-service.test.ts create mode 100644 src/lib/server/visits-service.ts diff --git a/public/manifest-rep.json b/public/manifest-rep.json new file mode 100644 index 0000000..a61d4c5 --- /dev/null +++ b/public/manifest-rep.json @@ -0,0 +1,25 @@ +{ + "name": "WFDASH — Rep Visits", + "short_name": "Visits", + "description": "Log customer visits on site. Works without signal.", + "start_url": "/rep", + "scope": "/rep", + "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-rep.js b/public/sw-rep.js new file mode 100644 index 0000000..20a334f --- /dev/null +++ b/public/sw-rep.js @@ -0,0 +1,102 @@ +// Service worker for the /rep visit-logging PWA. +// +// Unlike the boss-phone SW, this one exists primarily so the app SHELL +// loads with no signal. A rep at a mine shaft has GPS but often no +// data, and the whole check-in flow is built to complete offline: the +// customer list is cached in localStorage, matching runs on the phone, +// and the submission sits in an outbox until there's a connection. +// +// So the shell must open from cache, every time, or none of that is +// reachable. +const CACHE_NAME = "wfdash-rep-v1"; +const OFFLINE_URL = "/offline"; + +const PRECACHE_ASSETS = [ + "/rep", + "/manifest-rep.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); + + // Never cache API traffic. Serving a stale customer snapshot would + // hide newly-added customers from a rep standing in front of one, + // and the app maintains its own snapshot cache in localStorage where + // it can reason about the age of the data. + if (url.pathname.startsWith("/api/")) return; + + // Navigations: cache-first for the shell. The opposite of the boss + // dashboard's network-first, and deliberately so — the boss wants + // fresh numbers, the rep wants the app to open at all. Freshness + // comes from the snapshot refresh once there's signal. + if (request.mode === "navigate") { + event.respondWith( + caches + .match(request) + .then((cached) => { + if (cached) { + // Refresh in the background for the next launch. + event.waitUntil( + fetch(request) + .then((response) => + caches + .open(CACHE_NAME) + .then((cache) => cache.put(request, response.clone())) + ) + .catch(() => {}) + ); + return cached; + } + return fetch(request).then((response) => { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + return response; + }); + }) + .catch(async () => { + const shell = await caches.match("/rep"); + return shell || caches.match(OFFLINE_URL); + }) + ); + return; + } + + // Static assets: stale-while-revalidate. + event.respondWith( + caches.match(request).then((cached) => { + const networkFetch = fetch(request) + .then((response) => { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + return response; + }) + .catch(() => cached); + return cached || networkFetch; + }) + ); +}); diff --git a/src/app/(rep)/rep/check-in-view.tsx b/src/app/(rep)/rep/check-in-view.tsx new file mode 100644 index 0000000..a47c691 --- /dev/null +++ b/src/app/(rep)/rep/check-in-view.tsx @@ -0,0 +1,492 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { MapPin, Search, Check, WifiOff, RefreshCw, Loader2, Navigation } from "lucide-react"; +import { toast } from "sonner"; +import getPocketBase from "@/lib/pocketbase"; +import { rankNearbyCustomers, DEFAULT_RADIUS_KM } from "@/lib/visit-matching"; +import type { CustomerSnapshotRow } from "@/lib/server/visits-service"; +import { + enqueue, + flushOutbox, + readOutbox, + readCachedSnapshot, + writeCachedSnapshot, +} from "./outbox"; + +/** Radii the "widen search" button steps through, in km. */ +const RADIUS_STEPS = [DEFAULT_RADIUS_KM, 5, 15]; + +type Position = { + lat: number; + lon: number; + accuracyM: number | null; + source: "phone" | "tracker"; + label: string; +}; + +export function CheckInView() { + const [customers, setCustomers] = useState([]); + const [snapshotAt, setSnapshotAt] = useState(null); + const [position, setPosition] = useState(null); + const [locating, setLocating] = useState(false); + const [radiusKm, setRadiusKm] = useState(DEFAULT_RADIUS_KM); + const [selected, setSelected] = useState>(new Set()); + const [pinning, setPinning] = useState>(new Set()); + const [search, setSearch] = useState(""); + const [notes, setNotes] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [pending, setPending] = useState(0); + const [online, setOnline] = useState(true); + const repIdRef = useRef(""); + + /* ------------------------------------------------------ boot + sync */ + + const refreshPending = useCallback(() => setPending(readOutbox().length), []); + + const loadSnapshot = useCallback(async () => { + const pb = getPocketBase(); + repIdRef.current = pb.authStore.record?.id ?? ""; + + // Cache first so the screen is usable instantly and offline; the + // network refresh below replaces it when it lands. + const cached = readCachedSnapshot(); + if (cached) { + setCustomers(cached.customers as CustomerSnapshotRow[]); + setSnapshotAt(cached.fetchedAt); + } + + try { + const res = await fetch("/api/v1/visits/customers", { + headers: { Authorization: `Bearer ${pb.authStore.token}` }, + }); + if (!res.ok) throw new Error(String(res.status)); + const body = (await res.json()) as { + customers: CustomerSnapshotRow[]; + fetchedAt: string; + }; + setCustomers(body.customers); + setSnapshotAt(body.fetchedAt); + writeCachedSnapshot(body); + } catch { + // Offline with no cache is the only genuinely broken state, and + // the empty-state copy below covers it. + } + }, []); + + const flush = useCallback(async () => { + const pb = getPocketBase(); + if (!pb.authStore.token || readOutbox().length === 0) return; + const result = await flushOutbox(pb.authStore.token); + refreshPending(); + if (result.sent > 0) { + toast.success(`${result.sent} queued visit${result.sent === 1 ? "" : "s"} synced`); + } + }, [refreshPending]); + + useEffect(() => { + void loadSnapshot(); + refreshPending(); + setOnline(navigator.onLine); + + const goOnline = () => { + setOnline(true); + void flush(); + void loadSnapshot(); + }; + const goOffline = () => setOnline(false); + + window.addEventListener("online", goOnline); + window.addEventListener("offline", goOffline); + if (navigator.onLine) void flush(); + + return () => { + window.removeEventListener("online", goOnline); + window.removeEventListener("offline", goOffline); + }; + }, [loadSnapshot, flush, refreshPending]); + + /* ------------------------------------------------------- positioning */ + + const locate = useCallback(async () => { + setLocating(true); + + const fromPhone = await new Promise((resolve) => { + if (!("geolocation" in navigator)) return resolve(null); + navigator.geolocation.getCurrentPosition( + (pos) => + resolve({ + lat: pos.coords.latitude, + lon: pos.coords.longitude, + accuracyM: Number.isFinite(pos.coords.accuracy) ? pos.coords.accuracy : null, + source: "phone", + label: "Your phone", + }), + () => resolve(null), + // A long timeout beats a wrong answer: a cold GPS fix on a mine + // property can genuinely take 20s, and falling back to the + // tracker too eagerly would log the bakkie's position when the + // rep is 400m away inside the plant. + { enableHighAccuracy: true, timeout: 20_000, maximumAge: 30_000 } + ); + }); + + if (fromPhone) { + setPosition(fromPhone); + setLocating(false); + return; + } + + // Secondary: the vehicle tracker. Needs a network, so it can't help + // underground — but it covers "permission denied" and "GPS off", + // where the rep usually does have data. + try { + const pb = getPocketBase(); + const res = await fetch("/api/v1/visits/position", { + headers: { Authorization: `Bearer ${pb.authStore.token}` }, + }); + const body = await res.json(); + if (!res.ok) throw new Error(body.error ?? "Tracker unavailable"); + setPosition({ + lat: body.lat, + lon: body.lon, + accuracyM: null, + source: "tracker", + label: `Your vehicle${body.vehicleNo ? ` (${body.vehicleNo})` : ""}`, + }); + toast.info("Using your vehicle's position — phone location was unavailable."); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Could not get a location. You can still search for a customer by name." + ); + } finally { + setLocating(false); + } + }, []); + + /* ---------------------------------------------------------- matching */ + + const nearby = useMemo(() => { + if (!position) return []; + return rankNearbyCustomers({ + origin: [position.lat, position.lon], + customers: customers.map((c) => ({ + id: c.id, + customer: c.customer, + companyName: c.companyName, + rep: c.rep, + coordinates: c.lat !== undefined && c.lon !== undefined + ? { lat: c.lat, lon: c.lon } + : null, + })), + radiusKm, + repId: repIdRef.current, + }); + }, [position, customers, radiusKm]); + + const searchResults = useMemo(() => { + const term = search.trim().toLowerCase(); + if (term.length < 2) return []; + const nearbyIds = new Set(nearby.map((n) => n.id)); + return customers + .filter( + (c) => + !nearbyIds.has(c.id) && + (c.customer.toLowerCase().includes(term) || + (c.companyName ?? "").toLowerCase().includes(term)) + ) + .slice(0, 20); + }, [search, customers, nearby]); + + const toggle = useCallback( + (id: string, hasCoordinates: boolean) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + // Default the pin on for a customer with no position yet — that's + // how the map builds itself, one visit at a time. + if (!hasCoordinates) { + setPinning((prev) => { + const next = new Set(prev); + if (selected.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + }, + [selected] + ); + + /* ---------------------------------------------------------- submit */ + + const submit = useCallback(async () => { + if (!position || selected.size === 0) return; + setSubmitting(true); + + const entry = enqueue({ + lat: position.lat, + lon: position.lon, + accuracyM: position.accuracyM, + positionSource: position.source, + visitedAt: new Date().toISOString(), + visits: [...selected].map((customerId) => ({ + customerId, + notes: notes.trim() || undefined, + pinCustomer: pinning.has(customerId), + })), + }); + + const count = selected.size; + setSelected(new Set()); + setPinning(new Set()); + setNotes(""); + setSearch(""); + refreshPending(); + + const pb = getPocketBase(); + try { + const result = await flushOutbox(pb.authStore.token); + refreshPending(); + if (result.sent > 0) { + toast.success(`${count} visit${count === 1 ? "" : "s"} logged`); + } else { + toast.success(`${count} visit${count === 1 ? "" : "s"} saved — will sync when you have signal`); + } + } catch { + toast.success(`${count} visit${count === 1 ? "" : "s"} saved offline`); + } finally { + void entry; + setSubmitting(false); + } + }, [position, selected, pinning, notes, refreshPending]); + + /* ------------------------------------------------------------ render */ + + const canWiden = radiusKm !== RADIUS_STEPS[RADIUS_STEPS.length - 1]; + + return ( +
+
+
+

Log a visit

+

+ {snapshotAt + ? `${customers.length} customers · updated ${new Date(snapshotAt).toLocaleDateString()}` + : "Loading customers…"} +

+
+
+ {!online && ( + + Offline + + )} + {pending > 0 && ( + + )} +
+
+ + {!position ? ( + + ) : ( +
+
+ + + {position.label} + {position.accuracyM !== null && ( + + ±{Math.round(position.accuracyM)}m + + )} + + +
+

+ {position.lat.toFixed(5)}, {position.lon.toFixed(5)} · within {radiusKm} km +

+
+ )} + + {position && ( +
+

+ Nearby ({nearby.length}) +

+ + {nearby.length === 0 ? ( +

+ No customers pinned within {radiusKm} km yet. Search below — saving a + visit records this spot for next time. +

+ ) : ( +
    + {nearby.map((row) => ( + toggle(row.id, true)} + /> + ))} +
+ )} + + {canWiden && ( + + )} +
+ )} + +
+ + + {searchResults.length > 0 && ( +
    + {searchResults.map((row) => { + const hasCoordinates = row.lat !== undefined; + return ( + toggle(row.id, hasCoordinates)} + /> + ); + })} +
+ )} +
+ + {selected.size > 0 && ( +