diff --git a/docs/superpowers/plans/2026-06-19-cyclone-production-readiness.md b/docs/superpowers/plans/2026-06-19-cyclone-production-readiness.md new file mode 100644 index 0000000..2f8411f --- /dev/null +++ b/docs/superpowers/plans/2026-06-19-cyclone-production-readiness.md @@ -0,0 +1,4238 @@ +# Cyclone Production-Readiness (local-only) 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 backend in-memory batch store + 6 GET endpoints (with filter/sort/pagination and NDJSON streaming) to the Cyclone EDI suite, wire the existing 4 React pages + Upload page to live data via `@tanstack/react-query` v5, ship 4 fresh reference notes + a root README rewrite, and lock the local-only deploy posture (`127.0.0.1:8000`, no auth, no internet exposure). All on a single branch, with frequent commits, fully testable at every checkpoint. + +**Architecture:** Threaded `InMemoryStore` (process-local, list of `BatchRecord` keyed by uuid4) → 6 read-only FastAPI routes + an `application/x-ndjson` content-negotiated streaming variant on each list endpoint. Frontend: `QueryClientProvider` at the root, 6 hooks under `src/hooks/`, 4 page refactors, 5 new UI primitives (`Skeleton`, `EmptyState`, `ErrorState`, `FilterChips`, `Pagination`), a Layout-level refetch indicator, and a newly-streamed row flash on the table. Docs: 4 short notes under `docs/reference/`, root README rewritten. + +**Tech Stack:** Python 3.11+, FastAPI, Pydantic v2, `threading.RLock`; React 18 + Vite + TS + Tailwind, `@tanstack/react-query` v5, `lucide-react`, `sonner`, `zustand`, `clsx` + `tailwind-merge` (existing). No new Python deps; one new JS dep (`@tanstack/react-query`). + +**Spec:** `docs/superpowers/specs/2026-06-19-cyclone-production-readiness-design.md` (approved) + +**Worktree:** Use `superpowers:using-git-worktrees` to create `.worktrees/prod-readiness` from `main` before Task 1. + +--- + +## File map + +### New backend files +| File | Responsibility | +|---|---| +| `backend/src/cyclone/store.py` | `BatchRecord`, `InMemoryStore`, mappers (`to_ui_claim`, `to_ui_remittance`, `to_ui_provider`, `to_activity_event`) | +| `backend/tests/test_store.py` | ~8 tests: add/list/get, threading, mappers, filters, distinct providers, activity | +| `backend/tests/test_api_gets.py` | ~12 tests: happy path + filter for each of 6 GET endpoints + 404 for `batches/{id}` | +| `backend/tests/test_api_streaming.py` | 1 test: `Accept: application/x-ndjson` on `/api/claims` returns parseable lines | +| `backend/tests/test_api_parse_persists.py` | 2 tests: parse-837 persists + appears via `/api/claims`; failed parse does NOT create a batch | + +### Modified backend files +| File | Change | +|---|---| +| `backend/src/cyclone/api.py` | Add 6 GET routes; wire parse-837/parse-835 to `store.add()`; import new module | +| `backend/src/cyclone/__main__.py` | Bind uvicorn to `127.0.0.1:8000`; honor `CYCLONE_PORT` + `CYCLONE_RELOAD` env vars | + +### New frontend files +| File | Responsibility | +|---|---| +| `src/components/ui/skeleton.tsx` | `` with hairline + scanline-shimmer | +| `src/components/ui/empty-state.tsx` | `` — all-caps eyebrow, hairline-circle icon | +| `src/components/ui/error-state.tsx` | `` — destructive-hairline block + retry | +| `src/components/ui/filter-chips.tsx` | `` | +| `src/components/ui/pagination.tsx` | `` with `nav-active` echo | +| `src/hooks/useBatches.ts` | `useBatches()` | +| `src/hooks/useClaims.ts` | `useClaims(params)` — falls back to zustand when `!api.isConfigured` | +| `src/hooks/useRemittances.ts` | `useRemittances(params)` | +| `src/hooks/useProviders.ts` | `useProviders(params)` | +| `src/hooks/useActivity.ts` | `useActivity(params)` | +| `src/hooks/useParse.ts` | `useParse(kind)` — `useMutation` that invalidates 5 query keys on success | +| `src/lib/api.test.ts` | ~3 tests: listClaims URL, listBatches path, getBatch kind handling | + +### Modified frontend files +| File | Change | +|---|---| +| `package.json` | Add `@tanstack/react-query` ^5.59.0 | +| `tailwind.config.js` | Add 3 keyframes (`shimmer`, `scan`, `row-flash`) | +| `src/main.tsx` | Wrap `` in `` | +| `src/components/Layout.tsx` | Add `useIsFetching()` refetch indicator at top of `
` | +| `src/lib/api.ts` | Add `listBatches`, `getBatch`, `listClaims`, `listRemittances`, `listProviders`, `listActivity`; replace `legacyNotImplemented` for those with real fetches | +| `src/pages/Claims.tsx` | Use `useClaims`; add `Skeleton` (5 rows), `EmptyState` ("Claims · inbox idle"), `ErrorState`, `FilterChips`, `Pagination`, `data-newer-than` flash | +| `src/pages/Remittances.tsx` | Use `useRemittances`; same UX patterns | +| `src/pages/Providers.tsx` | Use `useProviders`; `Skeleton` variant=`card` | +| `src/pages/ActivityLog.tsx` | Use `useActivity`; `refetchInterval: 30_000`; `EmptyState` ("Activity · log idle") | +| `src/pages/Upload.tsx` | Replace inline `addParsedBatch` flow with `useParse(kind)` mutation | + +### New docs files +| File | Responsibility | +|---|---| +| `docs/reference/837p.md` | ~80 lines — 837P structure + CO Medicaid specifics | +| `docs/reference/835.md` | ~80 lines — 835 structure + balancing + CO Medicaid specifics | +| `docs/reference/x12naming.md` | ~50 lines — segments, elements, composites, loops, common qualifiers | +| `docs/reference/co-medicaid.md` | ~80 lines — TPID, payer IDs, allowed codes, POS codes | + +### Modified docs files +| File | Change | +|---|---| +| `README.md` (root) | Rewrite: what, install, dev, test, layout, roadmap, license note | + +--- + +## Phase 1 — Backend + +### Task 1: `cyclone.store` module — `BatchRecord` + empty `InMemoryStore` + +**Files:** +- Create: `backend/src/cyclone/store.py` +- Create: `backend/tests/test_store.py` + +- [ ] **Step 1: Write the failing test for `BatchRecord` construction + module import** + +`backend/tests/test_store.py`: +```python +"""Tests for the in-memory batch store.""" + +from __future__ import annotations + +import pytest + +from cyclone.store import BatchRecord, InMemoryStore, store +from cyclone.parsers.models import ParseResult, Envelope, ClaimOutput, ValidationReport + + +def _make_claim(claim_id: str = "CLM-1") -> ClaimOutput: + """Minimal ClaimOutput stub for store tests. Real parser not needed here.""" + return ClaimOutput( + claim_id=claim_id, + billing_provider={"npi": "1234567890", "name": "Test", "taxonomy": None, + "address": None, "city": None, "state": None, "zip": None, + "phone": None, "tax_id": None}, + subscriber={"member_id": "M1", "first_name": "Jane", "last_name": "Doe", + "dob": None, "gender": None, "address": None, "city": None, + "state": None, "zip": None}, + payer={"id": "P1", "name": "Test Payer", "type": None, "address": None, + "city": None, "state": None, "zip": None}, + diagnoses=[], + service_lines=[], + claim={"total_charge": 0.0, "place_of_service": "11", + "frequency_code": "1", "prior_auth": None, "provider_signature": None, + "assignment": None, "benefits_assigned": None, "release_of_info": None}, + validation=ValidationReport(passed=True, errors=[], warnings=[]), + raw_segments=[], + ) + + +def _make_result() -> ParseResult: + return ParseResult( + envelope=Envelope( + sender_id="SENDER", sender_qualifier="ZZ", + receiver_id="RECEIVER", receiver_qualifier="ZZ", + control_number="0001", usage_indicator="P", + transaction_set="837", transaction_date="2026-06-19", + transaction_time=None, + ), + billing_provider={"npi": "1234567890", "name": "Test", "taxonomy": None, + "address": None, "city": None, "state": None, "zip": None, + "phone": None, "tax_id": None}, + claims=[_make_claim("CLM-1")], + summary={"total_claims": 1, "passed": 1, "failed": 0, + "started_at": "2026-06-19T12:00:00Z", + "finished_at": "2026-06-19T12:00:01Z", + "duration_ms": 1000, "payer": "co_medicaid", "errors": 0}, + ) + + +def test_module_singleton_store_exists(): + """The module-level `store` should be importable and be an InMemoryStore.""" + assert isinstance(store, InMemoryStore) + + +def test_in_memory_store_starts_empty(): + s = InMemoryStore() + assert s.list() == [] + + +def test_add_and_get_round_trip(): + s = InMemoryStore() + result = _make_result() + rec = BatchRecord( + id="abc123", + kind="837p", + input_filename="test.txt", + parsed_at="2026-06-19T12:00:00Z", + result=result, + ) + s.add(rec) + assert s.get("abc123") is rec + assert s.list() == [rec] + + +def test_get_missing_returns_none(): + s = InMemoryStore() + assert s.get("does-not-exist") is None +``` + +> Note: if your `ClaimOutput` / `Envelope` / `ValidationReport` / `ParseResult` model fields differ, adjust the `_make_claim` / `_make_result` stubs to match the real field names. Run `python -c "from cyclone.parsers.models import ClaimOutput; help(ClaimOutput)"` to confirm. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd backend && .venv/bin/pytest tests/test_store.py -v` +Expected: `ModuleNotFoundError: No module named 'cyclone.store'` (or similar import error). + +- [ ] **Step 3: Implement `BatchRecord` + empty `InMemoryStore` (no mappers yet)** + +`backend/src/cyclone/store.py`: +```python +"""In-memory batch store for parsed X12 files. + +Lives at module scope so route handlers and tests can import a single +singleton. Single-process only — no cross-process coordination. Threading: +a re-entrant lock guards every public method so FastAPI's threadpool +request handlers can call them concurrently. +""" + +from __future__ import annotations + +import threading +from datetime import datetime, timezone +from typing import Iterable, Literal + +from pydantic import BaseModel, ConfigDict, Field + +from cyclone.parsers.models import ParseResult +from cyclone.parsers.models_835 import ParseResult835 + + +BatchKind = Literal["837p", "835"] + + +class BatchRecord(BaseModel): + """One parsed file, with a stable uuid4 id and the full ParseResult.""" + + model_config = ConfigDict(extra="ignore") + + id: str + kind: BatchKind + input_filename: str + parsed_at: str # ISO 8601 UTC, e.g. "2026-06-19T12:00:00Z" + result: ParseResult | ParseResult835 = Field(discriminator=None) + + +class InMemoryStore: + """Thread-safe list of BatchRecord, newest-first on `list`.""" + + def __init__(self) -> None: + self._batches: list[BatchRecord] = [] + self._lock = threading.RLock() + + def add(self, record: BatchRecord) -> None: + with self._lock: + self._batches.append(record) + + def list(self, *, limit: int = 100) -> list[BatchRecord]: + with self._lock: + # Newest first. + return list(reversed(self._batches[-limit:])) + + def get(self, batch_id: str) -> BatchRecord | None: + with self._lock: + for b in self._batches: + if b.id == batch_id: + return b + return None + + def all(self) -> list[BatchRecord]: + with self._lock: + return list(self._batches) + + +store = InMemoryStore() + + +def utcnow_iso() -> str: + """ISO 8601 UTC timestamp, second precision, suffixed with 'Z'.""" + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") +``` + +> Note: the imports `from cyclone.parsers.models import ParseResult` and `from cyclone.parsers.models_835 import ParseResult835` are correct based on the existing project layout. If the Pydantic v2 union complains about the discriminator, drop the `discriminator=None` and let Pydantic use the default behavior — the actual class is determined at runtime by the field type. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd backend && .venv/bin/pytest tests/test_store.py -v` +Expected: all 4 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/cyclone/store.py backend/tests/test_store.py +git commit -m "feat(backend): add cyclone.store with BatchRecord and InMemoryStore" +``` + +--- + +### Task 2: Store mappers — `to_ui_claim`, `to_ui_remittance`, `to_ui_provider`, `to_activity_event` + +**Files:** +- Modify: `backend/src/cyclone/store.py` +- Modify: `backend/tests/test_store.py` + +- [ ] **Step 1: Add failing tests for the mappers** + +Append to `backend/tests/test_store.py`: +```python +def test_to_ui_claim_maps_status_submitted(): + from cyclone.store import to_ui_claim + claim = _make_claim("CLM-1") + out = to_ui_claim(claim, batch_id="b1", parsed_at="2026-06-19T12:00:00Z") + assert out["id"] == "CLM-1" + assert out["status"] == "submitted" # passed=True, frequency_code="1" + assert out["providerNpi"] == "1234567890" + assert out["batchId"] == "b1" + + +def test_to_ui_claim_maps_status_pending_when_warnings(): + from cyclone.store import to_ui_claim + claim = _make_claim("CLM-2") + claim.validation = ValidationReport( + passed=True, errors=[], + warnings=[{"rule": "W001", "message": "soft"}], + ) + out = to_ui_claim(claim, batch_id="b1", parsed_at="2026-06-19T12:00:00Z") + assert out["status"] == "pending" + + +def test_to_ui_claim_maps_status_denied_when_failed(): + from cyclone.store import to_ui_claim + claim = _make_claim("CLM-3") + claim.validation = ValidationReport( + passed=False, + errors=[{"rule": "E001", "message": "hard"}], + warnings=[], + ) + out = to_ui_claim(claim, batch_id="b1", parsed_at="2026-06-19T12:00:00Z") + assert out["status"] == "denied" + + +def test_to_ui_remittance_maps_status_received_for_paid(): + from cyclone.parsers.models_835 import ( + ClaimPayment, ClaimFilingIndicator, ParseResult835, + Payer835, Payee835, Envelope as Envelope835, FinancialInfo, + ReassociationTrace, + ) + from cyclone.store import to_ui_remittance + cp = ClaimPayment( + payer_claim_control_number="PCN-1", + status_code="1", + status_label="Processed as Primary", + total_charge=100.0, total_paid=80.0, total_adjustment=20.0, + total_patient_responsibility=0.0, + claim_filing_indicator=ClaimFilingIndicator.MC, + original_claim_id=None, frequency_code=None, facility_type=None, + service_payments=[], + adjustments=[], + ) + out = to_ui_remittance(cp, batch_id="b1", parsed_at="2026-06-19T12:00:00Z") + assert out["status"] == "received" + + +def test_to_ui_provider_aggregates_claims(): + from cyclone.store import to_ui_provider + out = to_ui_provider( + npi="1234567890", name="Acme Clinic", tax_id="99-9999999", + address="1 Main", city="Denver", state="CO", zip="80202", phone="303-555-0100", + claim_count=5, outstanding_ar=250.0, + ) + assert out["npi"] == "1234567890" + assert out["claimCount"] == 5 + + +def test_to_activity_event_uses_iso_timestamp(): + from cyclone.store import to_activity_event + out = to_activity_event( + id="a1", kind="claim_submitted", + message="Claim CLM-1 submitted", + timestamp="2026-06-19T12:00:00Z", + npi="1234567890", amount=100.0, + ) + assert out["id"] == "a1" + assert out["kind"] == "claim_submitted" + assert out["amount"] == 100.0 +``` + +> Adjust stub values to match your actual Pydantic model field names. The important assertions are the `status` mapping rules (submitted/pending/denied) and that the mapper output is a dict with the right shape. + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cd backend && .venv/bin/pytest tests/test_store.py -v` +Expected: `ImportError: cannot import name 'to_ui_claim' from 'cyclone.store'`. + +- [ ] **Step 3: Implement the mappers** + +Add to `backend/src/cyclone/store.py` (after `utcnow_iso`): +```python +# --------------------------------------------------------------------------- +# Mappers: rich backend models → simpler UI types. +# The shape of the returned dicts must match the TypeScript interfaces in +# `src/types/index.ts`. Keep both sides in sync. +# --------------------------------------------------------------------------- + + +def to_ui_claim( + claim: ClaimOutput, + *, + batch_id: str, + parsed_at: str, +) -> dict: + """Map a 837P ClaimOutput to the UI's `Claim` shape. + + Status rules (per spec section 6.2): + - passed and frequency_code == "1" → submitted + - passed and any warnings → pending + - !passed → denied + - otherwise → draft + """ + v = claim.validation + if v.passed and claim.claim.frequency_code == "1": + status = "submitted" + elif v.passed and v.warnings: + status = "pending" + elif not v.passed: + status = "denied" + else: + status = "draft" + + return { + "id": claim.claim_id, + "patientName": f"{claim.subscriber.first_name} {claim.subscriber.last_name}".strip(), + "providerNpi": claim.billing_provider.npi, + "payerName": claim.payer.name, + "cptCode": ( + claim.service_lines[0].procedure.code + if claim.service_lines + else "" + ), + "billedAmount": float(claim.claim.total_charge or 0.0), + "receivedAmount": 0.0, # populated by 835 reconciliation (sub-project 2) + "status": status, + "denialReason": None, + "submissionDate": parsed_at, # batch-level timestamp; close enough + "batchId": batch_id, + "parsedAt": parsed_at, + } + + +def to_ui_remittance( + cp: ClaimPayment, # type: ignore[name-defined] # noqa: F821 + *, + batch_id: str, + parsed_at: str, +) -> dict: + """Map an 835 ClaimPayment to the UI's `Remittance` shape. + + Status rules (per spec section 6.2): + - CLP02 in {1, 2, 19, 20} → received (paid, primary/secondary) + - CLP02 == 4 → received (denied; denial reason from CAS) + - CLP02 in {21, 22} → reconciled (reversal of prior payment) + - any other valid code → received + """ + code = cp.status_code + if code in {"21", "22"}: + status = "reconciled" + else: + status = "received" # paid, denied, voided, etc. — all "received" until + # 837P↔835 reconciliation sets a more specific value (sub-project 2) + + return { + "id": cp.payer_claim_control_number, + "claimId": cp.original_claim_id or "", + "payerName": "", # filled by caller with batch-level payer name + "paidAmount": float(cp.total_paid or 0.0), + "adjustmentAmount": float(cp.total_adjustment or 0.0), + "status": status, + "denialReason": None, + "receivedDate": parsed_at, + "batchId": batch_id, + "parsedAt": parsed_at, + } + + +def to_ui_provider( + *, + npi: str, + name: str, + tax_id: str | None = None, + address: str | None = None, + city: str | None = None, + state: str | None = None, + zip: str | None = None, + phone: str | None = None, + claim_count: int = 0, + outstanding_ar: float = 0.0, +) -> dict: + return { + "npi": npi, + "name": name, + "taxId": tax_id or "", + "address": address or "", + "city": city or "", + "state": state or "", + "zip": zip or "", + "phone": phone or "", + "claimCount": claim_count, + "outstandingAr": float(outstanding_ar), + } + + +def to_activity_event( + *, + id: str, + kind: str, + message: str, + timestamp: str, + npi: str | None = None, + amount: float | None = None, +) -> dict: + return { + "id": id, + "kind": kind, + "message": message, + "timestamp": timestamp, + "npi": npi, + "amount": amount, + } +``` + +> You'll need to add the missing imports at the top of `store.py`: +> ```python +> from cyclone.parsers.models import ClaimOutput, ParseResult +> ``` +> (already imported in Task 1; the `ClaimPayment` import for `to_ui_remittance` should be added: +> ```python +> from cyclone.parsers.models_835 import ClaimPayment, ParseResult835 +> ```) + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cd backend && .venv/bin/pytest tests/test_store.py -v` +Expected: all 10 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/cyclone/store.py backend/tests/test_store.py +git commit -m "feat(backend): add store mappers (claim, remittance, provider, activity)" +``` + +--- + +### Task 3: Store iterators — `iter_claims`, `iter_remittances`, `distinct_providers`, `recent_activity` + +**Files:** +- Modify: `backend/src/cyclone/store.py` +- Modify: `backend/tests/test_store.py` + +- [ ] **Step 1: Add failing tests for the iterators** + +Append to `backend/tests/test_store.py`: +```python +def test_iter_claims_filters_by_status(): + from cyclone.store import to_ui_claim + s = InMemoryStore() + a = _make_claim("A") + b = _make_claim("B") + b.validation = ValidationReport(passed=True, errors=[], + warnings=[{"rule": "W1", "message": "x"}]) + s.add(BatchRecord(id="1", kind="837p", input_filename="a.txt", + parsed_at="2026-06-19T12:00:00Z", result=_make_result())) + out = list(s.iter_claims(status="submitted")) + assert all(c["status"] == "submitted" for c in out) + # B's status is "pending" (passed=True, has warnings), so it should be excluded + + +def test_iter_claims_filters_by_batch_id(): + from cyclone.store import to_ui_claim + s = InMemoryStore() + s.add(BatchRecord(id="b1", kind="837p", input_filename="a.txt", + parsed_at="2026-06-19T12:00:00Z", result=_make_result())) + out = list(s.iter_claims(batch_id="b1")) + assert all(c["batchId"] == "b1" for c in out) + + +def test_iter_claims_sorts_by_billed_amount_desc(): + s = InMemoryStore() + result = _make_result() + result.claims = [ + _make_claim("cheap"), + _make_claim("expensive"), + ] + result.claims[0].claim.total_charge = 50.0 + result.claims[1].claim.total_charge = 500.0 + s.add(BatchRecord(id="b1", kind="837p", input_filename="a.txt", + parsed_at="2026-06-19T12:00:00Z", result=result)) + out = list(s.iter_claims(sort="billedAmount", order="desc")) + assert out[0]["id"] == "expensive" + assert out[1]["id"] == "cheap" + + +def test_distinct_providers_aggregates_claims(): + s = InMemoryStore() + result = _make_result() + result.claims = [_make_claim("A"), _make_claim("B")] + s.add(BatchRecord(id="b1", kind="837p", input_filename="a.txt", + parsed_at="2026-06-19T12:00:00Z", result=result)) + out = s.distinct_providers() + assert len(out) == 1 + assert out[0]["npi"] == "1234567890" + assert out[0]["claimCount"] == 2 + + +def test_recent_activity_returns_newest_first(): + s = InMemoryStore() + s.add(BatchRecord(id="b1", kind="837p", input_filename="a.txt", + parsed_at="2026-06-19T11:00:00Z", result=_make_result())) + s.add(BatchRecord(id="b2", kind="837p", input_filename="b.txt", + parsed_at="2026-06-19T12:00:00Z", result=_make_result())) + out = s.recent_activity(limit=10) + assert out[0]["timestamp"] == "2026-06-19T12:00:00Z" + assert out[1]["timestamp"] == "2026-06-19T11:00:00Z" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd backend && .venv/bin/pytest tests/test_store.py -v -k "iter_ or distinct_ or recent_"` +Expected: `AttributeError: 'InMemoryStore' object has no attribute 'iter_claims'`. + +- [ ] **Step 3: Implement the iterators** + +Add to `backend/src/cyclone/store.py` (as methods on `InMemoryStore`): +```python + def iter_claims( + self, + *, + batch_id: str | None = None, + status: str | None = None, + provider_npi: str | None = None, + date_from: str | None = None, + date_to: str | None = None, + sort: str | None = None, + order: str = "desc", + limit: int = 100, + offset: int = 0, + ) -> Iterable[dict]: + with self._lock: + all_claims: list[dict] = [] + for b in self._batches: + if b.kind != "837p": + continue + if batch_id is not None and b.id != batch_id: + continue + for c in b.result.claims: # type: ignore[attr-defined] + all_claims.append(to_ui_claim( + c, batch_id=b.id, parsed_at=b.parsed_at, + )) + if status is not None: + all_claims = [c for c in all_claims if c["status"] == status] + if provider_npi is not None: + all_claims = [c for c in all_claims if c["providerNpi"] == provider_npi] + if sort is not None: + all_claims.sort( + key=lambda c: c.get(sort, 0) or 0, + reverse=(order == "desc"), + ) + yield from all_claims[offset:offset + limit] + + def iter_remittances( + self, + *, + batch_id: str | None = None, + payer: str | None = None, + claim_id: str | None = None, + date_from: str | None = None, + date_to: str | None = None, + sort: str | None = None, + order: str = "desc", + limit: int = 100, + offset: int = 0, + ) -> Iterable[dict]: + with self._lock: + all_remits: list[dict] = [] + for b in self._batches: + if b.kind != "835": + continue + if batch_id is not None and b.id != batch_id: + continue + for cp in b.result.claim_payments: # type: ignore[attr-defined] + all_remits.append(to_ui_remittance( + cp, batch_id=b.id, parsed_at=b.parsed_at, + )) + if payer is not None: + all_remits = [r for r in all_remits if r.get("payerName") == payer] + if claim_id is not None: + all_remits = [r for r in all_remits if r["claimId"] == claim_id] + if sort is not None: + all_remits.sort( + key=lambda r: r.get(sort, 0) or 0, + reverse=(order == "desc"), + ) + yield from all_remits[offset:offset + limit] + + def distinct_providers(self) -> list[dict]: + with self._lock: + by_npi: dict[str, dict] = {} + for b in self._batches: + if b.kind != "837p": + continue + bp = b.result.billing_provider # type: ignore[attr-defined] + npi = bp.npi + if npi not in by_npi: + by_npi[npi] = to_ui_provider( + npi=npi, + name=bp.name or "", + tax_id=bp.tax_id, + address=bp.address, + city=bp.city, + state=bp.state, + zip=bp.zip, + phone=bp.phone, + claim_count=0, + outstanding_ar=0.0, + ) + by_npi[npi]["claimCount"] += len(b.result.claims) # type: ignore[attr-defined] + return list(by_npi.values()) + + def recent_activity(self, *, limit: int = 200) -> list[dict]: + with self._lock: + events: list[dict] = [] + for b in reversed(self._batches): + if b.kind == "837p": + for c in b.result.claims: # type: ignore[attr-defined] + events.append(to_activity_event( + id=f"{b.id}:{c.claim_id}", + kind="claim_submitted", + message=f"Claim {c.claim_id} submitted · {c.payer.name}", + timestamp=b.parsed_at, + npi=c.billing_provider.npi, + amount=float(c.claim.total_charge or 0.0), + )) + elif b.kind == "835": + for cp in b.result.claim_payments: # type: ignore[attr-defined] + events.append(to_activity_event( + id=f"{b.id}:{cp.payer_claim_control_number}", + kind="remit_received", + message=f"Remit {cp.payer_claim_control_number} received", + timestamp=b.parsed_at, + amount=float(cp.total_paid or 0.0), + )) + return events[:limit] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd backend && .venv/bin/pytest tests/test_store.py -v` +Expected: all tests pass (15 total now). + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/cyclone/store.py backend/tests/test_store.py +git commit -m "feat(backend): add store iterators (claims, remits, providers, activity)" +``` + +--- + +### Task 4: Wire `parse-837` and `parse-835` to call `store.add()` on success + +**Files:** +- Modify: `backend/src/cyclone/api.py` +- Create: `backend/tests/test_api_parse_persists.py` + +- [ ] **Step 1: Add failing tests** + +`backend/tests/test_api_parse_persists.py`: +```python +"""Successful parses must persist to the store; failed parses must not.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from cyclone.api import app +from cyclone.store import store as global_store + +FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt" + + +@pytest.fixture(autouse=True) +def clear_store(): + """Reset the module-level store before and after each test.""" + with global_store._lock: + global_store._batches.clear() + yield + with global_store._lock: + global_store._batches.clear() + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +def test_successful_parse_837_creates_batch(client: TestClient): + assert len(global_store.list()) == 0 + text = FIXTURE.read_text() + resp = client.post( + "/api/parse-837", + files={"file": ("test.txt", text, "text/plain")}, + headers={"Accept": "application/json"}, + ) + assert resp.status_code == 200, resp.text + assert len(global_store.list()) == 1 + rec = global_store.list()[0] + assert rec.kind == "837p" + assert rec.input_filename == "test.txt" + + +def test_failed_parse_837_does_not_create_batch(client: TestClient): + """An empty / garbage file should NOT create a batch.""" + assert len(global_store.list()) == 0 + resp = client.post( + "/api/parse-837", + files={"file": ("garbage.txt", "not-edi", "text/plain")}, + headers={"Accept": "application/json"}, + ) + # Either a 4xx from the parser, or a 200 with 0 claims — the contract + # is that NO batch is added either way. + assert resp.status_code in (200, 400, 422) + assert len(global_store.list()) == 0 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd backend && .venv/bin/pytest tests/test_api_parse_persists.py -v` +Expected: first test fails because `len(global_store.list()) == 1` is False (no batches added yet). + +- [ ] **Step 3: Modify `cyclone.api` to call `store.add()` on success** + +In `backend/src/cyclone/api.py`, add the import near the top: +```python +from cyclone.store import BatchRecord, store, utcnow_iso +``` + +Then find the existing `POST /api/parse-837` route handler (the one that returns a `ParseResult`) and add the `store.add()` call right before the return statement. The structure should be: + +```python +@app.post("/api/parse-837") +async def parse_837_endpoint( + file: UploadFile = File(...), + payer: str = Query("co_medicaid"), + strict: bool = Query(False), + include_raw_segments: bool = Query(True), + request: Request = None, +): + # ... existing logic that produces `result: ParseResult` ... + # Right before returning the JSONResponse, add: + if result is not None: + rec = BatchRecord( + id=uuid.uuid4().hex, + kind="837p", + input_filename=file.filename or "upload.txt", + parsed_at=utcnow_iso(), + result=result, + ) + store.add(rec) + # ... return as before +``` + +Repeat for `parse_835_endpoint`, with `kind="835"` and the result cast to `ParseResult835`. + +Add the `uuid` import at the top of `api.py`: +```python +import uuid +``` + +> The exact location depends on your existing parse-route structure. The key invariant is: `store.add(record)` is called only after the parser successfully produces a result, and *before* the response is returned. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd backend && .venv/bin/pytest tests/test_api_parse_persists.py -v` +Expected: both tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/cyclone/api.py backend/tests/test_api_parse_persists.py +git commit -m "feat(backend): wire parse-837/parse-835 to persist to InMemoryStore" +``` + +--- + +### Task 5: `GET /api/batches` and `GET /api/batches/{id}` routes + +**Files:** +- Modify: `backend/src/cyclone/api.py` +- Modify: `backend/tests/test_api_gets.py` (new) + +- [ ] **Step 1: Add failing tests for the batch endpoints** + +`backend/tests/test_api_gets.py`: +```python +"""Tests for the 6 new GET endpoints.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from cyclone.api import app +from cyclone.store import store as global_store + +FIXTURE_837 = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt" + + +@pytest.fixture(autouse=True) +def clear_store(): + with global_store._lock: + global_store._batches.clear() + yield + with global_store._lock: + global_store._batches.clear() + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +@pytest.fixture +def seeded_store(): + """One parsed 837P fixture batch.""" + text = FIXTURE_837.read_text() + client = TestClient(app) + client.post( + "/api/parse-837", + files={"file": ("x.txt", text, "text/plain")}, + headers={"Accept": "application/json"}, + ) + return client + + +# --------------------------------------------------------------------------- # +# /api/batches +# --------------------------------------------------------------------------- # + + +def test_batches_returns_empty_list_when_no_parses(client: TestClient): + resp = client.get("/api/batches") + assert resp.status_code == 200 + body = resp.json() + assert body == {"items": [], "total": 0, "returned": 0, "has_more": False} + + +def test_batches_returns_summary_after_parse(seeded_store): + resp = seeded_store.get("/api/batches") + assert resp.status_code == 200 + body = resp.json() + assert body["total"] == 1 + assert body["returned"] == 1 + assert body["items"][0]["kind"] == "837p" + assert body["items"][0]["inputFilename"] == "x.txt" + + +# --------------------------------------------------------------------------- # +# /api/batches/{id} +# --------------------------------------------------------------------------- # + + +def test_get_batch_by_id_returns_full_result(seeded_store): + batches = seeded_store.get("/api/batches").json()["items"] + bid = batches[0]["id"] + resp = seeded_store.get(f"/api/batches/{bid}") + assert resp.status_code == 200 + body = resp.json() + assert "envelope" in body + assert "claims" in body + assert len(body["claims"]) == 2 + + +def test_get_batch_404_when_missing(client: TestClient): + resp = client.get("/api/batches/does-not-exist") + assert resp.status_code == 404 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd backend && .venv/bin/pytest tests/test_api_gets.py -v` +Expected: all 4 tests fail with 404 (routes don't exist yet). + +- [ ] **Step 3: Implement the routes** + +Add to `backend/src/cyclone/api.py`: +```python +from datetime import datetime + + +@app.get("/api/batches") +def list_batches(limit: int = Query(100, ge=1, le=1000)): + """Summary of all parsed batches, newest first.""" + records = store.list(limit=limit) + items = [ + { + "id": r.id, + "kind": r.kind, + "inputFilename": r.input_filename, + "parsedAt": r.parsed_at, + "claimCount": ( + len(r.result.claims) # type: ignore[attr-defined] + if r.kind == "837p" + else len(r.result.claim_payments) # type: ignore[attr-defined] + ), + } + for r in records + ] + return { + "items": items, + "total": len(store.all()), + "returned": len(items), + "has_more": len(store.all()) > len(items), + } + + +@app.get("/api/batches/{batch_id}") +def get_batch(batch_id: str): + rec = store.get(batch_id) + if rec is None: + raise HTTPException(status_code=404, detail=f"Batch {batch_id} not found") + return rec.result +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd backend && .venv/bin/pytest tests/test_api_gets.py -v -k "batches"` +Expected: 4 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/cyclone/api.py backend/tests/test_api_gets.py +git commit -m "feat(backend): add GET /api/batches and GET /api/batches/{id}" +``` + +--- + +### Task 6: `GET /api/claims` with filter / sort / pagination + +**Files:** +- Modify: `backend/src/cyclone/api.py` +- Modify: `backend/tests/test_api_gets.py` + +- [ ] **Step 1: Add failing tests** + +Append to `backend/tests/test_api_gets.py`: +```python +# --------------------------------------------------------------------------- # +# /api/claims +# --------------------------------------------------------------------------- # + + +def test_claims_returns_empty_list_when_no_parses(client: TestClient): + resp = client.get("/api/claims") + assert resp.status_code == 200 + body = resp.json() + assert body["items"] == [] + assert body["total"] == 0 + + +def test_claims_filters_by_status_submitted(seeded_store): + resp = seeded_store.get("/api/claims?status=submitted") + assert resp.status_code == 200 + body = resp.json() + assert all(c["status"] == "submitted" for c in body["items"]) + assert body["total"] >= 1 + + +def test_claims_filters_by_batch_id(seeded_store): + bid = seeded_store.get("/api/batches").json()["items"][0]["id"] + resp = seeded_store.get(f"/api/claims?batch_id={bid}") + assert resp.status_code == 200 + body = resp.json() + assert all(c["batchId"] == bid for c in body["items"]) + + +def test_claims_sorts_by_billed_amount_desc(seeded_store): + resp = seeded_store.get("/api/claims?sort=billedAmount&order=desc") + assert resp.status_code == 200 + body = resp.json() + amounts = [c["billedAmount"] for c in body["items"]] + assert amounts == sorted(amounts, reverse=True) + + +def test_claims_pagination_limit_offset(seeded_store): + resp1 = seeded_store.get("/api/claims?limit=1&offset=0") + body1 = resp1.json() + assert body1["returned"] == 1 + assert body1["has_more"] is True +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd backend && .venv/bin/pytest tests/test_api_gets.py -v -k "claims_"` +Expected: 404 / Not Found for `/api/claims`. + +- [ ] **Step 3: Implement the route** + +Add to `backend/src/cyclone/api.py`: +```python +@app.get("/api/claims") +def list_claims( + batch_id: str | None = Query(None), + status: str | None = Query(None), + provider_npi: str | None = Query(None, alias="provider_npi"), + payer: str | None = Query(None), + date_from: str | None = Query(None), + date_to: str | None = Query(None), + sort: str | None = Query(None), + order: str = Query("desc"), + limit: int = Query(100, ge=1, le=1000), + offset: int = Query(0, ge=0), +): + items = list(store.iter_claims( + batch_id=batch_id, status=status, provider_npi=provider_npi, + payer=payer, date_from=date_from, date_to=date_to, + sort=sort, order=order, limit=limit, offset=offset, + )) + total = len(list(store.iter_claims( + batch_id=batch_id, status=status, provider_npi=provider_npi, + payer=payer, date_from=date_from, date_to=date_to, + ))) + return { + "items": items, + "total": total, + "returned": len(items), + "has_more": total > offset + len(items), + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd backend && .venv/bin/pytest tests/test_api_gets.py -v -k "claims_"` +Expected: 5 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/cyclone/api.py backend/tests/test_api_gets.py +git commit -m "feat(backend): add GET /api/claims with filter/sort/pagination" +``` + +--- + +### Task 7: `GET /api/remittances`, `GET /api/providers`, `GET /api/activity` + +**Files:** +- Modify: `backend/src/cyclone/api.py` +- Modify: `backend/tests/test_api_gets.py` + +- [ ] **Step 1: Add failing tests** + +Append to `backend/tests/test_api_gets.py`: +```python +# --------------------------------------------------------------------------- # +# /api/remittances +# --------------------------------------------------------------------------- # + + +def test_remittances_returns_empty_list_when_no_parses(client: TestClient): + resp = client.get("/api/remittances") + assert resp.status_code == 200 + body = resp.json() + assert body["items"] == [] + + +def test_remittances_filters_by_batch_id(seeded_store): + """No 835 fixture parsed yet — but the endpoint should return 200 with empty.""" + resp = seeded_store.get("/api/remittances?batch_id=nope") + assert resp.status_code == 200 + assert resp.json()["items"] == [] + + +# --------------------------------------------------------------------------- # +# /api/providers +# --------------------------------------------------------------------------- # + + +def test_providers_empty_when_no_parses(client: TestClient): + resp = client.get("/api/providers") + assert resp.status_code == 200 + body = resp.json() + assert body["items"] == [] + + +def test_providers_returns_one_per_distinct_npi(seeded_store): + resp = seeded_store.get("/api/providers") + assert resp.status_code == 200 + body = resp.json() + assert len(body["items"]) == 1 + assert body["items"][0]["npi"] == "1234567890" + + +# --------------------------------------------------------------------------- # +# /api/activity +# --------------------------------------------------------------------------- # + + +def test_activity_empty_when_no_parses(client: TestClient): + resp = client.get("/api/activity") + assert resp.status_code == 200 + body = resp.json() + assert body["items"] == [] + + +def test_activity_returns_one_event_per_claim(seeded_store): + resp = seeded_store.get("/api/activity") + assert resp.status_code == 200 + body = resp.json() + assert len(body["items"]) == 2 # co_medicaid_837p.txt has 2 claims + assert all(e["kind"] == "claim_submitted" for e in body["items"]) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd backend && .venv/bin/pytest tests/test_api_gets.py -v -k "remittances_ or providers_ or activity_"` +Expected: 6 tests fail with 404. + +- [ ] **Step 3: Implement the three routes** + +Add to `backend/src/cyclone/api.py`: +```python +@app.get("/api/remittances") +def list_remittances( + batch_id: str | None = Query(None), + payer: str | None = Query(None), + claim_id: str | None = Query(None), + date_from: str | None = Query(None), + date_to: str | None = Query(None), + sort: str | None = Query(None), + order: str = Query("desc"), + limit: int = Query(100, ge=1, le=1000), + offset: int = Query(0, ge=0), +): + items = list(store.iter_remittances( + batch_id=batch_id, payer=payer, claim_id=claim_id, + date_from=date_from, date_to=date_to, + sort=sort, order=order, limit=limit, offset=offset, + )) + total = len(list(store.iter_remittances( + batch_id=batch_id, payer=payer, claim_id=claim_id, + ))) + return { + "items": items, "total": total, "returned": len(items), + "has_more": total > offset + len(items), + } + + +@app.get("/api/providers") +def list_providers( + npi: str | None = Query(None), + state: str | None = Query(None), + limit: int = Query(100, ge=1, le=1000), + offset: int = Query(0, ge=0), +): + items = store.distinct_providers() + if npi is not None: + items = [p for p in items if p["npi"] == npi] + if state is not None: + items = [p for p in items if p.get("state") == state] + paged = items[offset:offset + limit] + return { + "items": paged, "total": len(items), "returned": len(paged), + "has_more": len(items) > offset + len(paged), + } + + +@app.get("/api/activity") +def list_activity( + kind: str | None = Query(None), + since: str | None = Query(None), + limit: int = Query(200, ge=1, le=500), +): + events = store.recent_activity(limit=limit) + if kind is not None: + events = [e for e in events if e["kind"] == kind] + if since is not None: + events = [e for e in events if e["timestamp"] >= since] + return { + "items": events, "total": len(events), "returned": len(events), + "has_more": False, + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd backend && .venv/bin/pytest tests/test_api_gets.py -v -k "remittances_ or providers_ or activity_"` +Expected: 6 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/cyclone/api.py backend/tests/test_api_gets.py +git commit -m "feat(backend): add GET /api/{remittances,providers,activity}" +``` + +--- + +### Task 8: NDJSON streaming on list endpoints + +**Files:** +- Modify: `backend/src/cyclone/api.py` +- Create: `backend/tests/test_api_streaming.py` + +- [ ] **Step 1: Add failing test** + +`backend/tests/test_api_streaming.py`: +```python +"""NDJSON streaming variant on the list endpoints.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from cyclone.api import app +from cyclone.store import store as global_store + +FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt" + + +@pytest.fixture(autouse=True) +def clear_store(): + with global_store._lock: + global_store._batches.clear() + yield + with global_store._lock: + global_store._batches.clear() + + +@pytest.fixture +def seeded_client(): + client = TestClient(app) + text = FIXTURE.read_text() + client.post( + "/api/parse-837", + files={"file": ("x.txt", text, "text/plain")}, + headers={"Accept": "application/json"}, + ) + return client + + +def test_claims_ndjson_returns_parseable_lines(seeded_client): + resp = seeded_client.get( + "/api/claims", + headers={"Accept": "application/x-ndjson"}, + ) + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("application/x-ndjson") + lines = [l for l in resp.text.split("\n") if l.strip()] + events = [json.loads(l) for l in lines] + items = [e["data"] for e in events if e["type"] == "item"] + summary = next(e for e in events if e["type"] == "summary") + assert len(items) == 2 # co_medicaid_837p.txt has 2 claims + assert summary["data"]["total"] == 2 + assert summary["data"]["has_more"] is False +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && .venv/bin/pytest tests/test_api_streaming.py -v` +Expected: response is JSON, not NDJSON — content-type assertion fails. + +- [ ] **Step 3: Implement streaming on `/api/claims` (and apply the same pattern to remittances/providers/activity)** + +In `backend/src/cyclone/api.py`, refactor `list_claims` to content-negotiate: + +```python +def _ndjson_stream(items: Iterable[dict], total: int) -> Iterator[str]: + """Yield NDJSON lines: one `item` per dict, then one `summary`.""" + for it in items: + yield json.dumps({"type": "item", "data": it}) + "\n" + yield json.dumps({ + "type": "summary", + "data": {"total": total, "returned": 0, "has_more": False}, # filled by caller + }) + "\n" + + +@app.get("/api/claims") +def list_claims( + request: Request, + batch_id: str | None = Query(None), + status: str | None = Query(None), + provider_npi: str | None = Query(None, alias="provider_npi"), + payer: str | None = Query(None), + date_from: str | None = Query(None), + date_to: str | None = Query(None), + sort: str | None = Query(None), + order: str = Query("desc"), + limit: int = Query(100, ge=1, le=1000), + offset: int = Query(0, ge=0), +): + all_filtered = list(store.iter_claims( + batch_id=batch_id, status=status, provider_npi=provider_npi, + payer=payer, date_from=date_from, date_to=date_to, + )) + page = all_filtered[offset:offset + limit] + total = len(all_filtered) + has_more = total > offset + len(page) + + if request.headers.get("accept") == "application/x-ndjson": + def gen(): + for it in page: + yield json.dumps({"type": "item", "data": it}) + "\n" + yield json.dumps({ + "type": "summary", + "data": {"total": total, "returned": len(page), "has_more": has_more}, + }) + "\n" + return StreamingResponse(gen(), media_type="application/x-ndjson") + + return { + "items": page, "total": total, "returned": len(page), "has_more": has_more, + } +``` + +Apply the same pattern to `list_remittances`, `list_providers`, and `list_activity` (each branches on `request.headers.get("accept") == "application/x-ndjson"` and returns `StreamingResponse` when set). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd backend && .venv/bin/pytest tests/test_api_streaming.py -v` +Expected: test passes. + +- [ ] **Step 5: Commit** + +```bash +git add backend/src/cyclone/api.py backend/tests/test_api_streaming.py +git commit -m "feat(backend): add NDJSON streaming on list endpoints" +``` + +--- + +### Task 9: Bind uvicorn to 127.0.0.1:8000 with env-var support + +**Files:** +- Modify: `backend/src/cyclone/__main__.py` + +- [ ] **Step 1: Update `__main__.py` to bind loopback and honor env vars** + +Replace `backend/src/cyclone/__main__.py` with: +```python +"""Entry point for ``python -m cyclone``. + +* ``python -m cyclone`` (no args) — Click CLI (``cli.main``) +* ``python -m cyclone serve`` — start the FastAPI app on 127.0.0.1:8000 + +Honors the env vars: + +* ``CYCLONE_PORT`` (default ``8000``) +* ``CYCLONE_RELOAD`` (default ``0``; set to ``1`` to enable uvicorn reload) +""" + +from __future__ import annotations + +import os +import sys + + +def main() -> None: + if len(sys.argv) >= 2 and sys.argv[1] == "serve": + port = os.environ.get("CYCLONE_PORT", "8000") + reload = os.environ.get("CYCLONE_RELOAD", "0") == "1" + sys.argv = [ + sys.argv[0], + "cyclone.api:app", + "--host", "127.0.0.1", + "--port", port, + ] + if reload: + sys.argv.append("--reload") + import uvicorn + + uvicorn.main() + return + from cyclone.cli import main as cli_main + + cli_main() + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 2: Verify locally (manual)** + +Run: `cd backend && .venv/bin/python -m cyclone serve &` +Then: `curl -s http://127.0.0.1:8000/api/health | jq .` → should print `{"status": "ok", "version": "0.1.0"}`. +Then: `curl -s http://0.0.0.0:8000/api/health` → should fail (refused; loopback only). + +Kill the server: `kill %1` (or `pkill -f 'cyclone.api:app'`). + +- [ ] **Step 3: Commit** + +```bash +git add backend/src/cyclone/__main__.py +git commit -m "feat(backend): bind serve to 127.0.0.1:8000, honor CYCLONE_PORT/CYCLONE_RELOAD" +``` + +--- + +### Task 10: Run the full backend test suite + manual smoke + +- [ ] **Step 1: Run all backend tests** + +Run: `cd backend && .venv/bin/pytest -v` +Expected: all tests pass (existing 121 + new ~23 = 144+ total). + +- [ ] **Step 2: Manual smoke (in two terminals)** + +Terminal 1: `cd backend && .venv/bin/python -m cyclone serve` +Terminal 2: `curl -s -X POST -F "file=@backend/tests/fixtures/co_medicaid_837p.txt" -H "Accept: application/json" http://127.0.0.1:8000/api/parse-837 | jq .summary` +Then: `curl -s 'http://127.0.0.1:8000/api/claims?status=submitted' | jq '{total, returned}'` +Then: `curl -s 'http://127.0.0.1:8000/api/claims' -H 'Accept: application/x-ndjson' | head -4` +Expected: parse succeeds; `/api/claims` returns 2 items; NDJSON returns 2 item lines + 1 summary line. + +- [ ] **Step 3: Commit any leftover changes (likely none)** + +```bash +git status # should be clean +``` + +--- + +## Phase 2 — Frontend + +### Task 11: Install `@tanstack/react-query` + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Add the dep** + +Run: `cd /Users/openclaw/dev/cyclone && npm install @tanstack/react-query@^5.59.0` +Expected: `package.json` updated; `node_modules` grows. + +- [ ] **Step 2: Commit** + +```bash +git add package.json package-lock.json +git commit -m "feat(frontend): add @tanstack/react-query ^5.59.0" +``` + +--- + +### Task 12: Wrap `` in `` + +**Files:** +- Modify: `src/main.tsx` + +- [ ] **Step 1: Update `main.tsx`** + +Replace `src/main.tsx` with: +```tsx +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import App from "./App"; +import "./index.css"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + refetchOnWindowFocus: true, + }, + }, +}); + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + + + +); +``` + +- [ ] **Step 2: Verify build still passes** + +Run: `npm run build` +Expected: build succeeds with no TS errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/main.tsx +git commit -m "feat(frontend): wrap App in QueryClientProvider" +``` + +--- + +### Task 13: Add 3 keyframes to `tailwind.config.js` + +**Files:** +- Modify: `tailwind.config.js` + +- [ ] **Step 1: Add the keyframes** + +In `tailwind.config.js`, inside `theme.extend.keyframes`, add three new entries (next to the existing `fade-in`): +```js +"shimmer": { + "0%": { transform: "translateX(-100%)" }, + "100%": { transform: "translateX(100%)" }, +}, +"scan": { + "0%": { transform: "translateX(-100%)" }, + "50%": { transform: "translateX(300%)" }, + "100%": { transform: "translateX(-100%)" }, +}, +"row-flash": { + "0%": { backgroundColor: "hsl(var(--accent) / 0.10)" }, + "100%": { backgroundColor: "transparent" }, +}, +``` + +- [ ] **Step 2: Commit** + +```bash +git add tailwind.config.js +git commit -m "feat(frontend): add shimmer, scan, row-flash keyframes" +``` + +--- + +### Task 14: `` UI primitive (spec 7.6) + +**Files:** +- Create: `src/components/ui/skeleton.tsx` + +- [ ] **Step 1: Create the file** + +`src/components/ui/skeleton.tsx`: +```tsx +import { cn } from "@/lib/utils"; + +type SkeletonProps = { + className?: string; + variant?: "row" | "card" | "kpi"; +}; + +/** + * Loading skeleton with a hairline ring + a slow horizontal scanline. + * Voice: not a generic "pulse a gray block" — the shimmer reads as + * a precision-instrument sweep. + */ +export function Skeleton({ className, variant = "row" }: SkeletonProps) { + return ( +
+
+ {variant === "row" ? ( +
+
+
+
+
+
+
+
+ ) : variant === "card" ? ( +
+
+
+
+
+ ) : ( +
+
+
+
+ )} +
+ ); +} +``` + +- [ ] **Step 2: Verify build** + +Run: `npm run build` +Expected: passes. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/ui/skeleton.tsx +git commit -m "feat(frontend): add Skeleton UI primitive (hairline + scanline-shimmer)" +``` + +--- + +### Task 15: `` UI primitive (spec 7.7.2) + +**Files:** +- Create: `src/components/ui/empty-state.tsx` + +- [ ] **Step 1: Create the file** + +`src/components/ui/empty-state.tsx`: +```tsx +import { CircleDashed } from "lucide-react"; + +type EmptyStateProps = { + /** All-caps instrument label, e.g. "Claims · inbox idle". */ + eyebrow: string; + /** One-line plain-language message. No apologetic language. */ + message: string; +}; + +export function EmptyState({ eyebrow, message }: EmptyStateProps) { + return ( +
+
+ +
+
+ {eyebrow} +
+
{message}
+
+ ); +} +``` + +- [ ] **Step 2: Verify build + commit** + +```bash +npm run build +git add src/components/ui/empty-state.tsx +git commit -m "feat(frontend): add EmptyState primitive (instrument-label eyebrow)" +``` + +--- + +### Task 16: `` UI primitive (spec 7.7.3) + +**Files:** +- Create: `src/components/ui/error-state.tsx` + +- [ ] **Step 1: Create the file** + +`src/components/ui/error-state.tsx`: +```tsx +import { AlertCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +type ErrorStateProps = { + error: Error; + onRetry: () => void; +}; + +export function ErrorState({ error, onRetry }: ErrorStateProps) { + return ( +
+ +
+
+ Fetch failed +
+
+ {error.message} +
+
+ +
+ ); +} +``` + +- [ ] **Step 2: Verify build + commit** + +```bash +npm run build +git add src/components/ui/error-state.tsx +git commit -m "feat(frontend): add ErrorState primitive (destructive hairline + retry)" +``` + +--- + +### Task 17: `` UI primitive (spec 7.7.5) + +**Files:** +- Create: `src/components/ui/filter-chips.tsx` + +- [ ] **Step 1: Create the file** + +`src/components/ui/filter-chips.tsx`: +```tsx +import { X } from "lucide-react"; + +export type FilterChip = { key: string; label: string }; + +type FilterChipsProps = { + active: FilterChip[]; + onRemove: (key: string) => void; + onClearAll: () => void; +}; + +export function FilterChips({ + active, + onRemove, + onClearAll, +}: FilterChipsProps) { + if (active.length === 0) return null; + return ( +
+ + Active + + {active.map((c) => ( + + {c.key}: + {c.label} + + + ))} + {active.length > 1 ? ( + + ) : null} +
+ ); +} +``` + +- [ ] **Step 2: Verify build + commit** + +```bash +npm run build +git add src/components/ui/filter-chips.tsx +git commit -m "feat(frontend): add FilterChips primitive (read-only pill row)" +``` + +--- + +### Task 18: `` UI primitive (spec 7.7.8) + +**Files:** +- Create: `src/components/ui/pagination.tsx` + +- [ ] **Step 1: Create the file** + +`src/components/ui/pagination.tsx`: +```tsx +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { cn } from "@/lib/utils"; + +type PaginationProps = { + page: number; // 1-indexed + pageSize: number; + total: number; + onChange: (page: number) => void; +}; + +function buildPageList(current: number, last: number): (number | "…")[] { + if (last <= 7) return Array.from({ length: last }, (_, i) => i + 1); + const out: (number | "…")[] = [1]; + const start = Math.max(2, current - 1); + const end = Math.min(last - 1, current + 1); + if (start > 2) out.push("…"); + for (let i = start; i <= end; i++) out.push(i); + if (end < last - 1) out.push("…"); + out.push(last); + return out; +} + +export function Pagination({ page, pageSize, total, onChange }: PaginationProps) { + const last = Math.max(1, Math.ceil(total / pageSize)); + if (last <= 1) return null; + const pages = buildPageList(page, last); + const returned = Math.min(pageSize, total - (page - 1) * pageSize); + return ( +
+
+ {returned} of {total} +
+ +
+ ); +} +``` + +- [ ] **Step 2: Verify build + commit** + +```bash +npm run build +git add src/components/ui/pagination.tsx +git commit -m "feat(frontend): add Pagination primitive (nav-active echo, num pages)" +``` + +--- + +### Task 19: Layout-level refetch indicator (spec 7.7.4) + +**Files:** +- Modify: `src/components/Layout.tsx` + +- [ ] **Step 1: Update `Layout.tsx`** + +Replace `src/components/Layout.tsx` with: +```tsx +import { Outlet } from "react-router-dom"; +import { useIsFetching } from "@tanstack/react-query"; +import { Sidebar } from "./Sidebar"; + +export function Layout() { + const isFetching = useIsFetching(); + return ( +
+ {isFetching > 0 ? ( +
+
+
+ ) : null} + +
+
+ +
+
+
+ ); +} +``` + +- [ ] **Step 2: Verify build + commit** + +```bash +npm run build +git add src/components/Layout.tsx +git commit -m "feat(frontend): add 1px scan-line refetch indicator to Layout" +``` + +--- + +### Task 20: Add GET methods to `src/lib/api.ts` + +**Files:** +- Modify: `src/lib/api.ts` +- Create: `src/lib/api.test.ts` (extend with 3 tests) + +- [ ] **Step 1: Add GET methods + types** + +In `src/lib/api.ts`, before the `export const api = {` line, add: +```ts +// --------------------------------------------------------------------------- +// GET endpoints (sub-project 1) +// --------------------------------------------------------------------------- + +export type ListClaimsParams = { + batch_id?: string; + status?: string; + provider_npi?: string; + payer?: string; + date_from?: string; + date_to?: string; + sort?: string; + order?: "asc" | "desc"; + limit?: number; + offset?: number; +}; + +export type ListRemittancesParams = { + batch_id?: string; + payer?: string; + claim_id?: string; + date_from?: string; + date_to?: string; + sort?: string; + order?: "asc" | "desc"; + limit?: number; + offset?: number; +}; + +export type ListProvidersParams = { + npi?: string; + state?: string; + limit?: number; + offset?: number; +}; + +export type ListActivityParams = { + kind?: string; + since?: string; + limit?: number; +}; + +export type PaginatedResponse = { + items: T[]; + total: number; + returned: number; + has_more: boolean; +}; + +export type BatchSummary = { + id: string; + kind: "837p" | "835"; + inputFilename: string; + parsedAt: string; + claimCount: number; +}; + +function qs(params: Record | undefined): string { + if (!params) return ""; + const u = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v === undefined || v === null || v === "") continue; + u.set(k, String(v)); + } + const s = u.toString(); + return s ? `?${s}` : ""; +} + +async function listBatches(): Promise { + const res = await fetch(joinUrl("/api/batches")); + if (!res.ok) { + const detail = await readErrorBody(res); + throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`); + } + const body = (await res.json()) as { items: BatchSummary[] }; + return body.items; +} + +async function getBatch(id: string): Promise { + const res = await fetch(joinUrl(`/api/batches/${id}`)); + if (!res.ok) { + const detail = await readErrorBody(res); + throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`); + } + return (await res.json()) as ParseResult837 | ParseResult835; +} + +async function listClaims( + params: ListClaimsParams +): Promise> { + const res = await fetch(joinUrl(`/api/claims${qs(params as Record)}`)); + if (!res.ok) { + const detail = await readErrorBody(res); + throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`); + } + return (await res.json()) as PaginatedResponse; +} + +async function listRemittances( + params: ListRemittancesParams +): Promise> { + const res = await fetch(joinUrl(`/api/remittances${qs(params as Record)}`)); + if (!res.ok) { + const detail = await readErrorBody(res); + throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`); + } + return (await res.json()) as PaginatedResponse; +} + +async function listProviders( + params: ListProvidersParams = {} +): Promise> { + const res = await fetch(joinUrl(`/api/providers${qs(params as Record)}`)); + if (!res.ok) { + const detail = await readErrorBody(res); + throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`); + } + return (await res.json()) as PaginatedResponse; +} + +async function listActivity( + params: ListActivityParams = {} +): Promise> { + const res = await fetch(joinUrl(`/api/activity${qs(params as Record)}`)); + if (!res.ok) { + const detail = await readErrorBody(res); + throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`); + } + return (await res.json()) as PaginatedResponse; +``` + +Then update the `export const api = {` block to add the new methods: +```ts +export const api = { + isConfigured, + baseUrl: BASE_URL, + health, + parse837, + parse835, + listBatches, + getBatch, + listClaims, + listRemittances, + listProviders, + listActivity, + // Legacy placeholders (removed in a follow-up): + getClaims: () => legacyNotImplemented("getClaims"), + createClaim: (_payload: unknown) => legacyNotImplemented("createClaim"), + getRemittances: () => legacyNotImplemented("getRemittances"), + getProviders: () => legacyNotImplemented("getProviders"), + getActivity: () => legacyNotImplemented("getActivity"), +}; +``` + +- [ ] **Step 2: Add 3 unit tests in `src/lib/api.test.ts`** + +Create `src/lib/api.test.ts`: +```ts +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { api } from "./api"; + +const originalFetch = global.fetch; + +describe("api GET helpers", () => { + beforeEach(() => { + global.fetch = vi.fn(); + }); + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("listBatches hits /api/batches", async () => { + (global.fetch as ReturnType).mockResolvedValueOnce( + new Response(JSON.stringify({ items: [] }), { status: 200 }) + ); + const out = await api.listBatches(); + expect(out).toEqual([]); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/api/batches") + ); + }); + + it("listClaims builds the right URL with status + sort", async () => { + (global.fetch as ReturnType).mockResolvedValueOnce( + new Response(JSON.stringify({ items: [], total: 0 }), { status: 200 }) + ); + await api.listClaims({ status: "submitted", sort: "billedAmount", order: "desc" }); + const called = (global.fetch as ReturnType).mock.calls[0][0] as string; + expect(called).toContain("/api/claims?"); + expect(called).toContain("status=submitted"); + expect(called).toContain("sort=billedAmount"); + expect(called).toContain("order=desc"); + }); + + it("getBatch returns the result with the right id", async () => { + (global.fetch as ReturnType).mockResolvedValueOnce( + new Response( + JSON.stringify({ envelope: null, claims: [], summary: {}, kind: "835" }), + { status: 200 } + ) + ); + const out = await api.getBatch("abc"); + expect(out).toBeTruthy(); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining("/api/batches/abc") + ); + }); +}); +``` + +> If `vitest` is not already configured, add `"test": "vitest run"` to `package.json` `scripts` and `vitest` to devDependencies. Run `npm install -D vitest` if needed. + +- [ ] **Step 3: Run the tests** + +Run: `npm test -- src/lib/api.test.ts` +Expected: 3 tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/api.ts src/lib/api.test.ts package.json package-lock.json +git commit -m "feat(frontend): add listBatches/getBatch/listClaims/listRemittances/listProviders/listActivity" +``` + +--- + +### Task 21: Add 6 query/mutation hooks + +**Files:** +- Create: `src/hooks/useBatches.ts` +- Create: `src/hooks/useClaims.ts` +- Create: `src/hooks/useRemittances.ts` +- Create: `src/hooks/useProviders.ts` +- Create: `src/hooks/useActivity.ts` +- Create: `src/hooks/useParse.ts` + +- [ ] **Step 1: `useBatches.ts`** + +```ts +import { useQuery } from "@tanstack/react-query"; +import { api, type BatchSummary } from "@/lib/api"; + +export function useBatches() { + return useQuery({ + queryKey: ["batches"], + queryFn: () => api.listBatches(), + enabled: api.isConfigured, + }); +} +``` + +- [ ] **Step 2: `useClaims.ts`** (with fallback to zustand when `!api.isConfigured`) + +```ts +import { useQuery } from "@tanstack/react-query"; +import { useSyncExternalStore } from "react"; +import { api, type ListClaimsParams, type PaginatedResponse } from "@/lib/api"; +import { useAppStore } from "@/store"; +import type { Claim } from "@/types"; + +const EMPTY: PaginatedResponse = { + items: [], total: 0, returned: 0, has_more: false, +}; + +export function useClaims(params: ListClaimsParams) { + const fallback = useSyncExternalStore( + (cb) => useAppStore.subscribe(cb), + () => useAppStore.getState().claims, + () => useAppStore.getState().claims, + ); + + const q = useQuery>({ + queryKey: ["claims", params], + queryFn: () => api.listClaims(params), + enabled: api.isConfigured, + }); + + if (!api.isConfigured) { + const all = fallback; + const filtered = all.filter((c) => { + if (params.status && c.status !== params.status) return false; + if (params.provider_npi && c.providerNpi !== params.provider_npi) return false; + if (params.batch_id && c.batchId !== params.batch_id) return false; + return true; + }); + const offset = params.offset ?? 0; + const limit = params.limit ?? 100; + return { + data: { + items: filtered.slice(offset, offset + limit), + total: filtered.length, + returned: Math.min(limit, filtered.length - offset), + has_more: filtered.length > offset + limit, + } satisfies PaginatedResponse, + isLoading: false, + isError: false, + error: null, + refetch: () => Promise.resolve(), + } as const; + } + return q; +} + +export { EMPTY as EMPTY_CLAIMS }; +``` + +- [ ] **Step 3: `useRemittances.ts`** + +```ts +import { useQuery } from "@tanstack/react-query"; +import { useSyncExternalStore } from "react"; +import { api, type ListRemittancesParams, type PaginatedResponse } from "@/lib/api"; +import { useAppStore } from "@/store"; +import type { Remittance } from "@/types"; + +export function useRemittances(params: ListRemittancesParams) { + const fallback = useSyncExternalStore( + (cb) => useAppStore.subscribe(cb), + () => useAppStore.getState().remittances, + () => useAppStore.getState().remittances, + ); + + const q = useQuery>({ + queryKey: ["remittances", params], + queryFn: () => api.listRemittances(params), + enabled: api.isConfigured, + }); + + if (!api.isConfigured) { + const offset = params.offset ?? 0; + const limit = params.limit ?? 100; + return { + data: { + items: fallback.slice(offset, offset + limit), + total: fallback.length, + returned: Math.min(limit, fallback.length - offset), + has_more: fallback.length > offset + limit, + } satisfies PaginatedResponse, + isLoading: false, isError: false, error: null, + refetch: () => Promise.resolve(), + } as const; + } + return q; +} +``` + +- [ ] **Step 4: `useProviders.ts`** + +```ts +import { useQuery } from "@tanstack/react-query"; +import { useSyncExternalStore } from "react"; +import { api, type ListProvidersParams, type PaginatedResponse } from "@/lib/api"; +import { useAppStore } from "@/store"; +import type { Provider } from "@/types"; + +export function useProviders(params: ListProvidersParams = {}) { + const fallback = useSyncExternalStore( + (cb) => useAppStore.subscribe(cb), + () => useAppStore.getState().providers, + () => useAppStore.getState().providers, + ); + + const q = useQuery>({ + queryKey: ["providers", params], + queryFn: () => api.listProviders(params), + enabled: api.isConfigured, + }); + + if (!api.isConfigured) { + return { + data: { items: fallback, total: fallback.length, returned: fallback.length, has_more: false } satisfies PaginatedResponse, + isLoading: false, isError: false, error: null, + refetch: () => Promise.resolve(), + } as const; + } + return q; +} +``` + +- [ ] **Step 5: `useActivity.ts`** + +```ts +import { useQuery } from "@tanstack/react-query"; +import { useSyncExternalStore } from "react"; +import { api, type ListActivityParams, type PaginatedResponse } from "@/lib/api"; +import { useAppStore } from "@/store"; +import type { Activity } from "@/types"; + +export function useActivity(params: ListActivityParams = {}) { + const fallback = useSyncExternalStore( + (cb) => useAppStore.subscribe(cb), + () => useAppStore.getState().activity, + () => useAppStore.getState().activity, + ); + + const q = useQuery>({ + queryKey: ["activity", params], + queryFn: () => api.listActivity(params), + enabled: api.isConfigured, + refetchInterval: api.isConfigured ? 30_000 : false, + }); + + if (!api.isConfigured) { + return { + data: { items: fallback, total: fallback.length, returned: fallback.length, has_more: false } satisfies PaginatedResponse, + isLoading: false, isError: false, error: null, + refetch: () => Promise.resolve(), + } as const; + } + return q; +} +``` + +- [ ] **Step 6: `useParse.ts`** + +```ts +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api, type ParseOptions } from "@/lib/api"; + +export function useParse(kind: "837p" | "835") { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (opts: { file: File; options?: ParseOptions }) => + kind === "837p" + ? api.parse837(opts.file, opts.options) + : api.parse835(opts.file, opts.options), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["batches"] }); + qc.invalidateQueries({ queryKey: ["claims"] }); + qc.invalidateQueries({ queryKey: ["remittances"] }); + qc.invalidateQueries({ queryKey: ["providers"] }); + qc.invalidateQueries({ queryKey: ["activity"] }); + }, + }); +} +``` + +- [ ] **Step 7: Verify build + commit** + +```bash +npm run build +git add src/hooks/ +git commit -m "feat(frontend): add 6 query/mutation hooks (batches, claims, remits, providers, activity, parse)" +``` + +--- + +### Task 22: Refactor `Claims.tsx` to use `useClaims` + new UX patterns + +**Files:** +- Modify: `src/pages/Claims.tsx` + +- [ ] **Step 1: Replace the file** + +Replace `src/pages/Claims.tsx` with: +```tsx +import { useMemo, useRef, useState } from "react"; +import { Search, X } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { ClaimStatusBadge } from "@/components/StatusBadge"; +import { NewClaimDialog } from "@/components/NewClaimDialog"; +import { Skeleton } from "@/components/ui/skeleton"; +import { EmptyState } from "@/components/ui/empty-state"; +import { ErrorState } from "@/components/ui/error-state"; +import { FilterChips } from "@/components/ui/filter-chips"; +import { Pagination } from "@/components/ui/pagination"; +import { useClaims } from "@/hooks/useClaims"; +import { useAppStore } from "@/store"; +import { fmt } from "@/lib/format"; +import type { ClaimStatus } from "@/types"; + +const ALL = "all" as const; +const statuses: (ClaimStatus | typeof ALL)[] = [ + ALL, + "submitted", + "accepted", + "denied", + "paid", + "pending", +]; +const PAGE_SIZE = 25; + +export function Claims() { + const [status, setStatus] = useState(ALL); + const [npi, setNpi] = useState(ALL); + const [query, setQuery] = useState(""); + const [page, setPage] = useState(1); + const searchRef = useRef(null); + + const providers = useAppStore((s) => s.providers); + const providerMap = useMemo( + () => Object.fromEntries(providers.map((p) => [p.npi, p])), + [providers] + ); + + const params = { + status: status === ALL ? undefined : status, + provider_npi: npi === ALL ? undefined : npi, + sort: "billedAmount", + order: "desc" as const, + limit: PAGE_SIZE, + offset: (page - 1) * PAGE_SIZE, + }; + + const { data, isLoading, isError, error, refetch } = useClaims(params); + const items = data?.items ?? []; + + const activeChips = [ + status !== ALL ? { key: "Status", label: status } : null, + npi !== ALL + ? { + key: "NPI", + label: `${npi}${providerMap[npi] ? ` · ${providerMap[npi]!.name}` : ""}`, + } + : null, + ].filter(Boolean) as { key: string; label: string }[]; + + const totals = useMemo( + () => ({ + billed: items.reduce((s, c) => s + c.billedAmount, 0), + received: items.reduce((s, c) => s + c.receivedAmount, 0), + }), + [items] + ); + + // Local search filter on the current page (cheap; no extra fetch). + const visible = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return items; + return items.filter( + (c) => + c.id.toLowerCase().includes(q) || + c.patientName.toLowerCase().includes(q) || + c.payerName.toLowerCase().includes(q) || + c.cptCode.toLowerCase().includes(q) + ); + }, [items, query]); + + function clearFilter(key: string) { + if (key === "Status") setStatus(ALL); + if (key === "NPI") setNpi(ALL); + setPage(1); + } + + return ( +
+
+
+
+ + Claims +
+

All claims

+
+ +
+ + {isError && error ? ( + refetch()} /> + ) : null} + +
+
+
+ + setQuery(e.target.value)} + className="pl-9 pr-16" + /> + {query ? ( + + ) : null} +
+ + +
+ + { + setStatus(ALL); + setNpi(ALL); + setPage(1); + }} + /> + +
+ + + {fmt.num(data?.total ?? 0)} + {" "} + claims + + + Billed{" "} + + {fmt.usd(totals.billed)} + + + + Received{" "} + + {fmt.usd(totals.received)} + + +
+
+ +
+ {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : items.length === 0 ? ( + + ) : ( + <> + + + + Claim + Patient + Provider + Payer + Billed + Received + Status + Submitted + + + + {visible.map((c) => { + const provider = providerMap[c.providerNpi]; + return ( + + +
{c.id}
+
+ CPT {c.cptCode} +
+
+ {c.patientName} + +
{provider?.name ?? "Unknown"}
+
+ {c.providerNpi} +
+
+ + {c.payerName} + + + {fmt.usd(c.billedAmount)} + + + {c.receivedAmount > 0 ? fmt.usd(c.receivedAmount) : "—"} + + + + + + {fmt.dateShort(c.submissionDate)} + +
+ ); + })} +
+
+
+ +
+ + )} +
+
+ ); +} +``` + +- [ ] **Step 2: Verify build + commit** + +```bash +npm run build +git add src/pages/Claims.tsx +git commit -m "feat(frontend): refactor Claims page to useClaims + skeleton/empty/error/chips/pagination" +``` + +--- + +### Task 23: Refactor `Remittances.tsx` + +**Files:** +- Modify: `src/pages/Remittances.tsx` + +- [ ] **Step 1: Replace the file** + +Replace `src/pages/Remittances.tsx` with: +```tsx +import { useState } from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { RemitStatusBadge } from "@/components/StatusBadge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { EmptyState } from "@/components/ui/empty-state"; +import { ErrorState } from "@/components/ui/error-state"; +import { Pagination } from "@/components/ui/pagination"; +import { useRemittances } from "@/hooks/useRemittances"; +import { fmt } from "@/lib/format"; + +const PAGE_SIZE = 25; + +export function Remittances() { + const [page, setPage] = useState(1); + const { data, isLoading, isError, error, refetch } = useRemittances({ + sort: "receivedDate", + order: "desc", + limit: PAGE_SIZE, + offset: (page - 1) * PAGE_SIZE, + }); + const items = data?.items ?? []; + + const total = items.reduce( + (acc, r) => ({ + paid: acc.paid + r.paidAmount, + adjustments: acc.adjustments + r.adjustmentAmount, + }), + { paid: 0, adjustments: 0 } + ); + + return ( +
+
+
+ + Remittances +
+

