feat(rep): visit check-in PWA — phone GPS, offline outbox

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 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-09-06 21:09:27 +02:00
parent a551a71277
commit c8b7e1f51b
13 changed files with 1802 additions and 0 deletions

25
public/manifest-rep.json Normal file
View file

@ -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"
}
]
}

102
public/sw-rep.js Normal file
View file

@ -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;
})
);
});

View file

@ -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<CustomerSnapshotRow[]>([]);
const [snapshotAt, setSnapshotAt] = useState<string | null>(null);
const [position, setPosition] = useState<Position | null>(null);
const [locating, setLocating] = useState(false);
const [radiusKm, setRadiusKm] = useState(DEFAULT_RADIUS_KM);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [pinning, setPinning] = useState<Set<string>>(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<string>("");
/* ------------------------------------------------------ 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<Position | null>((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 (
<div className="mx-auto flex min-h-screen w-full max-w-md flex-col gap-4 px-4 py-5">
<header className="flex items-start justify-between gap-3">
<div>
<h1 className="text-xl font-bold tracking-tight">Log a visit</h1>
<p className="text-xs text-muted-foreground">
{snapshotAt
? `${customers.length} customers · updated ${new Date(snapshotAt).toLocaleDateString()}`
: "Loading customers…"}
</p>
</div>
<div className="flex flex-col items-end gap-1">
{!online && (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/15 px-2 py-1 text-[11px] font-medium text-amber-700 dark:text-amber-400">
<WifiOff className="h-3 w-3" /> Offline
</span>
)}
{pending > 0 && (
<button
onClick={() => void flush()}
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary"
>
<RefreshCw className="h-3 w-3" /> {pending} to sync
</button>
)}
</div>
</header>
{!position ? (
<button
onClick={() => void locate()}
disabled={locating}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-primary px-4 py-5 text-base font-semibold text-primary-foreground disabled:opacity-60"
>
{locating ? (
<>
<Loader2 className="h-5 w-5 animate-spin" /> Finding you…
</>
) : (
<>
<Navigation className="h-5 w-5" /> Find customers near me
</>
)}
</button>
) : (
<div className="rounded-xl border bg-card px-3 py-2.5 text-xs">
<div className="flex items-center justify-between gap-2">
<span className="inline-flex items-center gap-1.5 font-medium">
<MapPin className="h-3.5 w-3.5 text-primary" />
{position.label}
{position.accuracyM !== null && (
<span className="text-muted-foreground">
±{Math.round(position.accuracyM)}m
</span>
)}
</span>
<button
onClick={() => void locate()}
className="text-muted-foreground underline underline-offset-2"
>
Update
</button>
</div>
<p className="mt-1 font-mono text-[11px] text-muted-foreground">
{position.lat.toFixed(5)}, {position.lon.toFixed(5)} · within {radiusKm} km
</p>
</div>
)}
{position && (
<section className="flex flex-col gap-2">
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Nearby ({nearby.length})
</h2>
{nearby.length === 0 ? (
<p className="rounded-lg border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
No customers pinned within {radiusKm} km yet. Search below — saving a
visit records this spot for next time.
</p>
) : (
<ul className="flex flex-col gap-1.5">
{nearby.map((row) => (
<CustomerRow
key={row.id}
id={row.id}
name={row.name}
sub={`${row.companyName ?? "—"} · ${formatDistance(row.distanceKm)}${row.isMine ? " · yours" : ""}`}
checked={selected.has(row.id)}
onToggle={() => toggle(row.id, true)}
/>
))}
</ul>
)}
{canWiden && (
<button
onClick={() =>
setRadiusKm(
(current) =>
RADIUS_STEPS[
Math.min(RADIUS_STEPS.indexOf(current) + 1, RADIUS_STEPS.length - 1)
]
)
}
className="self-start text-xs font-medium text-primary underline underline-offset-2"
>
Widen search
</button>
)}
</section>
)}
<section className="flex flex-col gap-2">
<label className="relative block">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search any customer by name"
className="w-full rounded-xl border bg-background py-3 pl-9 pr-3 text-sm outline-none focus:border-primary"
/>
</label>
{searchResults.length > 0 && (
<ul className="flex flex-col gap-1.5">
{searchResults.map((row) => {
const hasCoordinates = row.lat !== undefined;
return (
<CustomerRow
key={row.id}
id={row.id}
name={row.customer}
sub={
hasCoordinates
? (row.companyName ?? "—")
: `${row.companyName ?? "—"} · will save this location`
}
checked={selected.has(row.id)}
onToggle={() => toggle(row.id, hasCoordinates)}
/>
);
})}
</ul>
)}
</section>
{selected.size > 0 && (
<textarea
value={notes}
onChange={(event) => setNotes(event.target.value)}
placeholder="Note (optional)"
rows={2}
className="w-full resize-none rounded-xl border bg-background px-3 py-2.5 text-sm outline-none focus:border-primary"
/>
)}
<div className="sticky bottom-0 mt-auto bg-gradient-to-t from-background via-background to-transparent pb-2 pt-3">
<button
onClick={() => void submit()}
disabled={!position || selected.size === 0 || submitting}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-primary px-4 py-4 text-base font-semibold text-primary-foreground disabled:opacity-40"
>
{submitting ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<>
<Check className="h-5 w-5" />
{selected.size === 0
? "Select who you saw"
: `Log ${selected.size} visit${selected.size === 1 ? "" : "s"}`}
</>
)}
</button>
</div>
</div>
);
}
function formatDistance(km: number): string {
return km < 1 ? `${Math.round(km * 1000)} m` : `${km.toFixed(1)} km`;
}
function CustomerRow({
name,
sub,
checked,
onToggle,
}: {
id: string;
name: string;
sub: string;
checked: boolean;
onToggle: () => void;
}) {
return (
<li>
<button
onClick={onToggle}
aria-pressed={checked}
// Generous hit area: this is tapped with gloves on, outdoors,
// in the sun.
className={`flex w-full items-center gap-3 rounded-xl border px-3 py-3 text-left transition-colors ${
checked ? "border-primary bg-primary/8" : "bg-card"
}`}
>
<span
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md border ${
checked ? "border-primary bg-primary text-primary-foreground" : "bg-background"
}`}
>
{checked && <Check className="h-4 w-4" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{name}</span>
<span className="block truncate text-xs text-muted-foreground">{sub}</span>
</span>
</button>
</li>
);
}

View file

@ -0,0 +1,54 @@
import type { Metadata, Viewport } from "next";
import type { ReactNode } from "react";
import { MobileAuthGate } from "@/app/(mobile)/m/auth-gate";
/**
* Rep visit-logging PWA shell.
*
* Third instance of the per-route-group PWA pattern already running
* here (`/m` boss phone, `/counter` over-the-counter), so it reuses
* `MobileAuthGate` and gets its own manifest + service worker rather
* than inventing anything.
*
* Deliberately not in the sidebar — matching `/m` and `/counter`. Reps
* install this to their home screen and never navigate to it from a
* menu; the desktop sidebar is the office's tool.
*/
export const metadata: Metadata = {
title: "WFDASH — Rep Visits",
description: "Log customer visits on site. Works without signal.",
manifest: "/manifest-rep.json",
icons: { apple: "/apple-touch-icon.png" },
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: "Visits",
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
// `viewportFit: cover` plus the safe-area padding below keeps the
// submit button clear of the home indicator on a notched iPhone —
// it's the control the rep taps most and the easiest one to lose
// under the system UI.
viewportFit: "cover",
themeColor: "#E85D4C",
};
export default function RepLayout({ children }: { children: ReactNode }) {
return (
<MobileAuthGate>
<main
className="min-h-screen bg-background text-foreground antialiased"
style={{
paddingBottom: "env(safe-area-inset-bottom)",
paddingTop: "env(safe-area-inset-top)",
}}
>
{children}
</main>
</MobileAuthGate>
);
}

View file

@ -0,0 +1,201 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
enqueue,
flushOutbox,
readOutbox,
removeFromOutbox,
readCachedSnapshot,
writeCachedSnapshot,
} from "./outbox";
const CHECK_IN = {
lat: -26.4,
lon: 27.4,
accuracyM: 12,
positionSource: "phone" as const,
visitedAt: "2026-09-06T09:15:00.000Z",
visits: [{ customerId: "c1" }],
};
function response(status: number): Response {
return new Response(status === 204 ? null : "{}", { status });
}
beforeEach(() => {
window.localStorage.clear();
});
describe("outbox queueing", () => {
it("starts empty", () => {
expect(readOutbox()).toEqual([]);
});
it("queues a check-in with an id and a zero attempt count", () => {
const entry = enqueue(CHECK_IN);
expect(entry.id).toBeTruthy();
expect(entry.attempts).toBe(0);
expect(readOutbox()).toHaveLength(1);
});
it("keeps entries distinct", () => {
enqueue(CHECK_IN);
enqueue(CHECK_IN);
const ids = readOutbox().map((e) => e.id);
expect(new Set(ids).size).toBe(2);
});
it("removes a specific entry", () => {
const first = enqueue(CHECK_IN);
enqueue(CHECK_IN);
removeFromOutbox(first.id);
expect(readOutbox()).toHaveLength(1);
expect(readOutbox()[0].id).not.toBe(first.id);
});
it("survives corrupt storage instead of throwing on the rep's screen", () => {
window.localStorage.setItem("wfdash.rep.outbox.v1", "{not json");
expect(readOutbox()).toEqual([]);
});
});
describe("flushOutbox", () => {
it("posts each entry and clears it once the server acknowledges", async () => {
enqueue(CHECK_IN);
const fetchImpl = vi.fn(async () => response(200)) as unknown as typeof fetch;
const result = await flushOutbox("tok", fetchImpl);
expect(result).toEqual({ sent: 1, failed: 0, remaining: 0 });
expect(readOutbox()).toHaveLength(0);
});
it("sends the check-in time, not the time it was flushed", async () => {
// A queued visit can reach the server hours later; recording the
// flush time would put the visit at the wrong point in the day and
// break any match against the vehicle trail.
enqueue(CHECK_IN);
const calls: RequestInit[] = [];
const fetchImpl = vi.fn(async (_url: unknown, init?: RequestInit) => {
calls.push(init!);
return response(200);
}) as unknown as typeof fetch;
await flushOutbox("tok", fetchImpl);
const body = JSON.parse(calls[0].body as string);
expect(body.visitedAt).toBe("2026-09-06T09:15:00.000Z");
expect(body.accuracyM).toBe(12);
});
it("sends the session token", async () => {
enqueue(CHECK_IN);
const calls: RequestInit[] = [];
const fetchImpl = vi.fn(async (_url: unknown, init?: RequestInit) => {
calls.push(init!);
return response(200);
}) as unknown as typeof fetch;
await flushOutbox("tok-123", fetchImpl);
expect((calls[0].headers as Record<string, string>).Authorization).toBe(
"Bearer tok-123"
);
});
it("keeps an entry when the phone is offline", async () => {
// The single most important behaviour here: a failed flush must not
// lose the visit.
enqueue(CHECK_IN);
const fetchImpl = vi.fn(async () => {
throw new TypeError("Failed to fetch");
}) as unknown as typeof fetch;
const result = await flushOutbox("tok", fetchImpl);
expect(result).toMatchObject({ sent: 0, failed: 1, remaining: 1 });
expect(readOutbox()[0].attempts).toBe(1);
expect(readOutbox()[0].lastError).toMatch(/Failed to fetch/);
});
it("keeps an entry on a 5xx and counts the attempt", async () => {
enqueue(CHECK_IN);
const fetchImpl = vi.fn(async () => response(503)) as unknown as typeof fetch;
await flushOutbox("tok", fetchImpl);
await flushOutbox("tok", fetchImpl);
expect(readOutbox()).toHaveLength(1);
expect(readOutbox()[0].attempts).toBe(2);
});
it("keeps an entry on a 401 — the session will be valid again after re-login", async () => {
enqueue(CHECK_IN);
const fetchImpl = vi.fn(async () => response(401)) as unknown as typeof fetch;
await flushOutbox("stale", fetchImpl);
expect(readOutbox()).toHaveLength(1);
});
it("drops an entry the server permanently rejects", async () => {
// A 400 means the server understood and refused it. Retrying forever
// would wedge every later visit behind one bad row.
enqueue(CHECK_IN);
const fetchImpl = vi.fn(async () => response(400)) as unknown as typeof fetch;
const result = await flushOutbox("tok", fetchImpl);
expect(result).toMatchObject({ sent: 0, failed: 1, remaining: 0 });
expect(readOutbox()).toHaveLength(0);
});
it("does not let one stuck entry block the ones behind it", async () => {
enqueue(CHECK_IN);
enqueue({ ...CHECK_IN, visits: [{ customerId: "c2" }] });
let call = 0;
const fetchImpl = vi.fn(async () => {
call++;
return response(call === 1 ? 503 : 200);
}) as unknown as typeof fetch;
const result = await flushOutbox("tok", fetchImpl);
expect(result).toMatchObject({ sent: 1, failed: 1, remaining: 1 });
expect(readOutbox()[0].visits[0].customerId).toBe("c1");
});
it("is a no-op on an empty queue", async () => {
const fetchImpl = vi.fn() as unknown as typeof fetch;
expect(await flushOutbox("tok", fetchImpl)).toEqual({
sent: 0,
failed: 0,
remaining: 0,
});
expect(fetchImpl).not.toHaveBeenCalled();
});
});
describe("customer snapshot cache", () => {
it("round-trips", () => {
writeCachedSnapshot({ customers: [{ id: "c1" }], fetchedAt: "2026-09-06T00:00:00.000Z" });
expect(readCachedSnapshot()).toEqual({
customers: [{ id: "c1" }],
fetchedAt: "2026-09-06T00:00:00.000Z",
});
});
it("returns null when nothing is cached", () => {
expect(readCachedSnapshot()).toBeNull();
});
it("rejects a cache whose shape is wrong rather than half-loading it", () => {
window.localStorage.setItem("wfdash.rep.customers.v1", JSON.stringify({ customers: "nope" }));
expect(readCachedSnapshot()).toBeNull();
});
});

