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:
parent
7ba0b9e71f
commit
95d93791f9
3 changed files with 169 additions and 4 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
58
src/app/(rep)/rep/snapshot-freshness.test.ts
Normal file
58
src/app/(rep)/rep/snapshot-freshness.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
51
src/app/(rep)/rep/snapshot-freshness.ts
Normal file
51
src/app/(rep)/rep/snapshot-freshness.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue