Files
cyclone/docs/superpowers/plans/2026-06-19-cyclone-production-readiness.md
T

4239 lines
138 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (`- [x]`) 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` | `<Skeleton variant="row" \| "card" \| "kpi" />` with hairline + scanline-shimmer |
| `src/components/ui/empty-state.tsx` | `<EmptyState eyebrow="…" message="…" />` — all-caps eyebrow, hairline-circle icon |
| `src/components/ui/error-state.tsx` | `<ErrorState error={err} onRetry={fn} />` — destructive-hairline block + retry |
| `src/components/ui/filter-chips.tsx` | `<FilterChips active={…} onRemove={…} onClearAll={…} />` |
| `src/components/ui/pagination.tsx` | `<Pagination page={n} total={n} onChange={fn} />` 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 `<App />` in `<QueryClientProvider>` |
| `src/components/Layout.tsx` | Add `useIsFetching()` refetch indicator at top of `<main>` |
| `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`
- [x] **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.
- [x] **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).
- [x] **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.
- [x] **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.
- [x] **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`
- [x] **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.
- [x] **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'`.
- [x] **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
> ```)
- [x] **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.
- [x] **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`
- [x] **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"
```
- [x] **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'`.
- [x] **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]
```
- [x] **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).
- [x] **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`
- [x] **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
```
- [x] **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).
- [x] **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.
- [x] **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.
- [x] **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)
- [x] **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
```
- [x] **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).
- [x] **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
```
- [x] **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.
- [x] **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`
- [x] **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
```
- [x] **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`.
- [x] **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),
}
```
- [x] **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.
- [x] **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`
- [x] **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"])
```
- [x] **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.
- [x] **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,
}
```
- [x] **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.
- [x] **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`
- [x] **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
```
- [x] **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.
- [x] **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).
- [x] **Step 4: Run test to verify it passes**
Run: `cd backend && .venv/bin/pytest tests/test_api_streaming.py -v`
Expected: test passes.
- [x] **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`
- [x] **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()
```
- [x] **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'`).
- [x] **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
- [x] **Step 1: Run all backend tests**
Run: `cd backend && .venv/bin/pytest -v`
Expected: all tests pass (existing 121 + new ~23 = 144+ total).
- [x] **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.
- [x] **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`
- [x] **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.
- [x] **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 `<App />` in `<QueryClientProvider>`
**Files:**
- Modify: `src/main.tsx`
- [x] **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(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
);
```
- [x] **Step 2: Verify build still passes**
Run: `npm run build`
Expected: build succeeds with no TS errors.
- [x] **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`
- [x] **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" },
},
```
- [x] **Step 2: Commit**
```bash
git add tailwind.config.js
git commit -m "feat(frontend): add shimmer, scan, row-flash keyframes"
```
---
### Task 14: `<Skeleton />` UI primitive (spec 7.6)
**Files:**
- Create: `src/components/ui/skeleton.tsx`
- [x] **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 (
<div
className={cn(
"relative overflow-hidden rounded-md bg-muted/30",
"ring-1 ring-inset ring-border/40",
className
)}
aria-busy="true"
aria-live="polite"
>
<div
className="absolute inset-0 animate-shimmer"
style={{
background:
"linear-gradient(90deg, transparent 0%, hsl(var(--muted-foreground) / 0.08) 50%, transparent 100%)",
}}
/>
{variant === "row" ? (
<div className="grid grid-cols-12 gap-3 p-4">
<div className="col-span-2 h-3 bg-muted/60 rounded" />
<div className="col-span-3 h-3 bg-muted/60 rounded" />
<div className="col-span-2 h-3 bg-muted/60 rounded" />
<div className="col-span-2 h-3 bg-muted/60 rounded" />
<div className="col-span-2 h-3 bg-muted/60 rounded" />
<div className="col-span-1 h-3 bg-muted/60 rounded" />
</div>
) : variant === "card" ? (
<div className="p-6 space-y-3">
<div className="h-4 w-1/2 bg-muted/60 rounded" />
<div className="h-3 w-1/3 bg-muted/60 rounded" />
<div className="h-3 w-2/3 bg-muted/60 rounded" />
</div>
) : (
<div className="p-5 space-y-3">
<div className="h-3 w-1/3 bg-muted/60 rounded" />
<div className="h-6 w-1/2 bg-muted/60 rounded" />
</div>
)}
</div>
);
}
```
- [x] **Step 2: Verify build**
Run: `npm run build`
Expected: passes.
- [x] **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: `<EmptyState />` UI primitive (spec 7.7.2)
**Files:**
- Create: `src/components/ui/empty-state.tsx`
- [x] **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 (
<div className="flex flex-col items-center justify-center gap-2.5 py-14">
<div className="h-10 w-10 rounded-full ring-1 ring-inset ring-border/60 flex items-center justify-center text-muted-foreground">
<CircleDashed className="h-4 w-4" strokeWidth={1.5} />
</div>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{eyebrow}
</div>
<div className="text-sm text-muted-foreground/80">{message}</div>
</div>
);
}
```
- [x] **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: `<ErrorState />` UI primitive (spec 7.7.3)
**Files:**
- Create: `src/components/ui/error-state.tsx`
- [x] **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 (
<div className="surface rounded-xl border border-destructive/30 p-4 flex items-center gap-3">
<AlertCircle
className="h-4 w-4 text-destructive shrink-0"
strokeWidth={1.75}
/>
<div className="flex-1 min-w-0">
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-destructive/80">
Fetch failed
</div>
<div className="text-sm text-foreground/90 truncate">
{error.message}
</div>
</div>
<Button variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
</div>
);
}
```
- [x] **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: `<FilterChips />` UI primitive (spec 7.7.5)
**Files:**
- Create: `src/components/ui/filter-chips.tsx`
- [x] **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 (
<div className="flex flex-wrap items-center gap-2 mt-4 pt-4 border-t border-border/40">
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Active
</span>
{active.map((c) => (
<span
key={c.key}
className="inline-flex items-center gap-1.5 rounded-md border border-border/60 bg-muted/30 pl-2.5 pr-1 py-0.5 text-xs"
>
<span className="text-muted-foreground">{c.key}:</span>
<span className="font-medium">{c.label}</span>
<button
type="button"
onClick={() => onRemove(c.key)}
className="ml-0.5 h-4 w-4 rounded-sm hover:bg-muted flex items-center justify-center"
aria-label={`Remove ${c.key} filter`}
>
<X className="h-3 w-3" strokeWidth={1.75} />
</button>
</span>
))}
{active.length > 1 ? (
<button
type="button"
onClick={onClearAll}
className="ml-auto text-[11px] text-muted-foreground hover:text-foreground transition-colors"
>
Clear all
</button>
) : null}
</div>
);
}
```
- [x] **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: `<Pagination />` UI primitive (spec 7.7.8)
**Files:**
- Create: `src/components/ui/pagination.tsx`
- [x] **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 (
<div className="flex items-center justify-between gap-3 mt-4 pt-4 border-t border-border/40 text-xs">
<div className="text-muted-foreground num">
{returned} of {total}
</div>
<nav className="flex items-center gap-1" aria-label="Pagination">
<button
type="button"
onClick={() => onChange(Math.max(1, page - 1))}
disabled={page <= 1}
className="h-7 w-7 rounded-md hover:bg-muted/60 flex items-center justify-center text-muted-foreground disabled:opacity-40"
aria-label="Previous page"
>
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={1.75} />
</button>
{pages.map((p, i) =>
p === "…" ? (
<span key={`e-${i}`} className="px-1 text-muted-foreground">
</span>
) : (
<button
key={p}
type="button"
onClick={() => onChange(p)}
aria-current={p === page ? "page" : undefined}
className={cn(
"relative h-7 min-w-7 px-2 rounded-md num text-xs flex items-center justify-center transition-colors",
p === page
? "bg-muted/60 text-foreground"
: "hover:bg-muted/40 text-muted-foreground"
)}
>
{p === page ? (
<span
aria-hidden
className="absolute left-0 top-1 bottom-1 w-px bg-accent rounded"
/>
) : null}
{p}
</button>
)
)}
<button
type="button"
onClick={() => onChange(Math.min(last, page + 1))}
disabled={page >= last}
className="h-7 w-7 rounded-md hover:bg-muted/60 flex items-center justify-center text-muted-foreground disabled:opacity-40"
aria-label="Next page"
>
<ChevronRight className="h-3.5 w-3.5" strokeWidth={1.75} />
</button>
</nav>
</div>
);
}
```
- [x] **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`
- [x] **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 (
<div className="relative min-h-screen z-10">
{isFetching > 0 ? (
<div
className="fixed top-0 left-0 right-0 z-50 h-px overflow-hidden pointer-events-none"
aria-hidden
>
<div
className="h-full w-1/3 bg-accent animate-scan"
style={{ boxShadow: "0 0 8px hsl(var(--accent) / 0.5)" }}
/>
</div>
) : null}
<Sidebar />
<main className="md:pl-60">
<div className="mx-auto max-w-[1400px] px-8 py-10">
<Outlet />
</div>
</main>
</div>
);
}
```
- [x] **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)
- [x] **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<T> = {
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<string, unknown> | 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<BatchSummary[]> {
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<ParseResult837 | ParseResult835> {
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<T = unknown>(
params: ListClaimsParams
): Promise<PaginatedResponse<T>> {
const res = await fetch(joinUrl(`/api/claims${qs(params as Record<string, unknown>)}`));
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`);
}
return (await res.json()) as PaginatedResponse<T>;
}
async function listRemittances<T = unknown>(
params: ListRemittancesParams
): Promise<PaginatedResponse<T>> {
const res = await fetch(joinUrl(`/api/remittances${qs(params as Record<string, unknown>)}`));
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`);
}
return (await res.json()) as PaginatedResponse<T>;
}
async function listProviders<T = unknown>(
params: ListProvidersParams = {}
): Promise<PaginatedResponse<T>> {
const res = await fetch(joinUrl(`/api/providers${qs(params as Record<string, unknown>)}`));
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`);
}
return (await res.json()) as PaginatedResponse<T>;
}
async function listActivity<T = unknown>(
params: ListActivityParams = {}
): Promise<PaginatedResponse<T>> {
const res = await fetch(joinUrl(`/api/activity${qs(params as Record<string, unknown>)}`));
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(`${res.status} ${res.statusText}${detail ? `${detail}` : ""}`);
}
return (await res.json()) as PaginatedResponse<T>;
```
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"),
};
```
- [x] **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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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.
- [x] **Step 3: Run the tests**
Run: `npm test -- src/lib/api.test.ts`
Expected: 3 tests pass.
- [x] **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`
- [x] **Step 1: `useBatches.ts`**
```ts
import { useQuery } from "@tanstack/react-query";
import { api, type BatchSummary } from "@/lib/api";
export function useBatches() {
return useQuery<BatchSummary[]>({
queryKey: ["batches"],
queryFn: () => api.listBatches(),
enabled: api.isConfigured,
});
}
```
- [x] **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<Claim> = {
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<PaginatedResponse<Claim>>({
queryKey: ["claims", params],
queryFn: () => api.listClaims<Claim>(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<Claim>,
isLoading: false,
isError: false,
error: null,
refetch: () => Promise.resolve(),
} as const;
}
return q;
}
export { EMPTY as EMPTY_CLAIMS };
```
- [x] **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<PaginatedResponse<Remittance>>({
queryKey: ["remittances", params],
queryFn: () => api.listRemittances<Remittance>(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<Remittance>,
isLoading: false, isError: false, error: null,
refetch: () => Promise.resolve(),
} as const;
}
return q;
}
```
- [x] **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<PaginatedResponse<Provider>>({
queryKey: ["providers", params],
queryFn: () => api.listProviders<Provider>(params),
enabled: api.isConfigured,
});
if (!api.isConfigured) {
return {
data: { items: fallback, total: fallback.length, returned: fallback.length, has_more: false } satisfies PaginatedResponse<Provider>,
isLoading: false, isError: false, error: null,
refetch: () => Promise.resolve(),
} as const;
}
return q;
}
```
- [x] **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<PaginatedResponse<Activity>>({
queryKey: ["activity", params],
queryFn: () => api.listActivity<Activity>(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<Activity>,
isLoading: false, isError: false, error: null,
refetch: () => Promise.resolve(),
} as const;
}
return q;
}
```
- [x] **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"] });
},
});
}
```
- [x] **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`
- [x] **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<ClaimStatus | typeof ALL>(ALL);
const [npi, setNpi] = useState<string>(ALL);
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const searchRef = useRef<HTMLInputElement>(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 (
<div className="space-y-8 animate-fade-in">
<header className="flex items-end justify-between gap-6 flex-wrap">
<div>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
<span className="inline-block h-px w-6 bg-border" />
Claims
</div>
<h1 className="text-[22px] font-semibold tracking-tight">All claims</h1>
</div>
<NewClaimDialog />
</header>
{isError && error ? (
<ErrorState error={error as Error} onRetry={() => refetch()} />
) : null}
<div className="surface rounded-xl p-4">
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[240px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={searchRef}
placeholder="Search by claim ID, patient, payer, CPT…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 pr-16"
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground p-1.5 rounded-md"
aria-label="Clear search"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
<Select
value={status}
onValueChange={(v) => {
setStatus(v as ClaimStatus | typeof ALL);
setPage(1);
}}
>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{statuses.map((s) => (
<SelectItem key={s} value={s}>
{s === ALL ? "All statuses" : s[0]!.toUpperCase() + s.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={npi}
onValueChange={(v) => {
setNpi(v);
setPage(1);
}}
>
<SelectTrigger className="w-[240px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All providers</SelectItem>
{providers.map((p) => (
<SelectItem key={p.npi} value={p.npi}>
{p.npi} {p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<FilterChips
active={activeChips}
onRemove={clearFilter}
onClearAll={() => {
setStatus(ALL);
setNpi(ALL);
setPage(1);
}}
/>
<div className="flex items-center gap-6 mt-4 pt-4 border-t border-border/40 text-xs text-muted-foreground">
<span>
<span className="num text-foreground font-medium">
{fmt.num(data?.total ?? 0)}
</span>{" "}
claims
</span>
<span>
Billed{" "}
<span className="num text-foreground font-medium">
{fmt.usd(totals.billed)}
</span>
</span>
<span>
Received{" "}
<span className="num text-foreground font-medium">
{fmt.usd(totals.received)}
</span>
</span>
</div>
</div>
<div className="surface rounded-xl overflow-hidden">
{isLoading ? (
<div className="p-4 space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} variant="row" />
))}
</div>
) : items.length === 0 ? (
<EmptyState
eyebrow="Claims · inbox idle"
message={
status !== ALL || npi !== ALL
? "No claims match the current filters."
: "Drop an 837P file on the Upload page to populate this list."
}
/>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Claim</TableHead>
<TableHead>Patient</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Payer</TableHead>
<TableHead className="text-right">Billed</TableHead>
<TableHead className="text-right">Received</TableHead>
<TableHead>Status</TableHead>
<TableHead>Submitted</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map((c) => {
const provider = providerMap[c.providerNpi];
return (
<TableRow
key={c.id}
data-newer-than={c.parsedAt}
className="data-[newer-than]:animate-row-flash"
>
<TableCell>
<div className="display num text-[13px]">{c.id}</div>
<div className="text-[11px] text-muted-foreground num">
CPT {c.cptCode}
</div>
</TableCell>
<TableCell className="font-medium">{c.patientName}</TableCell>
<TableCell>
<div className="text-sm">{provider?.name ?? "Unknown"}</div>
<div className="text-[11px] text-muted-foreground num">
{c.providerNpi}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{c.payerName}
</TableCell>
<TableCell className="text-right display num">
{fmt.usd(c.billedAmount)}
</TableCell>
<TableCell className="text-right display num text-muted-foreground">
{c.receivedAmount > 0 ? fmt.usd(c.receivedAmount) : "—"}
</TableCell>
<TableCell>
<ClaimStatusBadge status={c.status} />
</TableCell>
<TableCell className="text-muted-foreground text-[13px] num">
{fmt.dateShort(c.submissionDate)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<div className="px-4 pb-4">
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={data?.total ?? 0}
onChange={setPage}
/>
</div>
</>
)}
</div>
</div>
);
}
```
- [x] **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`
- [x] **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 (
<div className="space-y-8 animate-fade-in">
<header>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
<span className="inline-block h-px w-6 bg-border" />
Remittances
</div>
<h1 className="text-[22px] font-semibold tracking-tight">835 remittances</h1>
<p className="text-muted-foreground mt-1.5 text-[14px]">
Electronic remittance advice from payers, matched to submitted claims.
</p>
</header>
{isError && error ? (
<ErrorState error={error as Error} onRetry={() => refetch()} />
) : null}
<div className="grid gap-4 grid-cols-2 lg:grid-cols-3">
<div className="surface rounded-xl p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
Remits
</div>
<div className="display text-[28px] leading-none mt-3">
{fmt.num(data?.total ?? 0)}
</div>
</div>
<div className="surface rounded-xl p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
Total paid
</div>
<div className="display text-[28px] leading-none mt-3">{fmt.usd(total.paid)}</div>
</div>
<div className="surface rounded-xl p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
Adjustments
</div>
<div className="display text-[28px] leading-none mt-3">{fmt.usd(total.adjustments)}</div>
</div>
</div>
<div className="surface rounded-xl overflow-hidden">
{isLoading ? (
<div className="p-4 space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} variant="row" />
))}
</div>
) : items.length === 0 ? (
<EmptyState
eyebrow="Remittances · awaiting first 835"
message="Drop an 835 file on the Upload page to populate this list."
/>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Remit</TableHead>
<TableHead>Claim</TableHead>
<TableHead>Payer</TableHead>
<TableHead className="text-right">Paid</TableHead>
<TableHead className="text-right">Adjustment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Received</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((r) => (
<TableRow
key={r.id}
data-newer-than={r.parsedAt}
className="data-[newer-than]:animate-row-flash"
>
<TableCell className="display num text-[13px]">{r.id}</TableCell>
<TableCell className="display num text-[13px] text-muted-foreground">
{r.claimId}
</TableCell>
<TableCell>{r.payerName}</TableCell>
<TableCell className="text-right display num">
{fmt.usdPrecise(r.paidAmount)}
</TableCell>
<TableCell className="text-right display num text-muted-foreground">
{fmt.usdPrecise(r.adjustmentAmount)}
</TableCell>
<TableCell>
<RemitStatusBadge status={r.status} />
</TableCell>
<TableCell className="text-muted-foreground num text-[13px]">
{fmt.dateShort(r.receivedDate)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="px-4 pb-4">
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={data?.total ?? 0}
onChange={setPage}
/>
</div>
</>
)}
</div>
</div>
);
}
```
- [x] **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`
- [x] **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 (
<div className="space-y-8 animate-fade-in">
<header>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
<span className="inline-block h-px w-6 bg-border" />
Providers
</div>
<h1 className="text-[22px] font-semibold tracking-tight">Provider directory</h1>
<p className="text-muted-foreground mt-1.5 text-[14px]">
NPIs registered with the clearinghouse for 837P submission and 835
remittance retrieval.
</p>
</header>
{isError && error ? (
<ErrorState error={error as Error} onRetry={() => refetch()} />
) : null}
{isLoading ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} variant="card" />
))}
</div>
) : items.length === 0 ? (
<div className="surface rounded-xl">
<EmptyState
eyebrow="Providers · directory empty"
message="Providers appear here as soon as an 837P file is parsed."
/>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{items.map((p) => (
<article
key={p.npi}
data-newer-than={undefined}
className="surface rounded-xl p-6 flex flex-col gap-4 hover:bg-muted/20 transition-colors"
>
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-lg bg-muted/60 flex items-center justify-center text-foreground">
<Building2 className="h-5 w-5" strokeWidth={1.5} />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-[15px] font-semibold tracking-tight">
{p.name}
</h3>
<p className="text-[11px] text-muted-foreground num mt-0.5">
NPI {p.npi} · TIN {p.taxId}
</p>
</div>
</div>
<div className="space-y-1.5 text-[13px] text-muted-foreground">
<div className="flex items-center gap-2">
<MapPin className="h-3.5 w-3.5" strokeWidth={1.75} />
<span>
{p.address}, {p.city}, {p.state} {p.zip}
</span>
</div>
<div className="flex items-center gap-2">
<Phone className="h-3.5 w-3.5" strokeWidth={1.75} />
<span className="num">{p.phone}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-border/40">
<div>
<div className="text-[11px] uppercase tracking-wider text-muted-foreground">
Claims
</div>
<div className="display text-[20px] leading-none mt-1.5">
{fmt.num(p.claimCount)}
</div>
</div>
<div>
<div className="text-[11px] uppercase tracking-wider text-muted-foreground">
Outstanding AR
</div>
<div className="display text-[20px] leading-none mt-1.5">
{fmt.usd(p.outstandingAr)}
</div>
</div>
</div>
</article>
))}
</div>
)}
</div>
);
}
```
- [x] **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`
- [x] **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 (
<div className="space-y-8 animate-fade-in">
<header>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
<span className="inline-block h-px w-6 bg-border" />
Activity
</div>
<h1 className="text-[22px] font-semibold tracking-tight">Activity log</h1>
<p className="text-muted-foreground mt-1.5 text-[14px]">
Every claim submission, denial, payment, and provider event, in
reverse chronological order. Auto-refreshes every 30s.
</p>
</header>
{isError && error ? (
<ErrorState error={error as Error} onRetry={() => refetch()} />
) : null}
<div className="surface rounded-xl p-6">
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} variant="row" />
))}
</div>
) : items.length === 0 ? (
<EmptyState
eyebrow="Activity · log idle"
message="Activity will appear here after the first parse."
/>
) : (
<ActivityFeed items={items} emptyMessage="No activity recorded yet." />
)}
</div>
</div>
);
}
```
- [x] **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`
- [x] **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 (
<span className="inline-flex items-center gap-1.5 text-[11px] text-[hsl(var(--success))] font-medium">
<CheckCircle2 className="h-3.5 w-3.5" strokeWidth={1.75} />
Passed
</span>
);
if (hasWarnings)
return (
<span className="inline-flex items-center gap-1.5 text-[11px] text-[hsl(var(--warning))] font-medium">
<AlertTriangle className="h-3.5 w-3.5" strokeWidth={1.75} />
Warnings
</span>
);
return (
<span className="inline-flex items-center gap-1.5 text-[11px] text-destructive font-medium">
<XCircle className="h-3.5 w-3.5" strokeWidth={1.75} />
Failed
</span>
);
}
function StatPill({ label, value, mono = true }: { label: string; value: React.ReactNode; mono?: boolean }) {
return (
<div className="flex flex-col gap-0.5">
<div className="text-[10.5px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">{label}</div>
<div className={cn("text-[13px] text-foreground", mono && "display num")}>{value}</div>
</div>
);
}
function ClaimCard837({ claim }: { claim: ClaimOutput }) {
const [open, setOpen] = useState(false);
const passed = claim.validation.passed;
const hasWarnings = claim.validation.warnings.length > 0;
return (
<div className="surface rounded-lg overflow-hidden">
<button type="button" onClick={() => setOpen((v) => !v)}
className="w-full text-left px-4 py-3 hover:bg-muted/30 transition-colors" aria-expanded={open}>
<div className="flex items-center gap-3">
<ChevronRight className={cn("h-3.5 w-3.5 text-muted-foreground transition-transform shrink-0", open && "rotate-90")} strokeWidth={1.75} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 flex-wrap">
<span className="display num text-[13px]">{claim.claim_id}</span>
<span className="text-[12px] text-muted-foreground truncate">{claim.subscriber.first_name} {claim.subscriber.last_name}</span>
<span className="text-[12px] text-muted-foreground">· {claim.payer.name}</span>
</div>
<div className="flex items-center gap-3 mt-1 text-[11px] text-muted-foreground num">
<span>NPI {claim.billing_provider.npi}</span>
<span>·</span>
<span>{claim.service_lines.length} line{claim.service_lines.length === 1 ? "" : "s"}</span>
<span>·</span>
<span>{claim.diagnoses.length} dx</span>
</div>
</div>
<div className="text-right shrink-0">
<div className="display num text-[14px]">{fmt.usdDecimal(claim.claim.total_charge)}</div>
<div className="mt-0.5"><ValidationDot passed={passed} hasWarnings={hasWarnings} /></div>
</div>
</div>
</button>
{open ? (
<div className="border-t border-border/40 px-4 py-3 grid gap-4 bg-muted/20 animate-fade-in">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatPill label="Member" value={claim.subscriber.member_id} />
<StatPill label="Place of service" value={claim.claim.place_of_service ?? "—"} />
<StatPill label="Frequency" value={claim.claim.frequency_code ?? "—"} />
<StatPill label="Prior auth" value={claim.claim.prior_auth ?? "—"} />
</div>
</div>
) : null}
</div>
);
}
function ClaimCard835({ claim }: { claim: ClaimPayment }) {
const [open, setOpen] = useState(false);
const passed = claim.service_payments.length > 0;
return (
<div className="surface rounded-lg overflow-hidden">
<button type="button" onClick={() => setOpen((v) => !v)}
className="w-full text-left px-4 py-3 hover:bg-muted/30 transition-colors" aria-expanded={open}>
<div className="flex items-center gap-3">
<ChevronRight className={cn("h-3.5 w-3.5 text-muted-foreground transition-transform shrink-0", open && "rotate-90")} strokeWidth={1.75} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 flex-wrap">
<span className="display num text-[13px]">{claim.payer_claim_control_number}</span>
<span className="text-[12px] text-muted-foreground">{claim.status_label ?? `Status ${claim.status_code}`}</span>
</div>
<div className="flex items-center gap-3 mt-1 text-[11px] text-muted-foreground num">
<span>CLP {claim.status_code}</span>
<span>·</span>
<span>{claim.service_payments.length} svc</span>
</div>
</div>
<div className="text-right shrink-0">
<div className="display num text-[14px] text-[hsl(var(--success))]">{fmt.usdDecimal(claim.total_paid)}</div>
<div className="text-[11px] text-muted-foreground num">of {fmt.usdDecimal(claim.total_charge)}</div>
<div className="mt-0.5"><ValidationDot passed={passed} /></div>
</div>
</div>
</button>
</div>
);
}
export function Upload() {
const inputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [kind, setKind] = useState<ParsedBatchKind>("837p");
const [payer, setPayer] = useState<string>(PAYERS_837[0]!.value);
const [stream, setStream] = useState<StreamState>({
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 (
<div className="space-y-8 animate-fade-in">
<header>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
<span className="inline-block h-px w-6 bg-border" />
Upload · EDI parser
</div>
<h1 className="text-[22px] font-semibold tracking-tight">Parse an X12 file</h1>
<p className="text-muted-foreground mt-1.5 text-[14px] max-w-2xl">
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.
</p>
</header>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-[14px] flex items-center gap-2">
<UploadIcon className="h-3.5 w-3.5 text-muted-foreground" strokeWidth={1.75} />
File
</CardTitle>
<CardDescription>
.txt (X12 837P or 835). The file is sent to the FastAPI backend configured by <code className="text-[12px]">VITE_API_BASE_URL</code>.
</CardDescription>
</CardHeader>
<CardContent className="pt-0 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="grid gap-1.5">
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Kind</div>
<Select value={kind} onValueChange={(v) => {
const next = v as ParsedBatchKind;
setKind(next);
setPayer(next === "837p" ? PAYERS_837[0]!.value : PAYERS_835[0]!.value);
}}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="837p">837P (Professional claim)</SelectItem>
<SelectItem value="835">835 (ERA remittance)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Payer config</div>
<Select value={payer} onValueChange={setPayer}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{payerOptions.map((p) => (
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div
onDragOver={(e) => 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"
)}
>
<UploadIcon className="h-5 w-5 text-muted-foreground" strokeWidth={1.5} />
{file ? (
<div className="flex items-center gap-2 text-sm">
<FileText className="h-3.5 w-3.5 text-muted-foreground" strokeWidth={1.75} />
<span className="font-medium">{file.name}</span>
<span className="text-muted-foreground num">· {formatBytes(file.size)}</span>
</div>
) : (
<>
<div className="text-sm font-medium">Drop a file here, or click to choose</div>
<div className="text-[12px] text-muted-foreground">.txt the X12 837/835 file as exported from your clearinghouse</div>
</>
)}
<input ref={inputRef} type="file" accept=".txt" className="sr-only"
onChange={(e) => pickFile(e.target.files?.[0] ?? null)} />
</div>
<div className="flex items-center justify-between gap-3">
<div className="text-[12px] text-muted-foreground">
{api.isConfigured ? (
<span className="inline-flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-[hsl(var(--success))]" />
Backend ready · {api.baseUrl}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-[hsl(var(--warning))]">
<AlertTriangle className="h-3 w-3" strokeWidth={1.75} />
No backend configured set VITE_API_BASE_URL to enable parsing
</span>
)}
</div>
<div className="flex items-center gap-2">
{file ? (
<Button variant="ghost" onClick={() => pickFile(null)} disabled={parseMutation.isPending}>Clear</Button>
) : null}
<Button onClick={onParse} disabled={!file || parseMutation.isPending}>
{parseMutation.isPending ? (
<><Loader2 className="h-3.5 w-3.5 animate-spin" /> Parsing</>
) : (
<><UploadIcon className="h-3.5 w-3.5" /> Parse</>
)}
</Button>
</div>
</div>
</CardContent>
</Card>
{(parseMutation.isPending || stream.items.length > 0) ? (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-[14px] flex items-center gap-2">
{parseMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" strokeWidth={1.75} />
) : (
<CheckCircle2 className="h-3.5 w-3.5 text-[hsl(var(--success))]" strokeWidth={1.75} />
)}
{kind === "837p" ? "Claims" : "Claim payments"}
</CardTitle>
<p className="text-xs text-muted-foreground mt-1">
{totalSoFar} of {stream.expectedTotal ?? "?"} parsed
{stream.failed > 0 ? ` · ${stream.failed} failed validation` : ""}
</p>
</div>
<div className="text-right">
<div className="display num text-[16px]">{stream.expectedTotal ? `${progressPct}%` : "…"}</div>
<div className="text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground">progress</div>
</div>
</div>
<div className="mt-3 h-1 w-full rounded-full bg-muted overflow-hidden">
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${progressPct}%` }} />
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="max-h-[640px] overflow-y-auto -mx-2 px-2 space-y-2">
{stream.items.length === 0 && parseMutation.isPending ? (
<div className="text-center py-8 text-muted-foreground text-sm">Waiting for first claim</div>
) : null}
{stream.items.map((item, i) =>
item.kind === "837p" ? (
<ClaimCard837 key={`837-${item.data.claim_id}-${i}`} claim={item.data} />
) : (
<ClaimCard835 key={`835-${item.data.payer_claim_control_number}-${i}`} claim={item.data} />
)
)}
</div>
</CardContent>
</Card>
) : null}
{parsedBatches.length > 0 ? (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-[14px]">Recent batches</CardTitle>
<p className="text-xs text-muted-foreground mt-1">Parsed files, newest first.</p>
</CardHeader>
<CardContent className="pt-0">
<ul className="divide-y divide-border/40">
{parsedBatches.map((b) => (
<li key={b.id} className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
<Badge variant={b.kind === "837p" ? "default" : "muted"}>{b.kind === "837p" ? "837P" : "835"}</Badge>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{b.inputFilename}</div>
<div className="text-[11px] text-muted-foreground num">{fmt.dateShort(b.parsedAt)} · {b.claimCount} {b.kind === "837p" ? "claims" : "payments"}</div>
</div>
<div className="text-right shrink-0">
<div className="display num text-[13px]">{b.passed} / {b.claimCount}</div>
<div className="text-[11px] text-muted-foreground">passed</div>
</div>
</li>
))}
</ul>
</CardContent>
</Card>
) : null}
</div>
);
}
```
- [x] **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)
- [x] **Step 1: Build**
Run: `npm run build`
Expected: passes (no TS errors, no unused-import warnings).
- [x] **Step 2: Type-check**
Run: `npm run typecheck`
Expected: passes.
- [x] **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`
- [x] **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: `<total_charge>:<place_of_service>:<frequency_code>`
- 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 |
```
- [x] **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`
- [x] **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.
```
- [x] **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`
- [x] **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 103106 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
```
- [x] **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`
- [x] **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.
```
- [x] **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`
- [x] **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.
```
- [x] **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)
- [x] **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".
- [x] **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.
- [x] **Step 3: Start the frontend**
Terminal 3: `npm run dev`
Expected: "Local: http://localhost:5173".
- [x] **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 `<main>` while navigating back to `/upload` and re-parsing — the 1px scan-line indicator should appear briefly.
- [x] **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",…}`.
- [x] **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".
- [x] **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.17.7.8 live-data UX | T13 (keyframes), T15 (Empty), T16 (Error), T17 (Chips), T18 (Pagination), T19 (refetch indicator), T22T26 (row flash) |
| §8 docs (4 notes + README) | T28T32 |
| §10 testing (~23 backend, 3 frontend) | T1T8 (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, T22T26 |
**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 (T28T32) 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.