188
src/app/(rep)/rep/outbox.ts Normal file
View file

@ -0,0 +1,188 @@
/**
* Offline outbox for rep check-ins.
*
* A rep at a mine shaft has a GPS fix but frequently no signal. The
* whole flow is designed to complete without a network: the customer
* list is cached, matching runs on the phone, and the submission lands
* here until there's a connection.
*
* `localStorage` rather than IndexedDB on purpose. The payloads are
* tiny (a position plus a handful of customer ids), the volume is a few
* entries a day, and localStorage is synchronous — which matters when
* the rep hits Save and immediately locks the phone or walks into a
* dead zone. An IndexedDB write is async and can be interrupted between
* the call and the commit; that's precisely the moment we can't afford
* to lose the entry.
*
* Entries are only removed once the server has acknowledged them, so a
* failed flush retries rather than silently dropping a visit. The cost
* of that choice is possible duplicates if a response is lost in
* flight — deliberately preferred over a lost visit, and surfaced to
* the rep rather than merged away.
*/
const OUTBOX_KEY = "wfdash.rep.outbox.v1";
const SNAPSHOT_KEY = "wfdash.rep.customers.v1";
export interface QueuedVisit {
customerId: string;
notes?: string;
pinCustomer?: boolean;
}
export interface QueuedCheckIn {
/** Client-side id, so a retry can be matched to its entry. */
id: string;
lat: number;
lon: number;
accuracyM: number | null;
positionSource: "phone" | "tracker" | "manual";
/** When the rep actually checked in — not when it reached the server. */
visitedAt: string;
visits: QueuedVisit[];
/** Failed flush attempts, for surfacing a stuck entry to the rep. */
attempts: number;
lastError?: string;
}
export interface CachedSnapshot {
customers: unknown[];
fetchedAt: string;
}
function safeRead<T>(key: string, fallback: T): T {
if (typeof window === "undefined") return fallback;
try {
const raw = window.localStorage.getItem(key);
if (!raw) return fallback;
return JSON.parse(raw) as T;
} catch {
// Corrupt or unparseable — treat as empty rather than throwing on a
// screen the rep needs to work right now.
return fallback;
}
}
function safeWrite(key: string, value: unknown): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
/* quota or private mode — nothing useful to do here */
}
}
/* ---------------------------------------------------------------- outbox */
export function readOutbox(): QueuedCheckIn[] {
const entries = safeRead<QueuedCheckIn[]>(OUTBOX_KEY, []);
return Array.isArray(entries) ? entries : [];
}
export function enqueue(entry: Omit<QueuedCheckIn, "id" | "attempts">): QueuedCheckIn {
const queued: QueuedCheckIn = {
...entry,
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
attempts: 0,
};
safeWrite(OUTBOX_KEY, [...readOutbox(), queued]);
return queued;
}
export function removeFromOutbox(id: string): void {
safeWrite(
OUTBOX_KEY,
readOutbox().filter((entry) => entry.id !== id)
);
}
function markAttempt(id: string, error: string): void {
safeWrite(
OUTBOX_KEY,
readOutbox().map((entry) =>
entry.id === id
? { ...entry, attempts: entry.attempts + 1, lastError: error }
: entry
)
);
}
export interface FlushResult {
sent: number;
failed: number;
remaining: number;
}
/**
* Attempts to post every queued check-in, oldest first.
*
* A `4xx` other than 401/408/429 means the server understood and
* rejected the entry — retrying forever would wedge the queue behind a
* permanently-bad row, so those are dropped. Network failures, 401s
* (session expired, will be valid again after re-login) and 5xx are
* kept for the next flush.
*/
export async function flushOutbox(
token: string,
fetchImpl: typeof fetch = fetch
): Promise<FlushResult> {
const result: FlushResult = { sent: 0, failed: 0, remaining: 0 };
for (const entry of readOutbox()) {
try {
const response = await fetchImpl("/api/v1/visits", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
lat: entry.lat,
lon: entry.lon,
accuracyM: entry.accuracyM,
positionSource: entry.positionSource,
visitedAt: entry.visitedAt,
visits: entry.visits,
}),
});
if (response.ok) {
removeFromOutbox(entry.id);
result.sent++;
continue;
}
const permanent =
response.status >= 400 &&
response.status < 500 &&
![401, 408, 429].includes(response.status);
if (permanent) {
removeFromOutbox(entry.id);
result.failed++;
continue;
}
markAttempt(entry.id, `HTTP ${response.status}`);
result.failed++;
} catch (error) {
markAttempt(entry.id, error instanceof Error ? error.message : "offline");
result.failed++;
}
}
result.remaining = readOutbox().length;
return result;
}
/* -------------------------------------------------------------- snapshot */
export function readCachedSnapshot(): CachedSnapshot | null {
const cached = safeRead<CachedSnapshot | null>(SNAPSHOT_KEY, null);
if (!cached || !Array.isArray(cached.customers)) return null;
return cached;
}
export function writeCachedSnapshot(snapshot: CachedSnapshot): void {
safeWrite(SNAPSHOT_KEY, snapshot);
}

