Files
cyclone/src/hooks/useDashboardKpis.ts
T
Nora 59c3275adf 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.
2026-06-29 12:51:28 -06:00

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;
}