From 13c628fa4bb1700b776bc283f1bd8f28073021e2 Mon Sep 17 00:00:00 2001 From: LincPay Date: Tue, 25 Aug 2026 13:45:34 +0530 Subject: [PATCH] feat(pwa): actually send the offline queue, and sync when the radio returns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- public/sw.js | 2 +- src/features/enterprise/AppAlertsPage.tsx | 29 ++++++- src/features/enterprise/AppHomePage.tsx | 31 +++++-- src/features/enterprise/AppLedgerPage.tsx | 7 +- src/features/enterprise/AppProfilePage.tsx | 45 ++++++++-- src/features/enterprise/EnterpriseShell.tsx | 7 +- src/features/enterprise/EntrySheet.tsx | 12 ++- src/lib/ledgerStore.ts | 94 ++++++++++++++++----- 8 files changed, 182 insertions(+), 45 deletions(-) diff --git a/public/sw.js b/public/sw.js index bc8c284..eb18226 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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) => { diff --git a/src/features/enterprise/AppAlertsPage.tsx b/src/features/enterprise/AppAlertsPage.tsx index 7dd2eb6..41a88e3 100644 --- a/src/features/enterprise/AppAlertsPage.tsx +++ b/src/features/enterprise/AppAlertsPage.tsx @@ -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 - 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'}
    + {reasons.length === 0 && ( +
  • + {hi ? 'इस महीने कोई मापी गई वजह दर्ज नहीं है।' : 'No measured reason is on record for this run.'} +
  • + )} {reasons.map((r) => (
  • diff --git a/src/features/enterprise/AppHomePage.tsx b/src/features/enterprise/AppHomePage.tsx index 137a587..f5a8ad6 100644 --- a/src/features/enterprise/AppHomePage.tsx +++ b/src/features/enterprise/AppHomePage.tsx @@ -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(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 - 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} - {t('home.estimate')}: {inr(alert.shortfall)} {t('home.shortfall')} + {t('home.estimate')}: {inr(alert.shortfall ?? 0)} {t('home.shortfall')} › diff --git a/src/features/enterprise/AppLedgerPage.tsx b/src/features/enterprise/AppLedgerPage.tsx index be83586..1fce6df 100644 --- a/src/features/enterprise/AppLedgerPage.tsx +++ b/src/features/enterprise/AppLedgerPage.tsx @@ -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('WEEK') const [sheet, setSheet] = useState(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(() => { diff --git a/src/features/enterprise/AppProfilePage.tsx b/src/features/enterprise/AppProfilePage.tsx index b93d33b..90816a4 100644 --- a/src/features/enterprise/AppProfilePage.tsx +++ b/src/features/enterprise/AppProfilePage.tsx @@ -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(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 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.'}

    @@ -96,7 +123,7 @@ export function AppProfilePage() { {t('profile.lastSynced')}: {synced ? new Date(synced).toLocaleString() : t('profile.never')}