View file

@ -0,0 +1,16 @@
"use client";
import { CheckInView } from "./check-in-view";
/**
* `/rep` — the rep's whole app. One screen: find me, tick who I saw,
* log it.
*
* `"use client"` because the entire flow is client-side by design —
* geolocation, the cached customer snapshot, local matching, and the
* offline outbox all live in the browser so a check-in completes with
* no signal.
*/
export default function RepPage() {
return <CheckInView />;
}

View file

@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { getBrowserSessionUser } from "@/lib/server/browser-session";
import { getCustomerSnapshot } from "@/lib/server/visits-service";
/**
* GET /api/v1/visits/customers
*
* The customer snapshot the rep PWA caches for offline matching:
* every customer, with coordinates where they exist.
*
* The phone does the "who is near me" filtering locally with
* `rankNearbyCustomers` rather than asking the server per check-in.
* Mine shafts have poor signal, and phone GPS works without a network
* — a server-side match would fail in exactly the places reps need it.
* So the phone pulls this list when it has signal and works from the
* cache thereafter.
*
* Includes the rep field so the phone can sort its own customers first
* without a second lookup, and unpinned customers so the search box has
* something to find on day one (when nothing is pinned yet).
*
* Cached hard on the client, not here: the list changes rarely, but a
* newly-added customer should reach a rep on their next refresh rather
* than whenever a CDN feels like it.
*/
export async function GET(req: NextRequest) {
try {
await getBrowserSessionUser(req);
const customers = await getCustomerSnapshot();
return NextResponse.json(
{ customers, fetchedAt: new Date().toISOString() },
{ headers: { "Cache-Control": "no-store" } }
);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to load customers";
const status = (error as Error & { status?: number }).status || 500;
return NextResponse.json({ error: message }, { status });
}
}

