Files
udyamlinc/src/lib/ledgerStore.ts
T
LincPayandClaude Opus 5 13c628fa4b feat(pwa): actually send the offline queue, and sync when the radio returns
syncPending() flipped entries from PENDING to SYNCED without sending
anything. The offline half of the promise worked — the owner could record a
sale with no signal and never saw a spinner — but the entry stopped at
localStorage and the lender never saw it.

It now posts to /app/ledger/sync and marks only the entries the server
accepted. It re-reads storage before writing rather than reusing the snapshot,
because the owner may have tapped save again while the request was in flight
and that entry must stay PENDING. A failure is deliberately silent: the
entries stay queued and go again next time, because from the owner's side
nothing has gone wrong, and losing a tap is the one failure this app cannot
have.

useAutoSync() drains the queue on the browser's `online` event and once on
mount — the app is commonly opened after connectivity returns rather than
while it is returning, in which case no online event ever fires.

The consent toggle now writes to the server instead of holding local state,
and the alerts screen renders the borrower's real reason codes from
/app/reasons. Where the request succeeds with nothing on record it says so;
inventing three sentences on the borrower's own screen would be a lie about
the one thing they are entitled to see.

Also fixes a month label that rendered blank on every live forecast point: a
${p.month} interpolation had been eaten by an earlier edit, so the strip only
displayed correctly when the API was unreachable and the demo generator took
over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 13:45:34 +05:30

212 lines
7.5 KiB
TypeScript

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<LedgerKind, { code: string; en: string; hi: string }[]> = {
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.
*
* <p>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.
*
* <p>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<number> {
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.
*
* <p>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<LedgerEntry[]>(() => 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.
*
* <p>Without this the offline promise was only half kept: an entry recorded with
* no signal reached the lender on the owner's <em>next</em> 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.
*
* <p>Also runs once on mount, because the app is commonly opened <em>after</em>
* 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])
}