From 95d93791f977613c1f9b8e9546730e5db75f8bd2 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 7 Sep 2026 09:10:18 +0200 Subject: [PATCH] fix(rep): refresh the customer snapshot on reopen, not just on first load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/(rep)/rep/check-in-view.tsx | 64 ++++++++++++++++++-- src/app/(rep)/rep/snapshot-freshness.test.ts | 58 ++++++++++++++++++ src/app/(rep)/rep/snapshot-freshness.ts | 51 ++++++++++++++++ 3 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 src/app/(rep)/rep/snapshot-freshness.test.ts create mode 100644 src/app/(rep)/rep/snapshot-freshness.ts diff --git a/src/app/(rep)/rep/check-in-view.tsx b/src/app/(rep)/rep/check-in-view.tsx index a47c691..aabcf6b 100644 --- a/src/app/(rep)/rep/check-in-view.tsx +++ b/src/app/(rep)/rep/check-in-view.tsx @@ -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(""); /* ------------------------------------------------------ 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() {

Log a visit

- {snapshotAt - ? `${customers.length} customers · updated ${new Date(snapshotAt).toLocaleDateString()}` - : "Loading customers…"} + {snapshotAt ? ( + <> + {customers.length} customers · updated {formatSnapshotAge(snapshotAt)} + {isSnapshotStale(snapshotAt) && ( + + )} + + ) : ( + "Loading customers…" + )}

@@ -294,6 +341,15 @@ export function CheckInView() { {pending} to sync )} +
diff --git a/src/app/(rep)/rep/snapshot-freshness.test.ts b/src/app/(rep)/rep/snapshot-freshness.test.ts new file mode 100644 index 0000000..9b7777f --- /dev/null +++ b/src/app/(rep)/rep/snapshot-freshness.test.ts @@ -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); + }); +}); diff --git a/src/app/(rep)/rep/snapshot-freshness.ts b/src/app/(rep)/rep/snapshot-freshness.ts new file mode 100644 index 0000000..8fc7ee6 --- /dev/null +++ b/src/app/(rep)/rep/snapshot-freshness.ts @@ -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; +}