View file

@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from "next/server";
import { getBrowserSessionUser } from "@/lib/server/browser-session";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { getTrackerAdapter, TrackerError } from "@/lib/server/tracker-service";
/**
* GET /api/v1/visits/position — the tracker fallback.
*
* Phone GPS is the primary source for a check-in: it works with no
* signal and points at the rep rather than the bakkie. This endpoint is
* the **secondary** path, for when the rep has denied location
* permission or the device can't get a fix.
*
* It necessarily needs a network, so it can't help underground — but
* the case it covers (permission denied, or GPS off) is one where the
* rep usually does have data.
*
* Resolves the vehicle from the signed-in user's
* `users.tracker_vehicle_id`. A rep with no vehicle mapped gets a 404
* with a message an operator can act on, rather than a silent empty
* result that looks like "no customers nearby".
*/
export async function GET(req: NextRequest) {
try {
const user = await getBrowserSessionUser(req);
const pb = await getAdminPocketBase();
const record = await pb
.collection("users")
.getOne<{ tracker_vehicle_id?: string }>(user.id, {
fields: "id,tracker_vehicle_id",
});
const vehicleNo = String(record.tracker_vehicle_id ?? "").trim();
if (!vehicleNo) {
return NextResponse.json(
{
error:
"No vehicle is linked to your account. Ask the office to set your registration on your user record, or enable location on this phone.",
},
{ status: 404 }
);
}
const position = await getTrackerAdapter().getCurrentPosition(vehicleNo);
if (!position) {
return NextResponse.json(
{ error: `The tracker has no current position for ${vehicleNo}.` },
{ status: 404 }
);
}
return NextResponse.json(
{
lat: position.lat,
lon: position.lon,
address: position.address,
fixedAt: position.fixedAt,
fixAgeSeconds: position.fixAgeSeconds,
vehicleNo: position.vehicleNo,
},
{ headers: { "Cache-Control": "no-store" } }
);
} catch (error) {
// A rate-limit refusal is worth telling the rep about specifically —
// "try again in 10 seconds" is actionable; "failed" is not.
if (error instanceof TrackerError && error.retryAfterSeconds) {
return NextResponse.json(
{
error: `The tracker is rate-limited. Try again in ${error.retryAfterSeconds} seconds.`,
retryAfterSeconds: error.retryAfterSeconds,
},
{ status: 429 }
);
}
const message =
error instanceof Error ? error.message : "Could not read the vehicle position";
const status = (error as Error & { status?: number }).status || 500;
return NextResponse.json({ error: message }, { status });
}
}

