fix(rep): refresh the customer snapshot on reopen, not just on first load

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>
This commit is contained in:
Dave 2026-09-07 09:10:18 +02:00
parent 7ba0b9e71f
commit 95d93791f9
3 changed files with 169 additions and 4 deletions

View file

@ -13,6 +13,7 @@ import {
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];
@ -38,13 +39,15 @@ export function CheckInView() {
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 () => {
const loadSnapshot = useCallback(async (options?: { fromCacheOnly?: boolean }) => {
const pb = getPocketBase();
repIdRef.current = pb.authStore.record?.id ?? "";
@ -55,7 +58,9 @@ export function CheckInView() {
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}` },
@ -71,6 +76,8 @@ export function CheckInView() {
} catch {
// Offline with no cache is the only genuinely broken state, and
// the empty-state copy below covers it.
} finally {
setRefreshing(false);
}
}, []);
@ -96,13 +103,39 @@ export function CheckInView() {
};
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]);
@ -275,9 +308,23 @@ export function CheckInView() {
<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…"}
{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">
@ -294,6 +341,15 @@ export function CheckInView() {
<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>

View file

@ -0,0 +1,58 @@
import { describe, it, expect } from "vitest";
import { formatSnapshotAge, isSnapshotStale, STALE_AFTER_MS } from "./snapshot-freshness";
const NOW = new Date("2026-09-07T10:00:00.000Z");
function minutesAgo(m: number): string {
return new Date(NOW.getTime() - m * 60_000).toISOString();
}
describe("formatSnapshotAge", () => {
it("says 'just now' for anything under a minute", () => {
expect(formatSnapshotAge(minutesAgo(0.5), NOW)).toBe("just now");
});
it("reports minutes for anything under an hour", () => {
// This is the exact bug: pinned this morning, cached from three
// hours-turned-minutes earlier — the old date-only label would
// have shown the same string as a fresh fetch.
expect(formatSnapshotAge(minutesAgo(5), NOW)).toBe("5 min ago");
expect(formatSnapshotAge(minutesAgo(45), NOW)).toBe("45 min ago");
});
it("reports hours for anything under a day", () => {
expect(formatSnapshotAge(minutesAgo(180), NOW)).toBe("3h ago");
});
it("falls back to a calendar date once it's more than a day old", () => {
const dayOld = new Date(NOW.getTime() - 2 * 24 * 60 * 60_000);
expect(formatSnapshotAge(dayOld.toISOString(), NOW)).toBe(dayOld.toLocaleDateString());
});
it("treats a future timestamp (clock skew) as 'just now' rather than negative", () => {
const future = new Date(NOW.getTime() + 5_000).toISOString();
expect(formatSnapshotAge(future, NOW)).toBe("just now");
});
it("returns 'unknown' for an unparseable timestamp rather than throwing", () => {
expect(formatSnapshotAge("not a date", NOW)).toBe("unknown");
});
});
describe("isSnapshotStale", () => {
it("is not stale just under the threshold", () => {
const justUnder = new Date(NOW.getTime() - (STALE_AFTER_MS - 1000));
expect(isSnapshotStale(justUnder.toISOString(), NOW)).toBe(false);
});
it("is stale just over the threshold", () => {
const justOver = new Date(NOW.getTime() - (STALE_AFTER_MS + 1000));
expect(isSnapshotStale(justOver.toISOString(), NOW)).toBe(true);
});
it("treats an unparseable timestamp as stale, not fresh", () => {
// Erring toward "tell the operator to refresh" is the safe default
// when we can't tell how old the data actually is.
expect(isSnapshotStale("garbage", NOW)).toBe(true);
});
});

View file

@ -0,0 +1,51 @@
/**
* How to phrase "when was the customer snapshot last fetched" on the
* check-in screen.
*
* Extracted as a pure function after a real bug: the label used to
* show only the calendar date (`toLocaleDateString()`), so a snapshot
* fetched at 6am and one fetched thirty seconds ago read identically —
* "updated 9/7/2026" — for the rest of that day. A rep pinned a
* customer that morning; the app had already cached the customer list
* before that write landed, never refetched (the fetch only ever runs
* once, on mount), and the date-only label gave no way to notice the
* list was stale. The new customer was simply invisible, and the
* screen offered nothing that looked wrong.
*/
const MINUTE_MS = 60_000;
const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;
/**
* Formats the age of `fetchedAt` relative to `now` as a short phrase —
* "just now", "5 min ago", "3h ago", or a calendar date once it's more
* than a day old (at that point relative phrasing stops being useful
* and the actual date is more informative).
*/
export function formatSnapshotAge(fetchedAt: string, now: Date = new Date()): string {
const fetchedMs = new Date(fetchedAt).getTime();
if (Number.isNaN(fetchedMs)) return "unknown";
const ageMs = Math.max(0, now.getTime() - fetchedMs);
if (ageMs < MINUTE_MS) return "just now";
if (ageMs < HOUR_MS) {
const minutes = Math.round(ageMs / MINUTE_MS);
return `${minutes} min ago`;
}
if (ageMs < DAY_MS) {
const hours = Math.round(ageMs / HOUR_MS);
return `${hours}h ago`;
}
return new Date(fetchedMs).toLocaleDateString();
}
/** Age past which the snapshot is worth flagging as possibly stale. */
export const STALE_AFTER_MS = 15 * MINUTE_MS;
export function isSnapshotStale(fetchedAt: string, now: Date = new Date()): boolean {
const fetchedMs = new Date(fetchedAt).getTime();
if (Number.isNaN(fetchedMs)) return true;
return now.getTime() - fetchedMs > STALE_AFTER_MS;
}