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.
126 lines
4.4 KiB
TypeScript
126 lines
4.4 KiB
TypeScript
// @vitest-environment happy-dom
|
|
// SP27 Task 13: server-aggregated Dashboard KPIs.
|
|
//
|
|
// These tests pin the hook's contract independent of the Dashboard
|
|
// page: when the backend is wired, it calls ``api.getDashboardKpis``
|
|
// with the requested parameters and returns the resolved value; when
|
|
// the backend is not wired, it returns ``data: undefined`` so the
|
|
// Dashboard can render zero-shaped fallbacks.
|
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
import { renderHook, waitFor } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import React from "react";
|
|
import { api, type DashboardKpis } from "@/lib/api";
|
|
|
|
// Mock the api module so we can control isConfigured + getDashboardKpis
|
|
// without spinning up a real backend.
|
|
vi.mock("@/lib/api", async () => {
|
|
const actual = await vi.importActual<typeof import("@/lib/api")>("@/lib/api");
|
|
return {
|
|
...actual,
|
|
api: {
|
|
...actual.api,
|
|
getDashboardKpis: vi.fn(),
|
|
},
|
|
};
|
|
});
|
|
|
|
// Import AFTER the mock so the hook sees the mocked api.
|
|
const { useDashboardKpis } = await import("./useDashboardKpis");
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function makeWrapper() {
|
|
const client = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false, gcTime: 0, staleTime: 0 },
|
|
},
|
|
});
|
|
return ({ children }: { children: React.ReactNode }) =>
|
|
React.createElement(QueryClientProvider, { client }, children);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("useDashboardKpis", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("calls api.getDashboardKpis and returns the resolved value when configured", async () => {
|
|
const payload: DashboardKpis = {
|
|
totals: {
|
|
count: 60295,
|
|
billed: 940143.58,
|
|
received: 59753,
|
|
outstandingAr: 880390.58,
|
|
denied: 542,
|
|
denialRate: 0.9,
|
|
pending: 60,
|
|
},
|
|
monthly: [
|
|
{ month: "2026-01", label: "Jan", count: 100, billed: 15000,
|
|
received: 12000, denied: 2, denialRate: 2.0, ar: 3000 },
|
|
],
|
|
topProviders: [
|
|
{ npi: "1234567893", label: "Cedar Park", claimCount: 200,
|
|
billed: 30000, denied: 1 },
|
|
],
|
|
topDenials: [
|
|
{ id: "C-1", patientName: "Jane Doe", billedAmount: 250,
|
|
denialReason: "Missing modifier", submissionDate: "2026-06-20T12:00:00Z" },
|
|
],
|
|
};
|
|
(api.getDashboardKpis as ReturnType<typeof vi.fn>).mockResolvedValue(payload);
|
|
// Override the isConfigured bit too — the hook keys off this.
|
|
(api as unknown as { isConfigured: boolean }).isConfigured = true;
|
|
|
|
const { result } = renderHook(() => useDashboardKpis({ months: 6 }), {
|
|
wrapper: makeWrapper(),
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.data).toEqual(payload);
|
|
});
|
|
expect(api.getDashboardKpis).toHaveBeenCalledWith({ months: 6 });
|
|
expect(result.current.isError).toBe(false);
|
|
});
|
|
|
|
it("returns data: undefined when the backend is not configured", () => {
|
|
(api as unknown as { isConfigured: boolean }).isConfigured = false;
|
|
|
|
const { result } = renderHook(() => useDashboardKpis(), {
|
|
wrapper: makeWrapper(),
|
|
});
|
|
|
|
expect(result.current.data).toBeUndefined();
|
|
expect(result.current.isLoading).toBe(false);
|
|
expect(result.current.isError).toBe(false);
|
|
// queryFn should NOT have been called — bypassed entirely.
|
|
expect(api.getDashboardKpis).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("passes top_n_providers / top_n_denials through to the API", async () => {
|
|
(api.getDashboardKpis as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
totals: { count: 0, billed: 0, received: 0, outstandingAr: 0,
|
|
denied: 0, denialRate: 0, pending: 0 },
|
|
monthly: [], topProviders: [], topDenials: [],
|
|
});
|
|
(api as unknown as { isConfigured: boolean }).isConfigured = true;
|
|
|
|
renderHook(
|
|
() => useDashboardKpis({ months: 3, top_n_providers: 2, top_n_denials: 5 }),
|
|
{ wrapper: makeWrapper() },
|
|
);
|
|
|
|
expect(api.getDashboardKpis).toHaveBeenCalledWith({
|
|
months: 3, top_n_providers: 2, top_n_denials: 5,
|
|
});
|
|
});
|
|
}); |