View file

@ -0,0 +1,115 @@
import { NextRequest, NextResponse } from "next/server";
import { getBrowserSessionUser } from "@/lib/server/browser-session";
import {
recordVisits,
type PositionSource,
type RecordVisitInput,
} from "@/lib/server/visits-service";
const SOURCES: PositionSource[] = ["phone", "tracker", "manual"];
/**
* POST /api/v1/visits — record a rep check-in.
*
* Authenticated with the **browser session**, not `AGENT_API_KEY`: the
* caller is a logged-in rep on their phone. The rep id comes from the
* validated session rather than the request body, so a client can't
* log a visit as someone else.
*
* A plain route rather than a Server Action because the rep PWA
* replays queued check-ins from an offline outbox — a `fetch` is
* trivial to retry from stored JSON, whereas a Server Action is bound
* to the React runtime that queued it.
*
* Body:
* {
* lat, lon required, decimal degrees
* positionSource "phone" | "tracker" | "manual"
* accuracyM? GPS accuracy in metres
* fixAgeSeconds? how old the fix was
* visitedAt? ISO; set by the phone when the check-in was
* *made*, which for a queued submission is
* hours before it reaches us
* visits: [{ customerId, notes?, pinCustomer? }]
* }
*
* Idempotency is deliberately not attempted here — see the outbox in
* the rep PWA, which only removes an entry once the server has
* acknowledged it, and surfaces duplicates to the rep rather than
* silently merging them.
*/
export async function POST(req: NextRequest) {
try {
const user = await getBrowserSessionUser(req);
const body = (await req.json().catch(() => null)) as {
lat?: unknown;
lon?: unknown;
positionSource?: unknown;
accuracyM?: unknown;
fixAgeSeconds?: unknown;
visitedAt?: unknown;
visits?: unknown;
} | null;
if (!body || typeof body !== "object") {
return NextResponse.json({ error: "JSON object body required" }, { status: 400 });
}
const { lat, lon } = body;
if (typeof lat !== "number" || typeof lon !== "number") {
return NextResponse.json(
{ error: "lat and lon are required as numbers" },
{ status: 400 }
);
}
const positionSource = SOURCES.includes(body.positionSource as PositionSource)
? (body.positionSource as PositionSource)
: "phone";
if (!Array.isArray(body.visits) || body.visits.length === 0) {
return NextResponse.json(
{ error: "visits must be a non-empty array" },
{ status: 400 }
);
}
const visits: RecordVisitInput[] = [];
for (const entry of body.visits) {
if (!entry || typeof entry !== "object") continue;
const { customerId, notes, pinCustomer } = entry as Record<string, unknown>;
if (typeof customerId !== "string" || !customerId) continue;
visits.push({
customerId,
notes: typeof notes === "string" ? notes : undefined,
pinCustomer: pinCustomer === true,
});
}
if (visits.length === 0) {
return NextResponse.json(
{ error: "no valid customer ids in visits" },
{ status: 400 }
);
}
const result = await recordVisits({
repId: user.id,
lat,
lon,
positionSource,
accuracyM: typeof body.accuracyM === "number" ? body.accuracyM : null,
fixAgeSeconds:
typeof body.fixAgeSeconds === "number" ? body.fixAgeSeconds : null,
visitedAt: typeof body.visitedAt === "string" ? body.visitedAt : undefined,
visits,
});
return NextResponse.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to record visits";
const status = (error as Error & { status?: number }).status || 500;
return NextResponse.json({ error: message }, { status });
}
}

View file

