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:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user