59c3275adf
The Dashboard was hardcoded to useClaims({ limit: 100 }) and reduce
KPIs client-side. With 60k+ claims in production, every tile
(Billed $940K, Received $59K, Denial rate, Pending AR, monthly
sparkline, top providers, recent denials) was computed from a
0.16% sample of the dataset — silently wrong numbers on the
operator's primary view.
Fix: add GET /api/dashboard/kpis that aggregates server-side in
one read over the entire claim population. The new useDashboardKpis
hook consumes it and polls every 60s. Dashboard.tsx drops
useClaims({limit:100}) + useProviders() + the client-side
buildMonthly reduce.
Backend:
- store.py: dashboard_kpis() — one Claim query (selectinload on
batch to avoid N+1) + one bulk Remittance lookup, Python reduce
over the full population. Zero-filled response for empty DB.
- api.py: GET /api/dashboard/kpis behind matrix_gate, query-param
clamps (1..24 months, 0..50 top_n_*).
Frontend:
- api.ts: DashboardKpis types + getDashboardKpis() wrapper.
- useDashboardKpis.ts: TanStack Query hook, 60s refetchInterval,
bypass to data:undefined when not configured.
- Dashboard.tsx: switched to useDashboardKpis, extracted
ZERO_TOTALS constant, dropped the buildMonthly helper.
Tests:
- backend/tests/test_dashboard_kpis.py: 12 tests covering empty DB,
matched-remit math, pending-state semantics, monthly binning,
top-providers/top-denials sort + cap, orphan-claim defensive
guard, HTTP wiring + param validation.
- src/hooks/useDashboardKpis.test.ts: 3 tests for the hook
contract (configured path, unconfigured fallback, param
passthrough).
- src/pages/Dashboard.test.tsx: wrapped renders in
QueryClientProvider + stubbed useAuth + isConfigured=false. This
fixes 3 pre-existing Dashboard test failures (the page never had
a QueryClient set up because useClaims/useProviders were the
first useQuery hooks in the page).
Reviewer fixes (same commit):
1. topDenials sort placed empty submissionDate claims first under
reverse-lex. Drop them at append time.
2. r.batch lazy-load → N+1 on 60k rows. selectinload(Claim.batch).
3. pending_states rebuilt per call as a mutable set — moved to
module-level _DASHBOARD_PENDING_STATES frozenset.
4. Module-level ProviderORM import inconsistent with the
"local import inside the function" pattern — moved inline.
47 lines
1.8 KiB
TypeScript
47 lines
1.8 KiB
TypeScript
import { useQuery } from "@tanstack/react-query";
|
|
import { api, type DashboardKpis, type DashboardKpisParams } from "@/lib/api";
|
|
|
|
/**
|
|
* Server-aggregated Dashboard KPIs.
|
|
*
|
|
* Replaces the previous `useClaims({ limit: 100 })` + client-side
|
|
* reduce pattern. With 60k+ claims in production, paginating
|
|
* ``/api/claims`` and reducing client-side silently produced wrong
|
|
* numbers — the Dashboard's "Billed / Received / Denial rate / Pending
|
|
* AR" tiles were computed from a 100-row sample, not the full
|
|
* population. This hook hits ``GET /api/dashboard/kpis`` which does
|
|
* the aggregation server-side in a single read.
|
|
*
|
|
* Refreshes every 60s when the backend is configured so the KPIs stay
|
|
* roughly current without a manual reload. Live event-publish from
|
|
* ``useTailStream`` would be more elegant, but the underlying
|
|
* aggregates span the whole DB so we'd need a new "kpi_updated"
|
|
* event; 60s polling is a clear-enough SLA for a Dashboard view.
|
|
*
|
|
* Sample-data mode (no backend wired): returns ``data: undefined``
|
|
* rather than calling ``useQuery``. The Dashboard handles that with
|
|
* zero-shaped fallbacks so the KPI tiles render a coherent "0" rather
|
|
* than throwing. There's no in-memory aggregator — the old
|
|
* client-side reduce was the bug, not a feature to preserve.
|
|
*/
|
|
export function useDashboardKpis(params: DashboardKpisParams = {}) {
|
|
const q = useQuery<DashboardKpis>({
|
|
queryKey: ["dashboard", "kpis", params],
|
|
queryFn: () => api.getDashboardKpis(params),
|
|
enabled: api.isConfigured,
|
|
refetchInterval: api.isConfigured ? 60_000 : false,
|
|
staleTime: 30_000,
|
|
});
|
|
|
|
if (!api.isConfigured) {
|
|
return {
|
|
data: undefined,
|
|
isLoading: false,
|
|
isError: false,
|
|
error: null,
|
|
refetch: () => Promise.resolve(),
|
|
dataUpdatedAt: 0,
|
|
} as const;
|
|
}
|
|
return q;
|
|
} |