189 lines
5.4 KiB
TypeScript
189 lines
5.4 KiB
TypeScript
|
|
/**
|
||
|
|
* 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);
|
||
|
|
}
|