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>
This commit is contained in:
+1
-1
@@ -6,7 +6,7 @@
|
||||
* queued in localStorage by the app itself (see lib/ledgerStore.ts); this worker
|
||||
* only guarantees the shell and its assets are reachable.
|
||||
*/
|
||||
const CACHE = 'udyamlinc-v1'
|
||||
const CACHE = 'udyamlinc-v2'
|
||||
const SHELL = ['/', '/app', '/index.html', '/manifest.webmanifest', '/udyamlinc-tile.svg']
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { enterpriseSession } from '../../lib/api'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { enterpriseSession, fetchAppAlerts, fetchAppReasons } from '../../lib/api'
|
||||
import { Empty } from '../../components/ui/bits'
|
||||
import { findEnterprise, reasonCodesFor, tripWiresFor } from '../../lib/demo/enterprises'
|
||||
import { alertForEnterprise } from '../../lib/demo/ops'
|
||||
@@ -15,11 +16,28 @@ export function AppAlertsPage() {
|
||||
const { lang, t } = useLang('hi')
|
||||
const hi = lang === 'hi'
|
||||
|
||||
// Live alerts for this borrower. The token decides whose they are.
|
||||
const alertsQ = useQuery({ queryKey: ["app-alerts"], queryFn: fetchAppAlerts, enabled: Boolean(session) })
|
||||
// The scored reasons, not a sentence generated from the demo sector table.
|
||||
const reasonsQ = useQuery({ queryKey: ["app-reasons"], queryFn: fetchAppReasons, enabled: Boolean(session) })
|
||||
|
||||
const e = session ? findEnterprise(session.enterpriseId) : undefined
|
||||
if (!session || !e) return <Empty text="—" />
|
||||
|
||||
const alert = alertForEnterprise(e.enterpriseId)
|
||||
const reasons = reasonCodesFor(e)
|
||||
const live = alertsQ.data?.[0]
|
||||
const alert = live
|
||||
? { ...live, titleHi: live.titleHi ?? live.title, shortfall: live.shortfall ?? 0,
|
||||
breachMonth: live.breachMonth ?? "",
|
||||
reason: { driver: "", driverHi: "", weight: 0,
|
||||
sentenceEn: live.reasonEn ?? "", sentenceHi: live.reasonHi ?? "" } }
|
||||
: alertsQ.isSuccess ? null : alertForEnterprise(e.enterpriseId)
|
||||
// Fall back to the demo sentences only while the request is in flight — once it
|
||||
// has answered, an empty list means this borrower has no scored reasons, and
|
||||
// inventing three would be a lie told on the borrower's own screen.
|
||||
const live_reasons = reasonsQ.data
|
||||
const reasons = live_reasons
|
||||
? live_reasons.slice(0, 3)
|
||||
: reasonsQ.isSuccess ? [] : reasonCodesFor(e)
|
||||
const fired = tripWiresFor(e).filter((w) => w.fired)
|
||||
|
||||
if (!alert) {
|
||||
@@ -52,6 +70,11 @@ export function AppAlertsPage() {
|
||||
{hi ? 'ऐसा क्यों लग रहा है' : 'Why this is expected'}
|
||||
</div>
|
||||
<ul className="space-y-2.5">
|
||||
{reasons.length === 0 && (
|
||||
<li className="text-[12px] text-slate-500">
|
||||
{hi ? 'इस महीने कोई मापी गई वजह दर्ज नहीं है।' : 'No measured reason is on record for this run.'}
|
||||
</li>
|
||||
)}
|
||||
{reasons.map((r) => (
|
||||
<li key={r.driver} className="flex gap-2.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber mt-[7px] shrink-0" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { enterpriseSession } from '../../lib/api'
|
||||
import { enterpriseSession, fetchAppHome, fetchAppLedger, toLedgerEntry } from '../../lib/api'
|
||||
import type { ForecastPoint } from '../../lib/api'
|
||||
import { Empty } from '../../components/ui/bits'
|
||||
import { breachPoint, findEnterprise, forecastFor } from '../../lib/demo/enterprises'
|
||||
@@ -23,13 +24,31 @@ export function AppHomePage() {
|
||||
const [sheet, setSheet] = useState<LedgerKind | null>(null)
|
||||
|
||||
const e = session ? findEnterprise(session.enterpriseId) : undefined
|
||||
const { entries } = useLedger(session?.enterpriseId ?? '')
|
||||
const ledgerQ = useQuery({ queryKey: ["app-ledger"], queryFn: fetchAppLedger, enabled: Boolean(session) })
|
||||
const server = (ledgerQ.data ?? []).map(toLedgerEntry)
|
||||
const { entries } = useLedger(session?.enterpriseId ?? "", server)
|
||||
|
||||
// Live record for this borrower. The token carries the enterprise id, so the
|
||||
// server decides whose data this is — the app never asks for an id.
|
||||
const homeQ = useQuery({ queryKey: ["app-home"], queryFn: fetchAppHome, enabled: Boolean(session) })
|
||||
const home = homeQ.data
|
||||
|
||||
if (!session || !e) return <Empty text="—" />
|
||||
|
||||
const points = forecastFor(e)
|
||||
const alert = alertForEnterprise(e.enterpriseId)
|
||||
const breach = breachPoint(points)
|
||||
// Server forecast where we have one; the in-file generator only if the API is
|
||||
// unreachable, so a borrower with no signal still sees their own screen.
|
||||
const points = home?.forecast?.length
|
||||
? home.forecast.map((p) => ({
|
||||
month: p.month,
|
||||
label: monthRef(new Date(`${p.month}-01T00:00:00`)).label,
|
||||
labelHi: monthRef(new Date(`${p.month}-01T00:00:00`)).labelHi,
|
||||
p10: p.p10, p50: p.p50, p90: p.p90, obligation: p.obligation,
|
||||
}))
|
||||
: forecastFor(e)
|
||||
const alert = home?.alert
|
||||
? { ...home.alert, titleHi: home.alert.titleHi ?? home.alert.title }
|
||||
: alertForEnterprise(e.enterpriseId)
|
||||
const breach = breachPoint(points as never)
|
||||
const breachRef = breach ? monthRef(new Date(`${breach.month}-01T00:00:00`)) : null
|
||||
const totals = dayTotals(entries)
|
||||
const hi = lang === 'hi'
|
||||
@@ -51,7 +70,7 @@ export function AppHomePage() {
|
||||
{hi ? alert.titleHi : alert.title}
|
||||
</span>
|
||||
<span className="block text-[12.5px] font-bold mt-1">
|
||||
{t('home.estimate')}: {inr(alert.shortfall)} {t('home.shortfall')}
|
||||
{t('home.estimate')}: {inr(alert.shortfall ?? 0)} {t('home.shortfall')}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[16px] opacity-70">›</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { enterpriseSession } from '../../lib/api'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { enterpriseSession, fetchAppLedger, toLedgerEntry } from '../../lib/api'
|
||||
import type { LedgerKind } from '../../lib/api'
|
||||
import { Empty } from '../../components/ui/bits'
|
||||
import { CATEGORIES, useLedger } from '../../lib/ledgerStore'
|
||||
@@ -15,7 +16,9 @@ export function AppLedgerPage() {
|
||||
const { lang, t } = useLang('hi')
|
||||
const [range, setRange] = useState<Range>('WEEK')
|
||||
const [sheet, setSheet] = useState<LedgerKind | null>(null)
|
||||
const { entries } = useLedger(session?.enterpriseId ?? '')
|
||||
const ledgerQ = useQuery({ queryKey: ["app-ledger"], queryFn: fetchAppLedger, enabled: Boolean(session) })
|
||||
const server = (ledgerQ.data ?? []).map(toLedgerEntry)
|
||||
const { entries } = useLedger(session?.enterpriseId ?? "", server)
|
||||
const hi = lang === 'hi'
|
||||
|
||||
const cutoff = useMemo(() => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { clearSessions, enterpriseSession } from '../../lib/api'
|
||||
import { clearSessions, enterpriseSession, fetchAppHome, setAppConsent } from '../../lib/api'
|
||||
import type { Lang } from '../../lib/api'
|
||||
import { Empty } from '../../components/ui/bits'
|
||||
import { SECTORS, findEnterprise } from '../../lib/demo/enterprises'
|
||||
@@ -16,20 +17,41 @@ import { useOffline } from '../../lib/useOffline'
|
||||
export function AppProfilePage() {
|
||||
const navigate = useNavigate()
|
||||
const session = enterpriseSession()
|
||||
const queryClient = useQueryClient()
|
||||
const { lang, setLang, t } = useLang('hi')
|
||||
const offline = useOffline()
|
||||
const [consent, setConsent] = useState(true)
|
||||
const [justSynced, setJustSynced] = useState<number | null>(null)
|
||||
const hi = lang === 'hi'
|
||||
|
||||
const e = session ? findEnterprise(session.enterpriseId) : undefined
|
||||
// Live record, so the profile shows what the lender actually holds.
|
||||
const homeQ = useQuery({ queryKey: ["app-home"], queryFn: fetchAppHome, enabled: Boolean(session) })
|
||||
const api = homeQ.data?.enterprise
|
||||
|
||||
// Consent is the server's record, not a switch on this screen. Withdrawing it
|
||||
// has to stop scoring everywhere, so the toggle writes and then re-reads.
|
||||
const consent = api ? api.consentActive !== false : true
|
||||
const consentM = useMutation({
|
||||
mutationFn: (next: boolean) => setAppConsent(next),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['app-home'] }),
|
||||
})
|
||||
const demo = session ? findEnterprise(session.enterpriseId) : undefined
|
||||
const e = api
|
||||
? { ...(demo ?? ({} as never)),
|
||||
enterpriseId: String(api.enterpriseId ?? ""),
|
||||
name: String(api.name ?? ""), nameHi: String(api.nameHi ?? ""),
|
||||
block: String(api.block ?? ""), ownerCount: Number(api.ownerCount ?? 0),
|
||||
vintageMonths: Number(api.vintageMonths ?? 0),
|
||||
loanOutstanding: Number(api.loanOutstanding ?? 0),
|
||||
monthlyInstalment: Number(api.monthlyInstalment ?? 0),
|
||||
score: Number(api.score ?? 0), band: (api.band ?? "STANDARD") as never }
|
||||
: demo
|
||||
if (!session || !e) return <Empty text="—" />
|
||||
|
||||
const pending = pendingCount(e.enterpriseId)
|
||||
const synced = lastSyncedAt()
|
||||
|
||||
function doSync() {
|
||||
const n = syncPending(e!.enterpriseId)
|
||||
async function doSync() {
|
||||
const n = await syncPending(e!.enterpriseId)
|
||||
setJustSynced(n)
|
||||
setTimeout(() => setJustSynced(null), 2200)
|
||||
}
|
||||
@@ -76,10 +98,15 @@ export function AppProfilePage() {
|
||||
: 'Only transaction aggregates are read — never a name, number or account.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setConsent((c) => !c)}
|
||||
className="mt-2.5 text-[12px] font-semibold text-risk-red border border-risk-red/30 rounded-lg px-3 py-1.5"
|
||||
onClick={() => consentM.mutate(!consent)}
|
||||
disabled={consentM.isPending}
|
||||
className="mt-2.5 text-[12px] font-semibold text-risk-red border border-risk-red/30 rounded-lg px-3 py-1.5 disabled:opacity-50"
|
||||
>
|
||||
{consent ? t('profile.revoke') : hi ? 'सहमति दोबारा दें' : 'Give consent again'}
|
||||
{consentM.isPending
|
||||
? hi ? 'भेजा जा रहा है…' : 'Sending…'
|
||||
: consent
|
||||
? t('profile.revoke')
|
||||
: hi ? 'सहमति दोबारा दें' : 'Give consent again'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
@@ -96,7 +123,7 @@ export function AppProfilePage() {
|
||||
{t('profile.lastSynced')}: {synced ? new Date(synced).toLocaleString() : t('profile.never')}
|
||||
</div>
|
||||
<button
|
||||
onClick={doSync}
|
||||
onClick={() => void doSync()}
|
||||
disabled={offline || pending === 0}
|
||||
className="mt-2.5 w-full bg-forest disabled:opacity-40 text-white text-[13px] font-semibold py-2.5 rounded-lg"
|
||||
>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { enterpriseSession } from '../../lib/api'
|
||||
import { UdyamMark } from '../../components/ui/bits'
|
||||
import { useLang } from '../../lib/i18n'
|
||||
import { useOffline } from '../../lib/useOffline'
|
||||
import { pendingCount } from '../../lib/ledgerStore'
|
||||
import { pendingCount, useAutoSync } from '../../lib/ledgerStore'
|
||||
|
||||
/*
|
||||
* Enterprise PWA shell — a phone-width frame with a forest header and a four-tab
|
||||
@@ -22,11 +22,14 @@ export function EnterpriseShell() {
|
||||
const session = enterpriseSession()
|
||||
const { lang, t } = useLang('hi')
|
||||
const offline = useOffline()
|
||||
// Queued entries go the moment the radio returns, on whichever screen the
|
||||
// owner happens to be looking at.
|
||||
useAutoSync(session?.enterpriseId)
|
||||
const pending = session ? pendingCount(session.enterpriseId) : 0
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#eef3f0] flex justify-center">
|
||||
<div className="w-full max-w-[420px] bg-[#f4f7f5] min-h-screen flex flex-col shadow-[0_0_40px_rgba(6,48,31,0.08)]">
|
||||
<div className="w-full max-w-[420px] bg-page min-h-screen flex flex-col shadow-[0_0_40px_rgba(6,48,31,0.08)]">
|
||||
{/* ── header ────────────────────────────────────────── */}
|
||||
<header className="bg-forest text-white px-4 py-3 flex items-center gap-2.5 sticky top-0 z-30">
|
||||
<UdyamMark size={26} onDark />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import type { LedgerKind } from '../../lib/api'
|
||||
import { CATEGORIES, addEntry } from '../../lib/ledgerStore'
|
||||
import { CATEGORIES, addEntry, syncPending } from '../../lib/ledgerStore'
|
||||
import { useLang } from '../../lib/i18n'
|
||||
import { inr } from '../../lib/demo/seed'
|
||||
|
||||
@@ -32,14 +33,23 @@ export function EntrySheet({
|
||||
const [amount, setAmount] = useState('')
|
||||
const [category, setCategory] = useState(CATEGORIES[kind][0].code)
|
||||
const [done, setDone] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const hi = lang === 'hi'
|
||||
const meta = KIND_META[kind]
|
||||
const value = Number(amount) || 0
|
||||
|
||||
function save() {
|
||||
if (value <= 0) return
|
||||
// Local first, always. The entry is the owner's the moment they tap save;
|
||||
// reaching the lender is our problem, not theirs.
|
||||
addEntry({ enterpriseId, kind, amount: value, category })
|
||||
setDone(true)
|
||||
// Then try to send. A failure is silent by design — the entry stays PENDING
|
||||
// and the next pass picks it up.
|
||||
void syncPending(enterpriseId).then(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['app-ledger'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['app-home'] })
|
||||
})
|
||||
setTimeout(onClose, 900)
|
||||
}
|
||||
|
||||
|
||||
+72
-20
@@ -1,5 +1,6 @@
|
||||
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'
|
||||
|
||||
/*
|
||||
@@ -110,24 +111,38 @@ export function pendingCount(enterpriseId: string): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain the queue. The backend endpoint lands in a later pass — until then this
|
||||
* flips PENDING → SYNCED locally so the UI's sync states are exercisable.
|
||||
* 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 function syncPending(enterpriseId: string): number {
|
||||
const all = read()
|
||||
let n = 0
|
||||
const next = all.map((e) => {
|
||||
if (e.enterpriseId === enterpriseId && e.syncState === 'PENDING') {
|
||||
n++
|
||||
return { ...e, syncState: 'SYNCED' as const }
|
||||
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
|
||||
}
|
||||
return e
|
||||
})
|
||||
if (n) {
|
||||
write(next)
|
||||
|
||||
// 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 n
|
||||
return pending.length
|
||||
}
|
||||
|
||||
export function lastSyncedAt(): string | null {
|
||||
@@ -144,16 +159,53 @@ export function dayTotals(entries: LedgerEntry[], date = todayIso()) {
|
||||
}
|
||||
|
||||
/** Live ledger for a given enterprise, re-reading whenever an entry is written. */
|
||||
export function useLedger(enterpriseId: string) {
|
||||
const [entries, setEntries] = useState<LedgerEntry[]>(() => loadLedger(enterpriseId))
|
||||
/**
|
||||
* 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(() => setEntries(loadLedger(enterpriseId)), [enterpriseId])
|
||||
const refresh = useCallback(() => setLocal(loadLedger(enterpriseId)), [enterpriseId])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
window.addEventListener('ul-ledger-changed', refresh)
|
||||
return () => window.removeEventListener('ul-ledger-changed', 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])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user