feat(sp27): server-aggregate Dashboard KPIs so 100-row sample doesn't lie
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.
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user