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:
Nora
2026-06-29 12:51:28 -06:00
parent f5d119fbe7
commit 59c3275adf
8 changed files with 1173 additions and 91 deletions
+81
View File
@@ -153,6 +153,66 @@ export interface ListActivityParams {
limit?: number;
}
// ---------------------------------------------------------------------------
// Dashboard KPI types (SP27 Task 13).
//
// Returned by ``GET /api/dashboard/kpis`` — server-aggregated over the
// *entire* claim population. The Dashboard renders these directly; it
// no longer paginates ``/api/claims`` and reduces client-side (which
// silently produced wrong numbers with the previous ``limit: 100``
// cap).
// ---------------------------------------------------------------------------
export interface DashboardTotals {
count: number;
billed: number;
received: number;
outstandingAr: number;
denied: number;
denialRate: number;
pending: number;
}
export interface DashboardMonthly {
month: string; // "YYYY-MM"
label: string; // "Jan"
count: number;
billed: number;
received: number;
denied: number;
denialRate: number;
ar: number;
}
export interface DashboardTopProvider {
npi: string;
label: string;
claimCount: number;
billed: number;
denied: number;
}
export interface DashboardTopDenial {
id: string;
patientName: string;
billedAmount: number;
denialReason: string | null;
submissionDate: string;
}
export interface DashboardKpis {
totals: DashboardTotals;
monthly: DashboardMonthly[];
topProviders: DashboardTopProvider[];
topDenials: DashboardTopDenial[];
}
export interface DashboardKpisParams {
months?: number;
top_n_providers?: number;
top_n_denials?: number;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
@@ -702,6 +762,26 @@ async function listActivity<T = unknown>(
);
}
/**
* Fetch server-aggregated Dashboard KPIs.
*
* Drives ``GET /api/dashboard/kpis``. Computes billed / received /
* denial rate / pending AR / top providers / top denials server-side
* over the *full* claim population so the Dashboard's numbers are
* always correct regardless of dataset size. With 60k+ claims in
* production, fetching ``/api/claims?limit=100`` and reducing
* client-side silently produced wrong KPIs — this endpoint replaces
* that pattern.
*/
async function getDashboardKpis(
params: DashboardKpisParams = {}
): Promise<DashboardKpis> {
if (!isConfigured) throw notConfiguredError();
return authedFetch<DashboardKpis>(
`/api/dashboard/kpis${qs(params as Record<string, unknown>)}`
);
}
// ---------------------------------------------------------------------------
// Public surface — reconciliation endpoints (sub-project 2)
// POSTs throw `ApiError` so callers can inspect `.status`; the GET is shaped
@@ -960,4 +1040,5 @@ export const api = {
listAcks,
getAck,
listTa1Acks,
getDashboardKpis,
};