@ -7,6 +7,7 @@ import { useEffect } from "react";
* - /m → /sw-mobile.js (boss-dashboard PWA, dark theme)
* - /counter → /sw-counter.js (over-the-counter order PWA)
* - /new-order → /sw-counter.js (same counter PWA shell)
* - /rep → /sw-rep.js (rep visit-logging PWA, offline-first)
* - everything else → /sw.js (main WFDASH app)
*
* All SWs are scoped to `/` so co-existence is fine — they each
@ -21,6 +22,9 @@ function pickSwPath(): string {
if (path.startsWith("/counter") || path.startsWith("/new-order")) {
return "/sw-counter.js";
}
if (path === "/rep" || path.startsWith("/rep/")) {
return "/sw-rep.js";
}
return "/sw.js";
}

View file

@ -0,0 +1,270 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const setCustomerCoordinates = vi.fn();
vi.mock("./customers-service", () => ({
setCustomerCoordinates: (...args: unknown[]) => setCustomerCoordinates(...args),
}));
import { getCustomerSnapshot, recordVisits } from "./visits-service";
type Row = Record<string, unknown> & { id: string };
function fakePb(seed: Record<string, Row[]> = {}) {
const store: Record<string, Row[]> = { customers: [], rep_visits: [], ...seed };
let nextId = 1;
const failures: Record<string, Set<string>> = {};
const api = {
store,
/** Makes `create`/`update` on a collection reject for a given id. */
failOn(collection: string, id: string) {
(failures[collection] ??= new Set()).add(id);
},
collection(name: string) {
const rows = () => (store[name] ??= []);
return {
async getFullList<T>(): Promise<T[]> {
return rows() as unknown as T[];
},
async create(data: Record<string, unknown>) {
const customer = String(data.customer ?? "");
if (failures[name]?.has(customer)) throw new Error("create failed");
const row = { ...data, id: `v-${nextId++}` } as Row;
rows().push(row);
return row;
},
async update(id: string, data: Record<string, unknown>) {
if (failures[name]?.has(id)) throw new Error("update failed");
const row = rows().find((r) => r.id === id);
if (!row) throw new Error("not found");
Object.assign(row, data);
return row;
},
};
},
};
return api as unknown as Awaited<
ReturnType<typeof import("./pocketbase-admin").getAdminPocketBase>
> & { store: Record<string, Row[]>; failOn: (c: string, id: string) => void };
}
beforeEach(() => {
vi.clearAllMocks();
setCustomerCoordinates.mockResolvedValue({ id: "c1" });
});
describe("getCustomerSnapshot", () => {
it("flattens the company name and carries real coordinates", async () => {
const pb = fakePb({
customers: [
{
id: "c1",
customer: "DANIE GREYLING",
rep: "u-tiaan",
coordinates: { lat: -26.4, lon: 27.4 },
expand: { company: { company: "Sibanye - Driefontein" } },
},
] as Row[],
});
expect(await getCustomerSnapshot(pb)).toEqual([
{
id: "c1",
customer: "DANIE GREYLING",
companyName: "Sibanye - Driefontein",
rep: "u-tiaan",
lat: -26.4,
lon: 27.4,
},
]);
});
it("omits coordinates for an unpinned customer rather than sending Null Island", async () => {
// Every customer starts at PB's `{lon: 0, lat: 0}` default. Shipping
// that as a position would put the whole customer base in the Gulf
// of Guinea and make the nearby list nonsense.
const pb = fakePb({
customers: [
{ id: "c1", customer: "A", rep: "", coordinates: { lat: 0, lon: 0 } },
{ id: "c2", customer: "B", rep: "" },
] as Row[],
});
const snapshot = await getCustomerSnapshot(pb);
expect(snapshot[0]).not.toHaveProperty("lat");
expect(snapshot[1]).not.toHaveProperty("lon");
});
it("still includes unpinned customers — the search box needs them", async () => {
// On day one *every* customer is unpinned. Filtering them out would
// make the app useless until someone had already seeded the map.
const pb = fakePb({
customers: [{ id: "c1", customer: "A", coordinates: { lat: 0, lon: 0 } }] as Row[],
});
expect(await getCustomerSnapshot(pb)).toHaveLength(1);
});
it("tolerates a missing company expand", async () => {
const pb = fakePb({ customers: [{ id: "c1", customer: "A" }] as Row[] });
const [row] = await getCustomerSnapshot(pb);
expect(row.companyName).toBeNull();
expect(row.rep).toBe("");
});
});
describe("recordVisits", () => {
const base = {
repId: "u-tiaan",
lat: -26.4,
lon: 27.4,
positionSource: "phone" as const,
visitedAt: "2026-09-06T09:15:00.000Z",
};
it("writes one visit row per customer seen", async () => {
const pb = fakePb({ customers: [{ id: "c1" }, { id: "c2" }] as Row[] });
const result = await recordVisits(
{ ...base, accuracyM: 12, visits: [{ customerId: "c1" }, { customerId: "c2" }] },
pb
);
expect(result.created).toBe(2);
expect(pb.store.rep_visits[0]).toMatchObject({
rep: "u-tiaan",
customer: "c1",
visited_at: "2026-09-06T09:15:00.000Z",
coordinates: { lat: -26.4, lon: 27.4 },
position_source: "phone",
accuracy_m: 12,
pinned_customer: false,
});
});
it("stamps coordinates onto a customer being pinned", async () => {
const pb = fakePb({ customers: [{ id: "c1" }] as Row[] });
const result = await recordVisits(
{ ...base, visits: [{ customerId: "c1", pinCustomer: true }] },
pb
);
expect(setCustomerCoordinates).toHaveBeenCalledWith("c1", -26.4, 27.4, pb);
expect(result.pinned).toBe(1);
expect(pb.store.rep_visits[0].pinned_customer).toBe(true);
});
it("does not claim a pin that failed to write", async () => {
// A visit row saying `pinned_customer: true` when nothing was
// written would be a lie in the audit trail.
setCustomerCoordinates.mockRejectedValue(new Error("PB down"));
const pb = fakePb({ customers: [{ id: "c1" }] as Row[] });
const result = await recordVisits(
{ ...base, visits: [{ customerId: "c1", pinCustomer: true }] },
pb
);
expect(result.created).toBe(1);
expect(result.pinned).toBe(0);
expect(pb.store.rep_visits[0].pinned_customer).toBe(false);
});
it("keeps the other visits when one customer fails", async () => {
// Reps submit from a replayed offline queue; losing four good visits
// because one customer id went stale would be the worst outcome.
const pb = fakePb({ customers: [{ id: "c1" }, { id: "c2" }] as Row[] });
pb.failOn("rep_visits", "c1");
const result = await recordVisits(
{ ...base, visits: [{ customerId: "c1" }, { customerId: "c2" }] },
pb
);
expect(result.created).toBe(1);
expect(pb.store.rep_visits[0].customer).toBe("c2");
});
it("updates lastvisiteddate on the customer", async () => {
const pb = fakePb({ customers: [{ id: "c1" }] as Row[] });
await recordVisits({ ...base, visits: [{ customerId: "c1" }] }, pb);
expect(pb.store.customers[0].lastvisiteddate).toBe("2026-09-06T09:15:00.000Z");
});
it("still records the visit when lastvisiteddate cannot be updated", async () => {
const pb = fakePb({ customers: [{ id: "c1" }] as Row[] });
pb.failOn("customers", "c1");
const result = await recordVisits({ ...base, visits: [{ customerId: "c1" }] }, pb);
expect(result.created).toBe(1);
});
it("carries notes through", async () => {
const pb = fakePb({ customers: [{ id: "c1" }] as Row[] });
await recordVisits(
{ ...base, visits: [{ customerId: "c1", notes: "Quoted on hex nuts" }] },
pb
);
expect(pb.store.rep_visits[0].notes).toBe("Quoted on hex nuts");
});
it("records which position source was used", async () => {
const pb = fakePb({ customers: [{ id: "c1" }] as Row[] });
await recordVisits(
{ ...base, positionSource: "tracker", visits: [{ customerId: "c1" }] },
pb
);
expect(pb.store.rep_visits[0].position_source).toBe("tracker");
});
it("skips blank customer ids without failing the batch", async () => {
const pb = fakePb({ customers: [{ id: "c1" }] as Row[] });
const result = await recordVisits(
{ ...base, visits: [{ customerId: "" }, { customerId: "c1" }] },
pb
);
expect(result.created).toBe(1);
});
it("rejects a submission with no rep, no customers, or a bad position", async () => {
const pb = fakePb();
await expect(
recordVisits({ ...base, repId: "", visits: [{ customerId: "c1" }] }, pb)
).rejects.toThrow(/repId/);
await expect(recordVisits({ ...base, visits: [] }, pb)).rejects.toThrow(/at least one/);
await expect(
recordVisits({ ...base, lat: 99, visits: [{ customerId: "c1" }] }, pb)
).rejects.toThrow(/lat out of range/);
await expect(
recordVisits({ ...base, lon: NaN, visits: [{ customerId: "c1" }] }, pb)
).rejects.toThrow(/lon out of range/);
});
it("defaults visitedAt to now when the caller omits it", async () => {
const pb = fakePb({ customers: [{ id: "c1" }] as Row[] });
await recordVisits(
{ repId: "u", lat: -26.4, lon: 27.4, positionSource: "phone", visits: [{ customerId: "c1" }] },
pb
);
const at = new Date(String(pb.store.rep_visits[0].visited_at)).getTime();
expect(Math.abs(Date.now() - at)).toBeLessThan(5000);
});
});

