Reported live: a customer (Benjamin) was pinned this morning, but the
rep check-in screen never showed him nearby no matter how far the
search radius was widened — even though the server-side query for him
was confirmed correct and fast.
Root cause: the customer snapshot only ever fetched once, on mount
(empty-deps useEffect). A PWA is rarely actually closed — it gets
backgrounded and resumed — so reopening the app after someone else's
pin landed didn't count as "loading" it again; the phone kept working
off whatever list it cached before that write happened. Widening the
radius only changes which of the *already-loaded* customers match; it
can't find someone who was never in the list to begin with.
This was invisible because the freshness label only showed the
calendar date ("updated 9/7/2026"), so a snapshot from three hours
earlier read identically to one from thirty seconds ago for the rest
of that day.
Fixes, in snapshot-freshness.ts (pure, 9 tests) + check-in-view.tsx:
- Label now shows relative age ("5 min ago", "3h ago"), ticking forward
every 30s while the screen sits open.
- Snapshot older than 15 min is flagged with an inline "refresh" link.
- Refetches automatically when the app regains foreground focus AND
the cached snapshot has gone stale (visibilitychange + staleness
check — not on every glance, to avoid needless refetching).
- Always-available manual refresh icon in the header, independent of
staleness, for a rep who has out-of-band reason to believe the list
changed (e.g. told a colleague just pinned someone).
Separately, on the same report: "Henny" showing as nearby instead of
Benjamin was NOT a bug — there are 6 different "Hennie"-named customers
in the database, 5 unpinned and one (HENNIE, all caps) with real
coordinates 552m from Benjamin's, genuinely in the Driefontein area,
not Fochville. The app matched correctly; the ambiguous duplicate name
is a data-quality issue for the batch pin tool to help sort out
separately, not a code fix.
27 tests pass in src/app/(rep). Verified the server-side customer query
directly against PocketBase during diagnosis — confirmed correct and
not the source of the bug.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
548 lines
19 KiB
TypeScript
548 lines
19 KiB
TypeScript
"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";
|
|
import { formatSnapshotAge, isSnapshotStale } from "./snapshot-freshness";
|
|
|
|
/** 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 [refreshing, setRefreshing] = useState(false);
|
|
const [, forceTick] = useState(0);
|
|
const repIdRef = useRef<string>("");
|
|
|
|
/* ------------------------------------------------------ boot + sync */
|
|
|
|
const refreshPending = useCallback(() => setPending(readOutbox().length), []);
|
|
|
|
const loadSnapshot = useCallback(async (options?: { fromCacheOnly?: boolean }) => {
|
|
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);
|
|
}
|
|
if (options?.fromCacheOnly) return;
|
|
|
|
setRefreshing(true);
|
|
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.
|
|
} finally {
|
|
setRefreshing(false);
|
|
}
|
|
}, []);
|
|
|
|
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);
|
|
|
|
// The customer list used to only ever fetch once, on mount. A PWA
|
|
// is rarely actually closed — it gets backgrounded and resumed —
|
|
// so a snapshot from hours earlier would sit unrefreshed for the
|
|
// rest of the day with nothing prompting a reload. This is exactly
|
|
// how a customer pinned that morning became invisible on a phone
|
|
// that had the app open since before the pin: reopening the app
|
|
// (foregrounding it) didn't count as "loading" it again.
|
|
//
|
|
// Re-fetching on every foreground would be wasteful for a rep
|
|
// glancing at the screen repeatedly, so this only fires when the
|
|
// cached snapshot has actually gone stale (see `STALE_AFTER_MS`).
|
|
const onVisible = () => {
|
|
if (document.visibilityState !== "visible") return;
|
|
const cached = readCachedSnapshot();
|
|
if (!cached || isSnapshotStale(cached.fetchedAt)) {
|
|
void loadSnapshot();
|
|
}
|
|
};
|
|
|
|
window.addEventListener("online", goOnline);
|
|
window.addEventListener("offline", goOffline);
|
|
document.addEventListener("visibilitychange", onVisible);
|
|
if (navigator.onLine) void flush();
|
|
|
|
// Ticks the "X min ago" label forward even if nothing else
|
|
// re-renders the screen while it's sitting open.
|
|
const clock = window.setInterval(() => forceTick((n) => n + 1), 30_000);
|
|
|
|
return () => {
|
|
window.removeEventListener("online", goOnline);
|
|
window.removeEventListener("offline", goOffline);
|
|
document.removeEventListener("visibilitychange", onVisible);
|
|
window.clearInterval(clock);
|
|
};
|
|
}, [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 {formatSnapshotAge(snapshotAt)}
|
|
{isSnapshotStale(snapshotAt) && (
|
|
<button
|
|
onClick={() => void loadSnapshot()}
|
|
disabled={refreshing}
|
|
className="ml-1.5 inline-flex items-center gap-1 text-primary underline underline-offset-2"
|
|
>
|
|
<RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} />
|
|
refresh
|
|
</button>
|
|
)}
|
|
</>
|
|
) : (
|
|
"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>
|
|
)}
|
|
<button
|
|
onClick={() => void loadSnapshot()}
|
|
disabled={refreshing || !online}
|
|
aria-label="Refresh customer list"
|
|
title="Refresh customer list"
|
|
className="rounded-full p-1.5 text-muted-foreground disabled:opacity-40"
|
|
>
|
|
<RefreshCw className={`h-4 w-4 ${refreshing ? "animate-spin" : ""}`} />
|
|
</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>
|
|
);
|
|
}
|