Files
cyclone/docs/superpowers/plans/2026-07-02-cyclone-dashboard-recent-batches.md

12 KiB

SP30 — Dashboard "Recent batches" widget implementation plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a single Dashboard card that lists the 5 most recently parsed batches with one-line outcome per batch (status icon, filename, billed total, accepted/rejected/pending counts, top rejection reason). The operator can spot a bad batch — full envelope reject, sudden rejection spike — the moment they log in, without clicking through to /batches.

Architecture: Extend the existing GET /api/batches list response with billing-outcome fields (one batched SQL aggregate per request, no N+1). Extend the frontend BatchSummary TS type with the new fields (all optional — backwards compatible). Build a new RecentBatchesWidget component that mirrors the existing Dashboard "Recent activity" Card chrome. Wire it into the Dashboard between the KPI tile row and the Activity row. Use the existing useBatches(limit) hook (queryKey ["batches", 5]). Click → navigate to /batches?batch=ID opening the existing BatchDrawer.

Tech Stack: Python 3.11+, FastAPI, SQLAlchemy 2.x, SQLite (encrypted via SQLCipher), React 18 + TypeScript + Vite, TanStack Query, lucide-react.


File map

File Change
backend/src/cyclone/api.py Add _batch_summary_billing_outcomes helper. Extend list_batches handler to attach the new fields per item.
backend/tests/test_api_gets.py +2 tests (test_batches_includes_billing_outcome, test_batches_835_kind_returns_zero_billed_and_no_rejection).
src/lib/api.ts +6 optional fields on BatchSummary.
src/components/RecentBatchesWidget.tsx NEW component.
src/pages/Dashboard.tsx Insert widget between KPI tiles and Activity row.
src/components/RecentBatchesWidget.test.tsx NEW, 3 tests.
src/pages/Dashboard.test.tsx +1 test (widget renders + click navigates).

