import { useCallback, useEffect, useState } from 'react' import type { LedgerEntry, LedgerKind } from './api' import { syncAppLedger } from './api' import { dayOffsetIso, pickInt, pickOne, rng, seedOf, todayIso } from './demo/seed' /* * Offline-first ledger. * * Every entry is written to localStorage first and marked PENDING; a sync pass * flips it to SYNCED. This is what makes the deck's "works with zero * connectivity" claim true in the prototype — the owner never sees a spinner * and never loses a tap. */ const KEY = 'ul-ledger' const SYNC_KEY = 'ul-last-sync' export const CATEGORIES: Record = { IN: [ { code: 'SALE', en: 'Sale', hi: 'बिक्री' }, { code: 'UPI', en: 'UPI received', hi: 'UPI आया' }, { code: 'RECOVERY', en: 'Udhaar recovered', hi: 'उधारी वसूली' }, { code: 'OTHER_IN', en: 'Other', hi: 'अन्य' }, ], OUT: [ { code: 'INPUT', en: 'Raw material / feed', hi: 'कच्चा माल / दाना' }, { code: 'WAGES', en: 'Wages', hi: 'मज़दूरी' }, { code: 'TRANSPORT', en: 'Transport', hi: 'भाड़ा' }, { code: 'EMI', en: 'Instalment', hi: 'किस्त' }, { code: 'OTHER_OUT', en: 'Other', hi: 'अन्य' }, ], STOCK: [ { code: 'STOCK_IN', en: 'Stock added', hi: 'स्टॉक आया' }, { code: 'STOCK_OUT', en: 'Stock sold', hi: 'स्टॉक गया' }, ], } function read(): LedgerEntry[] { try { const raw = localStorage.getItem(KEY) return raw ? (JSON.parse(raw) as LedgerEntry[]) : [] } catch { return [] } } function write(entries: LedgerEntry[]) { localStorage.setItem(KEY, JSON.stringify(entries)) window.dispatchEvent(new Event('ul-ledger-changed')) } /** 30 days of plausible history, generated once per enterprise so the app is never empty. */ function seedHistory(enterpriseId: string): LedgerEntry[] { const r = rng(seedOf(`${enterpriseId}:ledger`)) const out: LedgerEntry[] = [] for (let d = 30; d >= 0; d--) { const entries = pickInt(r, 1, 3) for (let i = 0; i < entries; i++) { const kind: LedgerKind = r() > 0.45 ? 'IN' : 'OUT' const cat = pickOne(r, CATEGORIES[kind]) out.push({ id: `seed-${enterpriseId}-${d}-${i}`, enterpriseId, kind, amount: kind === 'IN' ? pickInt(r, 6, 52) * 100 : pickInt(r, 4, 38) * 100, category: cat.code, note: null, entryDate: dayOffsetIso(-d), syncState: 'SYNCED', createdAt: new Date().toISOString(), }) } } return out } export function loadLedger(enterpriseId: string): LedgerEntry[] { const all = read() const mine = all.filter((e) => e.enterpriseId === enterpriseId) if (mine.length) return mine.sort((a, b) => b.entryDate.localeCompare(a.entryDate)) const seeded = seedHistory(enterpriseId) write([...all, ...seeded]) return seeded.sort((a, b) => b.entryDate.localeCompare(a.entryDate)) } /** Three-tap write: amount → category → save. Always succeeds, online or not. */ export function addEntry(input: { enterpriseId: string kind: LedgerKind amount: number category: string note?: string }): LedgerEntry { const entry: LedgerEntry = { id: `local-${Date.now()}-${Math.round(Math.random() * 1e6)}`, enterpriseId: input.enterpriseId, kind: input.kind, amount: input.amount, category: input.category, note: input.note ?? null, entryDate: todayIso(), syncState: 'PENDING', createdAt: new Date().toISOString(), } write([...read(), entry]) return entry } export function pendingCount(enterpriseId: string): number { return read().filter((e) => e.enterpriseId === enterpriseId && e.syncState === 'PENDING').length } /** * Drain the offline queue to the server. * *

This was the missing half of the offline promise. Entries were written to * localStorage and marked PENDING, and this function flipped them to SYNCED * without anything leaving the phone — so an owner could record a sale with no * signal, which was the point, but it never reached the lender and the officer's * screen never saw it. * *

Safe to call whenever. The server dedupes on `clientId`, which the phone * minted, so a retry after a half-failed sync writes nothing twice. Entries that * fail to send stay PENDING and go again on the next pass rather than being * dropped — losing an owner's tap is the one failure this app cannot have, so a * network error is deliberately silent from their side. */ export async function syncPending(enterpriseId: string): Promise { const pending = read().filter( (e) => e.enterpriseId === enterpriseId && e.syncState === 'PENDING', ) if (pending.length === 0) return 0 try { await syncAppLedger(pending) } catch { return 0 } // Re-read rather than reusing the snapshot: the owner may have tapped save // again while the request was in flight, and that entry must stay PENDING. const sent = new Set(pending.map((e) => e.id)) write(read().map((e) => (sent.has(e.id) ? { ...e, syncState: 'SYNCED' as const } : e))) localStorage.setItem(SYNC_KEY, new Date().toISOString()) return pending.length } export function lastSyncedAt(): string | null { return localStorage.getItem(SYNC_KEY) } export function dayTotals(entries: LedgerEntry[], date = todayIso()) { const today = entries.filter((e) => e.entryDate === date) return { in: today.filter((e) => e.kind === 'IN').reduce((a, e) => a + e.amount, 0), out: today.filter((e) => e.kind === 'OUT').reduce((a, e) => a + e.amount, 0), count: today.length, } } /** Live ledger for a given enterprise, re-reading whenever an entry is written. */ /** * The borrower.s ledger: what the server holds, plus anything typed on this * device that has not synced yet. * *

Local entries come first and win on `clientId`, which is minted on the * phone. That is what makes the offline path honest — an entry recorded with no * signal is visible immediately, and when it syncs the server copy replaces it * without ever appearing twice. */ export function useLedger(enterpriseId: string, server: LedgerEntry[] = []) { const [local, setLocal] = useState(() => loadLedger(enterpriseId)) const refresh = useCallback(() => setLocal(loadLedger(enterpriseId)), [enterpriseId]) useEffect(() => { refresh() window.addEventListener("ul-ledger-changed", refresh) return () => window.removeEventListener("ul-ledger-changed", refresh) }, [refresh]) const seen = new Set(local.map((e) => e.id)) const entries = [...local, ...server.filter((e) => !seen.has(e.id))] .sort((a, b) => b.entryDate.localeCompare(a.entryDate)) return { entries, refresh } } /** * Drain the queue whenever the network comes back. * *

Without this the offline promise was only half kept: an entry recorded with * no signal reached the lender on the owner's next save or manual tap, * and an owner who recorded a sale and put the phone away never synced at all. * The browser tells us when the radio returns; that is the moment to try. * *

Also runs once on mount, because the app is commonly opened after * connectivity is restored rather than while it is being restored — in that * case no `online` event ever fires. */ export function useAutoSync(enterpriseId: string | undefined) { useEffect(() => { if (!enterpriseId) return const drain = () => { void syncPending(enterpriseId) } drain() window.addEventListener('online', drain) return () => window.removeEventListener('online', drain) }, [enterpriseId]) }