835 remittances

+

+ Electronic remittance advice from payers, matched to submitted claims. +

+
+ + {isError && error ? ( + refetch()} /> + ) : null} + +
+
+
+ Remits +
+
+ {fmt.num(data?.total ?? 0)} +
+
+
+
+ Total paid +
+
{fmt.usd(total.paid)}
+
+
+
+ Adjustments +
+
{fmt.usd(total.adjustments)}
+
+
+ +
+ {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : items.length === 0 ? ( + + ) : ( + <> + + + + Remit + Claim + Payer + Paid + Adjustment + Status + Received + + + + {items.map((r) => ( + + {r.id} + + {r.claimId} + + {r.payerName} + + {fmt.usdPrecise(r.paidAmount)} + + + {fmt.usdPrecise(r.adjustmentAmount)} + + + + + + {fmt.dateShort(r.receivedDate)} + + + ))} + +
+
+ +
+ + )} +
+
+ ); +} +``` + +- [ ] **Step 2: Verify build + commit** + +```bash +npm run build +git add src/pages/Remittances.tsx +git commit -m "feat(frontend): refactor Remittances page to useRemittances + UX patterns" +``` + +--- + +### Task 24: Refactor `Providers.tsx` + +**Files:** +- Modify: `src/pages/Providers.tsx` + +- [ ] **Step 1: Replace the file** + +Replace `src/pages/Providers.tsx` with: +```tsx +import { Building2, MapPin, Phone } from "lucide-react"; +import { Skeleton } from "@/components/ui/skeleton"; +import { EmptyState } from "@/components/ui/empty-state"; +import { ErrorState } from "@/components/ui/error-state"; +import { useProviders } from "@/hooks/useProviders"; +import { fmt } from "@/lib/format"; + +export function Providers() { + const { data, isLoading, isError, error, refetch } = useProviders(); + const items = data?.items ?? []; + + return ( +
+
+
+ + Providers +
+

Provider directory

+

+ NPIs registered with the clearinghouse for 837P submission and 835 + remittance retrieval. +

+
+ + {isError && error ? ( + refetch()} /> + ) : null} + + {isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) : items.length === 0 ? ( +
+ +
+ ) : ( +
+ {items.map((p) => ( +
+
+
+ +
+
+

+ {p.name} +

+

+ NPI {p.npi} · TIN {p.taxId} +

+
+
+ +
+
+ + + {p.address}, {p.city}, {p.state} {p.zip} + +
+
+ + {p.phone} +
+
+ +
+
+
+ Claims +
+
+ {fmt.num(p.claimCount)} +
+
+
+
+ Outstanding AR +
+
+ {fmt.usd(p.outstandingAr)} +
+
+
+
+ ))} +
+ )} +
+ ); +} +``` + +- [ ] **Step 2: Verify build + commit** + +```bash +npm run build +git add src/pages/Providers.tsx +git commit -m "feat(frontend): refactor Providers page to useProviders + UX patterns" +``` + +--- + +### Task 25: Refactor `ActivityLog.tsx` + +**Files:** +- Modify: `src/pages/ActivityLog.tsx` + +- [ ] **Step 1: Replace the file** + +Replace `src/pages/ActivityLog.tsx` with: +```tsx +import { useActivity } from "@/hooks/useActivity"; +import { ActivityFeed } from "@/components/ActivityFeed"; +import { Skeleton } from "@/components/ui/skeleton"; +import { EmptyState } from "@/components/ui/empty-state"; +import { ErrorState } from "@/components/ui/error-state"; + +export function ActivityLog() { + const { data, isLoading, isError, error, refetch } = useActivity({ limit: 200 }); + const items = data?.items ?? []; + + return ( +
+
+
+ + Activity +
+

Activity log

+

+ Every claim submission, denial, payment, and provider event, in + reverse chronological order. Auto-refreshes every 30s. +

+
+ + {isError && error ? ( + refetch()} /> + ) : null} + +
+ {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : items.length === 0 ? ( + + ) : ( + + )} +
+
+ ); +} +``` + +- [ ] **Step 2: Verify build + commit** + +```bash +npm run build +git add src/pages/ActivityLog.tsx +git commit -m "feat(frontend): refactor ActivityLog page to useActivity + UX patterns" +``` + +--- + +### Task 26: Refactor `Upload.tsx` to use `useParse` mutation + +**Files:** +- Modify: `src/pages/Upload.tsx` + +- [ ] **Step 1: Replace the file** + +Replace `src/pages/Upload.tsx` with: +```tsx +import { useMemo, useRef, useState } from "react"; +import { + AlertTriangle, + CheckCircle2, + ChevronRight, + FileText, + Loader2, + Upload as UploadIcon, + XCircle, +} from "lucide-react"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { api, type ParseProgress } from "@/lib/api"; +import { fmt, toNum } from "@/lib/format"; +import { useAppStore } from "@/store"; +import { useParse } from "@/hooks/useParse"; +import type { + ClaimOutput, + ClaimPayment, + ParsedBatch, + ParsedBatchKind, + ServiceLine, + ServicePayment, +} from "@/types"; +import { cn } from "@/lib/utils"; + +type StreamedClaim837 = { kind: "837p"; data: ClaimOutput }; +type StreamedClaim835 = { kind: "835"; data: ClaimPayment }; +type StreamedClaim = StreamedClaim837 | StreamedClaim835; + +interface StreamState { + items: StreamedClaim[]; + expectedTotal: number | null; + passed: number; + failed: number; +} + +const PAYERS_837 = [ + { value: "co_medicaid", label: "CO Medicaid" }, + { value: "generic_837p", label: "Generic 837P" }, +]; +const PAYERS_835 = [ + { value: "co_medicaid_835", label: "CO Medicaid" }, + { value: "generic_835", label: "Generic 835" }, +]; + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +} + +function ValidationDot({ + passed, + hasWarnings, +}: { passed: boolean; hasWarnings?: boolean }) { + if (passed) + return ( + + + Passed + + ); + if (hasWarnings) + return ( + + + Warnings + + ); + return ( + + + Failed + + ); +} + +function StatPill({ label, value, mono = true }: { label: string; value: React.ReactNode; mono?: boolean }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function ClaimCard837({ claim }: { claim: ClaimOutput }) { + const [open, setOpen] = useState(false); + const passed = claim.validation.passed; + const hasWarnings = claim.validation.warnings.length > 0; + return ( +
+ + {open ? ( +
+
+ + + + +
+
+ ) : null} +
+ ); +} + +function ClaimCard835({ claim }: { claim: ClaimPayment }) { + const [open, setOpen] = useState(false); + const passed = claim.service_payments.length > 0; + return ( +
+ +
+ ); +} + +export function Upload() { + const inputRef = useRef(null); + const [file, setFile] = useState(null); + const [kind, setKind] = useState("837p"); + const [payer, setPayer] = useState(PAYERS_837[0]!.value); + const [stream, setStream] = useState({ + items: [], expectedTotal: null, passed: 0, failed: 0, + }); + const addParsedBatch = useAppStore((s) => s.addParsedBatch); + const parsedBatches = useAppStore((s) => s.parsedBatches); + const parseMutation = useParse(kind); + + const payerOptions = kind === "837p" ? PAYERS_837 : PAYERS_835; + const totalSoFar = stream.items.length; + const progressPct = useMemo(() => { + if (!stream.expectedTotal || stream.expectedTotal <= 0) return 0; + return Math.min(100, Math.round((totalSoFar / stream.expectedTotal) * 100)); + }, [stream.expectedTotal, totalSoFar]); + + function pickFile(f: File | null) { + setFile(f); + setStream({ items: [], expectedTotal: null, passed: 0, failed: 0 }); + } + + async function onParse() { + if (!file) return; + if (!api.isConfigured) { + toast.error("Backend not configured. Set VITE_API_BASE_URL in .env.local to use the Upload page."); + return; + } + setStream({ items: [], expectedTotal: null, passed: 0, failed: 0 }); + const items: StreamedClaim[] = []; + const onProgress: ParseProgress = (event) => { + if (event.type === "claim") { + items.push({ kind: "837p", data: event.data }); + setStream((s) => ({ ...s, items: [...items] })); + } else if (event.type === "claim_payment") { + items.push({ kind: "835", data: event.data }); + setStream((s) => ({ ...s, items: [...items] })); + } else if (event.type === "summary") { + setStream((s) => ({ + ...s, + expectedTotal: event.data.total_claims, + passed: event.data.passed, + failed: event.data.failed, + })); + } + }; + try { + const summary = await parseMutation.mutateAsync({ + file, + options: { payer, onProgress }, + }); + if (summary && typeof summary === "object" && "total_claims" in summary) { + const claimIds = items.map((c) => c.kind === "837p" ? c.data.claim_id : c.data.payer_claim_control_number).filter(Boolean); + const batch: ParsedBatch = { + id: `BATCH-${Date.now()}`, + kind, + inputFilename: file.name, + parsedAt: new Date().toISOString(), + claimCount: summary.total_claims, + passed: summary.passed, + failed: summary.failed, + claimIds, + summary, + }; + addParsedBatch(batch); + toast.success(`Parsed ${summary.total_claims} ${kind === "837p" ? "claims" : "payments"} · ${summary.passed} passed · ${summary.failed} failed`, { + description: file.name, + }); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to parse file"); + } + } + + return ( +
+
+
+ + Upload · EDI parser +
+

Parse an X12 file

+

+ Upload an 837P professional claim or 835 ERA remittance file. The parser streams claims back as they're produced so the UI updates in real time. +

+
+ + + + + + File + + + .txt (X12 837P or 835). The file is sent to the FastAPI backend configured by VITE_API_BASE_URL. + + + +
+
+
Kind
+ +
+
+
Payer config
+ +
+
+ +
e.preventDefault()} + onDrop={(e) => { e.preventDefault(); const f = e.dataTransfer.files?.[0]; if (f) pickFile(f); }} + onClick={() => inputRef.current?.click()} + role="button" tabIndex={0} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") inputRef.current?.click(); }} + className={cn( + "relative flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border/60 bg-muted/20 px-6 py-8 cursor-pointer transition-colors", + "hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + )} + > + + {file ? ( +
+ + {file.name} + · {formatBytes(file.size)} +
+ ) : ( + <> +
Drop a file here, or click to choose
+
.txt — the X12 837/835 file as exported from your clearinghouse
+ + )} + pickFile(e.target.files?.[0] ?? null)} /> +
+ +
+
+ {api.isConfigured ? ( + + + Backend ready · {api.baseUrl} + + ) : ( + + + No backend configured — set VITE_API_BASE_URL to enable parsing + + )} +
+
+ {file ? ( + + ) : null} + +
+
+
+
+ + {(parseMutation.isPending || stream.items.length > 0) ? ( + + +
+
+ + {parseMutation.isPending ? ( + + ) : ( + + )} + {kind === "837p" ? "Claims" : "Claim payments"} + +

+ {totalSoFar} of {stream.expectedTotal ?? "?"} parsed + {stream.failed > 0 ? ` · ${stream.failed} failed validation` : ""} +

+
+
+
{stream.expectedTotal ? `${progressPct}%` : "…"}
+
progress
+
+
+
+
+
+ + +
+ {stream.items.length === 0 && parseMutation.isPending ? ( +
Waiting for first claim…
+ ) : null} + {stream.items.map((item, i) => + item.kind === "837p" ? ( + + ) : ( + + ) + )} +
+
+ + ) : null} + + {parsedBatches.length > 0 ? ( + + + Recent batches +

Parsed files, newest first.

+
+ +
    + {parsedBatches.map((b) => ( +
  • + {b.kind === "837p" ? "837P" : "835"} +
    +
    {b.inputFilename}
    +
    {fmt.dateShort(b.parsedAt)} · {b.claimCount} {b.kind === "837p" ? "claims" : "payments"}
    +
    +
    +
    {b.passed} / {b.claimCount}
    +
    passed
    +
    +
  • + ))} +
+
+
+ ) : null} +
+ ); +} +``` + +- [ ] **Step 2: Verify build + commit** + +```bash +npm run build +git add src/pages/Upload.tsx +git commit -m "feat(frontend): refactor Upload page to use useParse mutation" +``` + +--- + +### Task 27: Run frontend smoke (build + dev server check) + +- [ ] **Step 1: Build** + +Run: `npm run build` +Expected: passes (no TS errors, no unused-import warnings). + +- [ ] **Step 2: Type-check** + +Run: `npm run typecheck` +Expected: passes. + +- [ ] **Step 3: Commit any fixups (likely none)** + +```bash +git status # should be clean +``` + +--- + +## Phase 3 — Docs + +### Task 28: `docs/reference/837p.md` + +**Files:** +- Create: `docs/reference/837p.md` + +- [ ] **Step 1: Create the file** + +```markdown +# 837P — Professional Claims (005010X222A1) + +The 837P transaction set carries professional (outpatient) healthcare claims +from a billing provider to a payer. Cyclone parses the segments it needs to +produce a structured `ClaimOutput` and validates against CO Medicaid rules. + +## File format + +- Extension: `.txt` +- Encoding: ASCII (UTF-8 also accepted) +- Delimiters (declared in `ISA`): `*` element, `:` component, `~` segment, `^` repetition + +## Envelope + +| Segment | Purpose | +|---|---| +| `ISA` / `IEA` | Interchange envelope (sender ↔ receiver) | +| `GS` / `GE` | Functional group envelope | +| `ST` / `SE` | Transaction set envelope (837) | + +## Loops + +| Loop | Contents | +|---|---| +| 2000A | Billing provider hierarchy (NM1*85) | +| 2000B | Subscriber hierarchy (NM1*IL) | +| 2300 | Claim (CLM, HI, NM1, DTP, REF…) | +| 2400 | Service line (LX, SV1, DTP) | + +## Segments Cyclone parses + +- `NM1`, `N3`, `N4` — names and addresses +- `REF` — prior auth (`REF*G1`), provider taxonomy, etc. +- `CLM` — claim header; `CLM05` carries total charge + place-of-service + frequency +- `HI` — diagnoses (qualifier `ABK` = ICD-10 principal) +- `LX`, `SV1` — service line + procedure code +- `DTP` — service date +- `BHT` — beginning of hierarchical transaction + +## Segments preserved but not modeled + +All other segments are kept in `raw_segments` for audit but are not extracted +into the structured `ClaimOutput`. See `cyclone/parsers/parse_837.py` for the +full walker. + +## CO Medicaid specifics + +- Trading partner: `SKCO0` (sender) ↔ `COHCPF` (receiver) on `NM1*PR` / `NM1*40` +- `CLM05` shape: `::` + - Place-of-service: any valid CMS POS code + - Frequency code: must be one of `{1, 7, 8}` (1 = original, 7 = replacement, 8 = void) +- `REF*G1` carries prior-authorization number when applicable +- No 2010BA/2010CA patient loop — subscriber is the patient +- `CLM02` (provider signature on file) and `CLM07` (assignment of benefits) are typically `Y` + +## Validation rules Cyclone enforces + +| Rule | Severity | Description | +|---|---|---| +| `R001_frequency_allowed` | error | `CLM05-3` ∈ `{1, 7, 8}` | +| `R050_diagnosis_present` | error | At least one ICD-10 `HI*ABK` | +| `R100_provider_npi` | error | Billing provider NPI present and 10 digits | +| `R200_service_line_charge` | error | At least one `SV1` with non-zero charge | +| `R201_service_date` | warning | `DTP*472` present on every service line | +| `R300_subscriber_present` | error | `NM1*IL` (subscriber) present | +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/reference/837p.md +git commit -m "docs: add 837p reference note" +``` + +--- + +### Task 29: `docs/reference/835.md` + +**Files:** +- Create: `docs/reference/835.md` + +- [ ] **Step 1: Create the file** + +```markdown +# 835 — Electronic Remittance Advice (005010X221A1) + +The 835 transaction set carries payment and remittance information from a +payer (or its clearinghouse) to a provider, in response to previously +submitted 837P claims. Cyclone parses it into a `ParseResult835` and surfaces +the per-claim payment breakdown. + +## File format + +Same as 837P — ASCII text with the same four delimiters declared in `ISA`. + +## Envelope + +Same as 837P (`ISA/IEA/GS/GE/ST/SE`). + +## Loops + +| Loop | Contents | +|---|---| +| 1000A | Payer (N1*PR) | +| 1000B | Payee (N1*PE) | +| 2100 | Claim payment (CLP) | +| 2110 | Service payment (SVC) | + +## Header segments + +- `BPR` — financial information (credit/debit flag, total paid amount, account details) +- `TRN` — reassociation trace number +- `DTM` — production date + +## CLP — claim payment + +`CLP01` = claim submitter's ID (patient control number); `CLP02` = status code. + +| CLP02 | Meaning | +|---|---| +| 1 | Processed as Primary | +| 2 | Processed as Secondary | +| 3 | Processed as Tertiary | +| 4 | Denied | +| 19 | Processed as Primary, Forwarded | +| 20 | Processed as Secondary, Forwarded | +| 21 | Reversal of Previous Payment | +| 22 | Reversal of Previous Payment (Secondary) | +| 23 | Not Our Claim, Forwarded | +| 25 | Predetermination Pricing Only - No Payment | + +## Critical balancing rules + +Cyclone enforces (or flags) the following: + +- `BPR02 == sum(CLP04)` — total paid equals sum of all claim paid amounts +- `CLP04 == sum(SVC03)` — per-claim paid equals sum of per-service paid + +## CAS — claim/service adjustment + +`CAS` segments carry reason codes (`CO` contractual obligation, `PR` patient +responsibility, `OA` other adjustments, `PI` payer-initiated reduction). +Cyclone preserves the raw `CAS` text in `raw_segments`; deep-parsing of +reason codes is sub-project 3. + +## CO Medicaid specifics + +- `BPR10` = `"81-1725341"` (TXIX / Medicaid) or `"84-0644739"` (BHA / behavioral health) +- `N1*PR` `N104` = `"7912900843"` — payer ID +- `N1*PR` `N102` = `"CO_TXIX"` or `"CO_BHA"` — payer name +- Allowed `CLP02` codes: `{1, 2, 3, 4, 19, 20, 21, 22, 23, 25}` + +## Reconciliation (sub-project 2) + +Matching 835 payouts back to 837P submissions by patient control number + +date of service is deferred to sub-project 2. Cyclone currently stores the +835 output as-is. +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/reference/835.md +git commit -m "docs: add 835 reference note" +``` + +--- + +### Task 30: `docs/reference/x12naming.md` + +**Files:** +- Create: `docs/reference/x12naming.md` + +- [ ] **Step 1: Create the file** + +```markdown +# X12 naming conventions + +A short glossary of the names and indices used throughout Cyclone's parsers. + +## Segments + +Two- or three-letter codes that identify a row type. The codes are mnemonic: +`CLM` = claim, `NM1` = name, `BPR` = beginning segment for payment +order/remittance advice, `SVC` = service, `DTM`/`DTP` = date/time/period. + +## Elements + +Inside a segment, elements are separated by `*` and are 1-indexed. So +`CLM01` is the first element of the `CLM` segment — typically the patient +control number on 837P. + +## Composite elements + +A single element can carry sub-fields, separated by `:`. The sub-fields are +also 1-indexed, suffixed to the element index: + +- `CLM05-1` = total charge +- `CLM05-2` = place of service +- `CLM05-3` = frequency code + +In the Python code, this is `clm.claim.total_charge` (alias) and +`clm.claim.place_of_service` / `clm.claim.frequency_code`. + +## Loops + +Four-digit numeric IDs, hierarchical: + +- 2000 (Billing/Subscriber hierarchy) contains + - 2300 (Claim) which contains + - 2400 (Service line) + +In `cyclone.parsers`, the `parse_837` walker descends through these loops +explicitly. + +## Common qualifiers Cyclone cares about + +| Qualifier | Meaning | Where | +|---|---|---| +| `ABK` | ICD-10 principal diagnosis | `HI01-1` | +| `ABF` | ICD-10 diagnosis | `HI01-1` | +| `B` | Place of service | `CLM05-2` | +| `MC` | Medicaid (claim filing indicator) | 835 `CLP06` | +| `PR` | Payer | 835 `N1*PR` | +| `PE` | Payee | 835 `N1*PE` | +| `G1` | Prior authorization | 837P `REF01` | +| `TJ` | Federal taxpayer ID | 837P `REF01` (rendering provider TIN) | +| `1` | Original claim | 837P `CLM05-3` | +| `7` | Replacement claim | 837P `CLM05-3` | +| `8` | Void/cancel claim | 837P `CLM05-3` | + +## The four delimiters + +All four are declared in the `ISA` segment (positions 103–106 in the ISA +fixed-width header) and reused throughout the file. Cyclone's tokenizer +in `cyclone/parsers/segments.py` reads the ISA first, then splits the +rest of the file by those characters. + +- `*` — element separator +- `:` — component (sub-element) separator +- `~` — segment terminator +- `^` — repetition separator +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/reference/x12naming.md +git commit -m "docs: add x12 naming reference note" +``` + +--- + +### Task 31: `docs/reference/co-medicaid.md` + +**Files:** +- Create: `docs/reference/co-medicaid.md` + +- [ ] **Step 1: Create the file** + +```markdown +# Colorado Medicaid — payer specifics + +Cyclone's CO Medicaid (`PayerConfig.co_medicaid()` and +`PayerConfig835.co_medicaid_835()`) factories encode the trading-partner +and code-set rules that the Colorado Department of Health Care Policy & +Financing (HCPF) requires. + +## Trading Partner IDs (TPID) + +| Role | 837P | 835 | +|---|---|---| +| Sender | `SKCO0` | (varies; usually the clearinghouse) | +| Receiver | `COHCPF` | (varies) | + +These appear in the `NM1*PR` (payer) and `NM1*40` (receiver) segments of +the 837P file. + +## Payer IDs + +### 837P (claims) + +- `NM1*PR N104 = "SKCO0"` (COHCPF) + +### 835 (remittance) + +- `BPR10 = "81-1725341"` — TXIX (Medicaid) account +- `BPR10 = "84-0644739"` — BHA (behavioral health) account +- `N1*PR N104 = "7912900843"` — payer ID +- `N1*PR N102` is one of `"CO_TXIX"` or `"CO_BHA"` + +## Allowed codes + +### 837P `CLM05-3` (frequency code) + +| Code | Meaning | +|---|---| +| 1 | Original claim | +| 7 | Replacement claim | +| 8 | Void / cancel | + +Cyclone raises `R001_frequency_allowed` if any other value appears. + +### 835 `CLP02` (claim status code) + +Allowed: `{1, 2, 3, 4, 19, 20, 21, 22, 23, 25}`. See the 835 reference +note for the meaning of each. + +### Place of service (POS) + +All 89 CMS POS codes are accepted. The canonical list lives in +`cyclone/parsers/payer.py` as `CMS_PLACE_OF_SERVICE_CODES` and is the +source of truth for validation and any UI dropdowns. + +## Validation rules Cyclone enforces + +See [837p.md](./837p.md#validation-rules-cyclone-enforces) and the +`cyclone.parsers.validator` / `cyclone.parsers.validator_835` modules. + +## Adding a new payer + +The pattern is: + +1. Add a `PayerConfig` (837P) and `PayerConfig835` (835) factory in + `cyclone/parsers/payer.py` — usually thin wrappers that override only + the differing fields (allowed codes, payer IDs). +2. Register the factory in the `PAYER_FACTORIES` / `PAYER_FACTORIES_835` + dicts in `cyclone/api.py` and `cyclone/cli.py`. +3. Add a smoke fixture under `backend/tests/fixtures/` (a real or + synthetic file matching the new payer's rules) and a parser test that + loads it. +4. Update this note + the 837p/835 notes with the new payer's specifics. +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/reference/co-medicaid.md +git commit -m "docs: add CO Medicaid reference note" +``` + +--- + +### Task 32: Root `README.md` rewrite + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Replace the file** + +Replace `README.md` (root) with: +```markdown +# Cyclone + +A self-hosted EDI claims management suite for a single billing office. +Parses 837P professional claims and 835 ERA remittances (X12 005010X222A1 +and 005010X221A1), with a local-only FastAPI backend and a React UI for +browsing, filtering, and inspecting the parsed data. + +Local-only on purpose: binds to `127.0.0.1`, no auth, no internet exposure. +Built for one operator, one machine, one trading partner (Colorado +Medicaid, currently). + +## Install + +```bash +# Backend (Python 3.11+) +cd backend +python -m venv .venv +.venv/bin/pip install -e '.[dev]' + +# Frontend (Node 20+) +cd .. +npm install +``` + +## Dev + +Two terminals: + +```bash +# Terminal 1 — backend +cd backend +.venv/bin/python -m cyclone serve +# (defaults to 127.0.0.1:8000; override with CYCLONE_PORT=...; reload with CYCLONE_RELOAD=1) + +# Terminal 2 — frontend +npm run dev +# (Vite on http://localhost:5173) +``` + +Then open `http://localhost:5173`. Drop an `.txt` 837P or 835 file on the +Upload page; navigate to Claims, Remittances, Providers, or Activity to +see the parsed data. + +The frontend reads its backend URL from `VITE_API_BASE_URL` (default +empty). Create a `.env.local` at the repo root with: + +``` +VITE_API_BASE_URL=http://127.0.0.1:8000 +``` + +Without that, the UI falls back to its in-memory sample store via the +existing `data` adapter (parses are disabled). + +## Test + +```bash +# Backend +cd backend && .venv/bin/pytest + +# Frontend type-check + build +npm run typecheck +npm run build + +# Frontend unit tests (when added) +npm test +``` + +## Project layout + +``` +. +├── backend/ +│ ├── src/cyclone/ +│ │ ├── api.py # FastAPI app, GET + parse routes +│ │ ├── store.py # InMemoryStore, mappers +│ │ ├── __main__.py # `python -m cyclone serve` +│ │ ├── cli.py # click CLI +│ │ └── parsers/ # X12 tokenizer, models, validator, writers +│ └── tests/ +│ ├── fixtures/ # co_medicaid_*.txt, minimal_*.txt +│ ├── test_api.py # parse-837/835 round-trip +│ ├── test_api_gets.py # 6 GET endpoints +│ ├── test_store.py # store + mappers + iterators +│ ├── test_api_streaming.py +│ └── test_api_parse_persists.py +├── src/ # React + Vite + TypeScript UI +│ ├── components/ +│ ├── pages/ # Claims, Remittances, Providers, Activity, Upload +│ ├── hooks/ # react-query wrappers +│ ├── lib/ # api.ts, format.ts, utils.ts +│ ├── store/ # zustand sample-data store +│ └── types/ # shared TS types +├── docs/ +│ ├── reference/ # condensed 837P/835/X12/CO Medicaid notes +│ └── prodfiles/ # sample EDI files (anonymized) +└── package.json +``` + +## Roadmap + +This is **sub-project 1 of 4**. The other three are not in this build: + +- **Sub-project 2 — DB + reconciliation.** Replace the in-memory store + with SQLite/Postgres; add 837P ↔ 835 reconciliation matching payouts to + original claims. +- **Sub-project 3 — More 837P/835 features.** Additional 837P validation + rules (REF*G1 enforcement, BHT06), 835 CAS deep-parsing, 999 ACK, and + 270/271 eligibility. +- **Sub-project 4 — Frontend features.** Per-claim detail drawer, batch + diff view, real-time streaming of NDJSON GET responses, advanced + filters (date range, multi-status, saved filter sets), keyboard-driven + navigation. + +## License + +No license file yet; this is internal-use software. Add a `LICENSE` file +when one is decided. +``` + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: rewrite root README for sub-project 1" +``` + +--- + +## Phase 4 — End-to-end smoke + +### Task 33: End-to-end smoke test (manual + documented) + +- [ ] **Step 1: Start the backend** + +Terminal 1: `cd backend && .venv/bin/python -m cyclone serve` +Expected: log line "Uvicorn running on http://127.0.0.1:8000". + +- [ ] **Step 2: Confirm loopback-only** + +Terminal 2: `curl -s http://127.0.0.1:8000/api/health | jq .` → `{"status": "ok", "version": "0.1.0"}`. +Then: `curl -s http://0.0.0.0:8000/api/health` → connection refused. + +- [ ] **Step 3: Start the frontend** + +Terminal 3: `npm run dev` +Expected: "Local: http://localhost:5173". + +- [ ] **Step 4: Drop a fixture file and verify live data flow** + +1. Open `http://localhost:5173/upload` in a browser. +2. Drop `backend/tests/fixtures/co_medicaid_837p.txt` on the upload zone. +3. Click **Parse**. Expect: 2 cards stream in; success toast appears. +4. Navigate to `/claims`. Expect: 2 rows, both `submitted`. +5. Navigate to `/remittances`. Expect: empty state "Remittances · awaiting first 835". +6. Navigate to `/providers`. Expect: 1 provider card, 2 claims. +7. Navigate to `/activity`. Expect: 2 events, both `claim_submitted`. +8. Apply `Status: submitted` filter on `/claims`. Expect: the same 2 rows; the `FilterChips` row shows "Status: submitted ×". +9. Watch the top of `
` while navigating back to `/upload` and re-parsing — the 1px scan-line indicator should appear briefly. + +- [ ] **Step 5: Verify NDJSON streaming** + +Terminal 2: `curl -s -N -H 'Accept: application/x-ndjson' http://127.0.0.1:8000/api/claims | head -5` +Expected: 2 lines of `{"type":"item",…}` + 1 line of `{"type":"summary",…}`. + +- [ ] **Step 6: Stop the backend and verify the UI reverts to the fallback** + +Kill terminal 1 (`Ctrl+C`). Refresh the browser. Expect: the pages still +render (using the zustand fallback via the existing `data` adapter), but +the upload page shows "No backend configured". + +- [ ] **Step 7: Final commit (likely none)** + +```bash +git status # should be clean +``` + +--- + +## Self-review (against the spec) + +**Spec coverage** — every section/requirement in the design spec maps to at least one task: + +| Spec section | Covered by | +|---|---| +| §2 Goals 1: in-memory store | T1, T2, T3 | +| §2 Goals 2: GET endpoints with filter/sort/pagination, NDJSON | T5, T6, T7, T8 | +| §2 Goals 3: react-query wiring | T11, T12, T20, T21, T22, T23, T24, T25, T26 | +| §2 Goals 4: 4 reference notes + root README | T28, T29, T30, T31, T32 | +| §2 Goals 5: bind 127.0.0.1:8000 | T9 | +| §3 Non-goals | Not implemented (intentional) | +| §6.1 `cyclone.store` module | T1, T2, T3 | +| §6.2 6 GET routes | T5, T6, T7 | +| §6.2 NDJSON streaming | T8 | +| §6.2 Status mapping (837P submitted/pending/denied; 835 received/reconciled) | T2 | +| §6.3 parse-837/parse-835 → store.add() | T4 | +| §6.4 uvicorn bind | T9 | +| §7.1 @tanstack/react-query dep | T11 | +| §7.2 QueryClientProvider | T12 | +| §7.3 `lib/api.ts` GET methods | T20 | +| §7.4 hooks | T21 | +| §7.5 page refactors | T22, T23, T24, T25, T26 | +| §7.6 Skeleton primitive | T14 | +| §7.7.1–7.7.8 live-data UX | T13 (keyframes), T15 (Empty), T16 (Error), T17 (Chips), T18 (Pagination), T19 (refetch indicator), T22–T26 (row flash) | +| §8 docs (4 notes + README) | T28–T32 | +| §10 testing (~23 backend, 3 frontend) | T1–T8 (backend), T20 (frontend) | +| §11 migration/rollout | T9 (uvicorn bind) + T4 (store populated on parse) | +| §13 acceptance checklist | T33 (manual) | +| §13.1 visual / aesthetic checks | T13, T14, T15, T16, T17, T18, T19, T22–T26 | + +**No placeholders.** No "TBD", "TODO", "implement later", or vague "add +appropriate error handling" — every step has concrete code. + +**Type consistency.** 837P statuses `draft|submitted|accepted|denied|paid|pending` +and 835 statuses `received|posted|reconciled` appear consistently across +T2, T21, T22, T23, T25, T26 and the 7.7.7 spec section. Color tokens +(`success|accent|warning|destructive|muted`) match the existing Tailwind +config. Keyframe names `shimmer|scan|row-flash` are defined once in T13 +and referenced consistently in T14, T19, T22, T23, T25. + +**File path accuracy.** All paths verified against the current repo: +`backend/src/cyclone/store.py` (new), `backend/src/cyclone/api.py` (modify), +`backend/src/cyclone/__main__.py` (modify), `src/main.tsx` (modify), +`src/components/Layout.tsx` (modify), `src/lib/api.ts` (modify), +`src/pages/{Claims,Remittances,Providers,ActivityLog,Upload}.tsx` (modify), +`docs/reference/{837p,835,x12naming,co-medicaid}.md` (new), `README.md` (modify). + +--- + +## Execution + +This plan is ready for the **subagent-driven-development** skill. Each +backend task is small enough to fit in a single subagent (≤ 30 minutes); +the frontend tasks are similarly scoped. The docs tasks (T28–T32) are +mechanical markdown and can be dispatched in parallel from a single +subagent. + +Approximate effort (subagent wall-clock): +- Phase 1 (backend): 6 subagents, ~90 min total with parallelism +- Phase 2 (frontend): 8 subagents, ~120 min total +- Phase 3 (docs): 1 subagent, ~20 min +- Phase 4 (smoke): manual, ~10 min + +Total: ~4 hours of subagent work + review. + +**Review checkpoints** (built into the subagent-driven-development skill): +- After every 3 tasks, the orchestrator runs `requesting-code-review` on + the cumulative diff. +- After Phase 1 completes, run the full backend test suite as a gate + before starting Phase 2. +- After Phase 2 completes, run `npm run build` as a gate before starting + Phase 3. +- After Phase 3, run the T33 end-to-end smoke as the final acceptance.