Step 1: Backend — extend list_batches with billing-outcome fields

  • Open backend/src/cyclone/api.py and read list_batches at lines 1769-1808 plus the existing helpers at 1740-1764. Note the pattern.
  • Add a new helper _batch_summary_billing_outcomes(records) near the existing helpers (after line 1764). It:
    1. Opens a single db.SessionLocal()() as s session.
    2. Collects batch ids from records via [r.id for r in records]. Returns {} if empty.
    3. Runs one GROUP BY query:
      from sqlalchemy import func
      from cyclone.db import Claim, ClaimState
      rows = (
          s.query(Claim.batch_id, Claim.state, func.count(Claim.id), func.sum(Claim.charge_amount))
          .filter(Claim.batch_id.in_(batch_ids))
          .group_by(Claim.batch_id, Claim.state)
          .all()
      )
      
    4. Buckets into a {batch_id: {"accepted": int, "rejected": int, "pending": int, "billed": float}} dict. Define the state buckets:
      • accepted = PAID, RECEIVED, RECONCILED, PARTIAL
      • rejected = REJECTED, DENIED, REVERSED
      • pending = SUBMITTED, DRAFT (everything else is "other" — not surfaced on the widget)
    5. Runs the rejection-reason probe (only when there's at least one rejected claim across all batches — skip the query otherwise):
      rejected_states = (ClaimState.REJECTED, ClaimState.DENIED, ClaimState.REVERSED)
      rej = (
          s.query(Claim.batch_id, Claim.rejection_reason, Claim.payer_rejected_status_code)
          .filter(Claim.batch_id.in_(batch_ids),
                  ((Claim.state.in_(rejected_states)) | (Claim.payer_rejected_status_code.in_(("A4","A6","A7")))))
          .order_by(Claim.rejected_at.desc().nullslast())
          .all()
      )
      
      Bucket into {batch_id: {"top_rejection_reason": str|null, "has_payer_reject": bool}}.
    6. Truncate top_rejection_reason to 60 chars with suffix.
    7. Returns {batch_id: {accepted, rejected, pending, billed, top_rejection_reason, has_problem}} where has_problem = rejected > 0 or has_payer_reject.
  • Update list_batches handler (line 1783-1793) to:
    1. Call _batch_summary_billing_outcomes(records) after records = store.list(limit=limit).
    2. For each item in the constructed items list, look up outcomes = outcomes_by_id.get(r.id, {}) and add: acceptedCount, rejectedCount, pendingCount, billedTotal, topRejectionReason, hasProblem (defaulting sensibly when missing — e.g. acceptedCount=0, hasProblem=False).
  • Confirm the existing test_batches_* tests in test_api_gets.py still pass (the new fields are added, none removed — existing assertions on kind, inputFilename, claimIds are untouched).

Step 2: Backend tests

  • Open backend/tests/test_api_gets.py. Add the 2 tests after the existing 4 test_batches_* tests (after line 101).
  • test_batches_includes_billing_outcome — seed one batch via direct ORM insert (use the _add_batch / _add_claim helper pattern from test_dashboard_kpis.py:36-91 — these helpers don't exist in test_api_gets.py so inline them):
    from datetime import datetime, timezone
    from decimal import Decimal
    from cyclone.db import Batch, Claim, ClaimState, db as db_module
    
    with db_module.SessionLocal()() as s:
        s.add(Batch(id="b-out", kind="837p", input_filename="out.edi",
                     parsed_at=datetime(2026, 7, 1, 12, 0, tzinfo=timezone.utc),
                     totals_json={"total_claims": 3},
                     validation_json={"passed": True, "warnings": [], "errors": []},
                     raw_result_json={"_": "stub"}))
        s.add(Claim(id="C-paid", batch_id="b-out", patient_control_number="PCN-paid",
                    charge_amount=Decimal("100.00"), state=ClaimState.PAID,
                    raw_json={"subscriber": {"first_name":"Jane","last_name":"Doe"},
                              "payer": {"name":"CO_TXIX"}, "billing_provider": {"npi":"1234567893"},
                              "service_lines": []}))
        s.add(Claim(id="C-rej", batch_id="b-out", patient_control_number="PCN-rej",
                    charge_amount=Decimal("50.00"), state=ClaimState.REJECTED,
                    rejection_reason="999 AK5 R",
                    raw_json={"subscriber": {"first_name":"Jane","last_name":"Doe"},
                              "payer": {"name":"CO_TXIX"}, "billing_provider": {"npi":"1234567893"},
                              "service_lines": []}))
        s.add(Claim(id="C-sub", batch_id="b-out", patient_control_number="PCN-sub",
                    charge_amount=Decimal("75.00"), state=ClaimState.SUBMITTED,
                    raw_json={"subscriber": {"first_name":"Jane","last_name":"Doe"},
                              "payer": {"name":"CO_TXIX"}, "billing_provider": {"npi":"1234567893"},
                              "service_lines": []}))
        s.commit()
    resp = client.get("/api/batches")
    item = resp.json()["items"][0]
    assert item["acceptedCount"] == 1
    assert item["rejectedCount"] == 1
    assert item["pendingCount"] == 1
    assert item["billedTotal"] == 225.0
    assert item["topRejectionReason"] == "999 AK5 R"
    assert item["hasProblem"] is True
    
  • test_batches_835_kind_returns_zero_billed_and_no_rejection — seed one 835 via /api/parse-835 (uses existing fixture pattern in test_batches_claim_ids_empty_for_835 at lines 85-101), assert the new fields are zeroed out: acceptedCount=0, rejectedCount=0, pendingCount=0, billedTotal=0, topRejectionReason=None, hasProblem=False.
  • Run: cd backend && .venv/bin/pytest tests/test_api_gets.py -v. Both new tests pass; existing 4 still pass.

Step 3: Frontend types — extend BatchSummary

  • Open src/lib/api.ts line 245-259. Add 6 optional fields to the BatchSummary interface:
    /** Number of claims on the batch whose state is paid / received / partial / reconciled. */
    acceptedCount?: number;
    /** Number of claims whose state is rejected / denied / reversed. */
    rejectedCount?: number;
    /** Number of claims whose state is submitted / draft. */
    pendingCount?: number;
    /** Sum of charge_amount across all claims on the batch (837P only; 0 for 835). */
    billedTotal?: number;
    /** Most recent rejection_reason on the batch (truncated to 60 chars). Null when no rejections. */
    topRejectionReason?: string | null;
    /** True when the batch has rejections OR any 277CA A4/A6/A7 payer-reject claim. */
    hasProblem?: boolean;
    

Step 4: Frontend component — RecentBatchesWidget

  • Create src/components/RecentBatchesWidget.tsx. Build per the spec's row markup (Card chrome mirroring Dashboard.tsx:252-287).
  • Add data-testid="recent-batches-widget" on the outer <Card> for Playwright + the Dashboard integration test.
  • Import Layers, CheckCircle2, AlertTriangle from lucide-react.
  • Empty state: "No batches yet." centered with text-[13px] text-muted-foreground px-2 py-6.
  • Row markup per spec — role="button" tabIndex={0} + onClick + onKeyDown for Enter key.

Step 5: Frontend wire-up — Dashboard

  • Open src/pages/Dashboard.tsx. Add imports:
    import { useBatches } from "@/hooks/useBatches";
    import { RecentBatchesWidget } from "@/components/RecentBatchesWidget";
    
  • Inside the Dashboard component (after const activity = activityQuery.data?.items ?? []; on line 81), add:
    const RECENT_BATCHES_LIMIT = 5;
    const recentBatchesQuery = useBatches(RECENT_BATCHES_LIMIT);
    
  • Insert a new <section> BETWEEN line 245 (</section> for KPI tiles) and line 247 ({/* Activity + Top providers */}):
    <section
      className="animate-fade-in-up"
      style={{ animationDelay: `${sectionBase + 200}ms` }}
    >
      <RecentBatchesWidget
        batches={recentBatchesQuery.data ?? []}
        onRowClick={(id) => navigate(`/batches?batch=${encodeURIComponent(id)}`)}
      />
    </section>
    

Step 6: Frontend tests — component + Dashboard

  • Create src/components/RecentBatchesWidget.test.tsx with 3 tests (empty state, 2-batch render, 835 row). Mirror the patterns from src/components/ActivityFeed.test.tsx (if present) or src/pages/Batches.test.tsx.
  • Open src/pages/Dashboard.test.tsx. Read its existing setup. Add SP30: Recent batches widget renders + click navigates to /batches?batch=ID:
    1. Mock api.listBatches to resolve with 2 batches.
    2. Render <Dashboard /> inside MemoryRouter + QueryClientProvider.
    3. Wait for the widget's testid.
    4. Click the first row's <li role="button">.
    5. Assert URL is /batches?batch=ID.
  • Run: npx vitest run src/components/RecentBatchesWidget.test.tsx src/pages/Dashboard.test.tsx — all green.

Step 7: Per-tree verification

  • cd backend && .venv/bin/pytest — full backend suite passes (no regressions).
  • npx vitest run — full frontend suite passes (no regressions).
  • npm run typecheck — 0 errors in SP30 files (existing baseline errors in Upload.tsx are acceptable).
  • npm run lint — 0 errors in SP30 files.

Step 8: Atomic merge + deploy

  • Commit: feat(sp30): Dashboard Recent batches widget with billing outcome.
  • git checkout main && git merge --no-ff sp30-recent-batches-widget -m "merge: SP30 Dashboard Recent batches widget into main".
  • docker compose build backend frontend — rebuild both images (backend has the new endpoint fields; frontend has the new widget).
  • docker compose up -d backend frontend — restart.
  • Playwright smoke test:
    1. Login
    2. Navigate to /
    3. Wait for [data-testid="recent-batches-widget"]
    4. Screenshot /tmp/dashboard-sp30.png
    5. Click top row → assert URL changes
    6. Screenshot /tmp/dashboard-sp30-clicked.png
  • Show user the screenshots + the curl response from /api/batches confirming the new fields.