View file

@ -0,0 +1,211 @@
/**
* Rep visit check-in.
*
* A rep opens the PWA at a mine shaft, the phone takes a GPS fix, and
* the app shows which customers are around them. They tick who they
* saw and submit.
*
* ## Why the phone matches, not the server
*
* This service does **not** expose a "customers near this point"
* endpoint. It hands the phone a compact snapshot of every customer
* and the phone runs `rankNearbyCustomers` locally.
*
* That's deliberate. Mine shafts have poor signal, and the whole point
* of using the phone's GPS rather than the vehicle tracker is that GPS
* needs no network. If matching required a round-trip, the check-in
* would fail in exactly the places reps use it. Matching locally gives
* one code path that behaves identically online and off — and the
* snapshot is small (~616 rows) and cacheable.
*
* ## Why phone GPS and not the tracker
*
* The tracker knows where the *bakkie* is; the phone knows where the
* *rep* is, and they may be several hundred metres apart once the rep
* has walked into the plant. `position_source` records which was used,
* and the independently-collected vehicle trail
* (`vehicle-trail-service.ts`) corroborates the check-in afterwards
* without the rep doing anything.
*/
import { getAdminPocketBase } from "./pocketbase-admin";
import { setCustomerCoordinates } from "./customers-service";
import { isRealGeoPoint } from "@/lib/geo";
const CUSTOMERS_COLLECTION = "customers";
const VISITS_COLLECTION = "rep_visits";
type AdminPocketBase = Awaited<ReturnType<typeof getAdminPocketBase>>;
/**
* How a check-in's position was obtained.
*
* NOTE: these must exist as values on the `rep_visits.position_source`
* select in the PB admin — PocketBase rejects an unknown select value
* with a 500 (see AGENTS.md).
*/
export type PositionSource = "phone" | "tracker" | "manual";
/** One customer as the phone needs it for offline matching. */
export interface CustomerSnapshotRow {
id: string;
customer: string;
companyName: string | null;
rep: string;
/** Omitted entirely when the customer has no real position yet. */
lat?: number;
lon?: number;
}
export interface RecordVisitInput {
customerId: string;
notes?: string;
/**
* Stamp the check-in position onto the customer. The phone defaults
* this on for customers that have no coordinates yet — that's how the
* map builds itself, one visit at a time.
*/
pinCustomer?: boolean;
}
export interface RecordVisitsArgs {
repId: string;
lat: number;
lon: number;
positionSource: PositionSource;
/** GPS accuracy in metres, when the browser reported one. */
accuracyM?: number | null;
/** Age of the fix in seconds. */
fixAgeSeconds?: number | null;
/** ISO timestamp of the visit. Defaults to now. */
visitedAt?: string;
visits: RecordVisitInput[];
}
export interface RecordVisitsResult {
created: number;
pinned: number;
visitIds: string[];
}
/**
* Every customer, flattened for the phone's local cache.
*
* Sends customers **without** coordinates too. On day one that's all of
* them, and the rep reaches those through the search box — which is
* also how they get pinned in the first place. Filtering them out would
* make the app useless until someone had already seeded the map.
*/
export async function getCustomerSnapshot(
pbArg?: AdminPocketBase
): Promise<CustomerSnapshotRow[]> {
const pb = pbArg ?? (await getAdminPocketBase());
const rows = await pb.collection(CUSTOMERS_COLLECTION).getFullList<{
id: string;
customer?: string;
rep?: string;
coordinates?: { lat: number; lon: number };
expand?: { company?: { company?: string } };
}>({
fields: "id,customer,rep,coordinates,expand.company.company",
expand: "company",
sort: "customer",
});
return rows.map((row) => {
const snapshot: CustomerSnapshotRow = {
id: row.id,
customer: row.customer ?? "",
companyName: row.expand?.company?.company ?? null,
rep: row.rep ?? "",
};
// Null Island is PB's default for an unset geoPoint, so an
// unpinned customer must not ship as (0, 0) — the phone would
// place the entire customer base off the coast of Ghana.
if (isRealGeoPoint(row.coordinates)) {
snapshot.lat = row.coordinates.lat;
snapshot.lon = row.coordinates.lon;
}
return snapshot;
});
}
/**
* Writes one check-in: a `rep_visits` row per customer seen, plus the
* coordinate stamp for any customer being pinned.
*
* Partial success is deliberate. Reps submit in poor signal and often
* from a replayed offline queue; failing the whole batch because one
* customer id went stale would lose the other four visits with it. Each
* visit is written independently and the caller gets the count.
*/
export async function recordVisits(
args: RecordVisitsArgs,
pbArg?: AdminPocketBase
): Promise<RecordVisitsResult> {
const pb = pbArg ?? (await getAdminPocketBase());
if (!args.repId) throw new Error("recordVisits: repId is required");
if (!Number.isFinite(args.lat) || args.lat < -90 || args.lat > 90) {
throw new Error(`recordVisits: lat out of range (${String(args.lat)})`);
}
if (!Number.isFinite(args.lon) || args.lon < -180 || args.lon > 180) {
throw new Error(`recordVisits: lon out of range (${String(args.lon)})`);
}
if (args.visits.length === 0) {
throw new Error("recordVisits: at least one customer is required");
}
const visitedAt = args.visitedAt ?? new Date().toISOString();
const result: RecordVisitsResult = { created: 0, pinned: 0, visitIds: [] };
for (const visit of args.visits) {
if (!visit.customerId) continue;
// Pin first: if the coordinate write fails the visit is still worth
// recording, but a visit row claiming `pinned_customer: true` when
// nothing was written would be a lie in the audit trail.
let pinned = false;
if (visit.pinCustomer) {
try {
await setCustomerCoordinates(visit.customerId, args.lat, args.lon, pb);
pinned = true;
result.pinned++;
} catch {
pinned = false;
}
}
try {
const row = await pb.collection(VISITS_COLLECTION).create({
rep: args.repId,
customer: visit.customerId,
visited_at: visitedAt,
coordinates: { lat: args.lat, lon: args.lon },
position_source: args.positionSource,
fix_age_seconds: args.fixAgeSeconds ?? 0,
accuracy_m: args.accuracyM ?? 0,
notes: visit.notes ?? "",
pinned_customer: pinned,
});
result.created++;
result.visitIds.push((row as { id: string }).id);
} catch {
continue;
}
// `lastvisiteddate` predates this feature and is shown on the
// customer page, so keep it in step. Best-effort: a visit that
// recorded fine shouldn't fail because this did.
try {
await pb
.collection(CUSTOMERS_COLLECTION)
.update(visit.customerId, { lastvisiteddate: visitedAt });
} catch {
/* non-fatal */
}
}
return result;
}