# 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` | `` with hairline + scanline-shimmer | | `src/components/ui/empty-state.tsx` | `` — all-caps eyebrow, hairline-circle icon | | `src/components/ui/error-state.tsx` | `` — destructive-hairline block + retry | | `src/components/ui/filter-chips.tsx` | `` | | `src/components/ui/pagination.tsx` | `` with `nav-active` echo | | `src/hooks/useBatches.ts` | `useBatches()` | | `src/hooks/useClaims.ts` | `useClaims(params)` — falls back to zustand when `!api.isConfigured` | | `src/hooks/useRemittances.ts` | `useRemittances(params)` | | `src/hooks/useProviders.ts` | `useProviders(params)` | | `src/hooks/useActivity.ts` | `useActivity(params)` | | `src/hooks/useParse.ts` | `useParse(kind)` — `useMutation` that invalidates 5 query keys on success | | `src/lib/api.test.ts` | ~3 tests: listClaims URL, listBatches path, getBatch kind handling | ### Modified frontend files | File | Change | |---|---| | `package.json` | Add `@tanstack/react-query` ^5.59.0 | | `tailwind.config.js` | Add 3 keyframes (`shimmer`, `scan`, `row-flash`) | | `src/main.tsx` | Wrap `` in `` | | `src/components/Layout.tsx` | Add `useIsFetching()` refetch indicator at top of `
` | | `src/lib/api.ts` | Add `listBatches`, `getBatch`, `listClaims`, `listRemittances`, `listProviders`, `listActivity`; replace `legacyNotImplemented` for those with real fetches | | `src/pages/Claims.tsx` | Use `useClaims`; add `Skeleton` (5 rows), `EmptyState` ("Claims · inbox idle"), `ErrorState`, `FilterChips`, `Pagination`, `data-newer-than` flash | | `src/pages/Remittances.tsx` | Use `useRemittances`; same UX patterns | | `src/pages/Providers.tsx` | Use `useProviders`; `Skeleton` variant=`card` | | `src/pages/ActivityLog.tsx` | Use `useActivity`; `refetchInterval: 30_000`; `EmptyState` ("Activity · log idle") | | `src/pages/Upload.tsx` | Replace inline `addParsedBatch` flow with `useParse(kind)` mutation | ### New docs files | File | Responsibility | |---|---| | `docs/reference/837p.md` | ~80 lines — 837P structure + CO Medicaid specifics | | `docs/reference/835.md` | ~80 lines — 835 structure + balancing + CO Medicaid specifics | | `docs/reference/x12naming.md` | ~50 lines — segments, elements, composites, loops, common qualifiers | | `docs/reference/co-medicaid.md` | ~80 lines — TPID, payer IDs, allowed codes, POS codes | ### Modified docs files | File | Change | |---|---| | `README.md` (root) | Rewrite: what, install, dev, test, layout, roadmap, license note | --- ## Phase 1 — Backend ### Task 1: `cyclone.store` module — `BatchRecord` + empty `InMemoryStore` **Files:** - Create: `backend/src/cyclone/store.py` - Create: `backend/tests/test_store.py` - [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 `` in `` **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( ); ``` - [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: `` 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 (
{variant === "row" ? (
) : variant === "card" ? (
) : (
)}
); } ``` - [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: `` 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 (
{eyebrow}
{message}
); } ``` - [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: `` 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 (
Fetch failed
{error.message}
); } ``` - [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: `` 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 (
Active {active.map((c) => ( {c.key}: {c.label} ))} {active.length > 1 ? ( ) : null}
); } ``` - [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: `` 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 (
{returned} of {total}
); } ``` - [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 (
{isFetching > 0 ? (
) : null}
); } ``` - [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 = { items: T[]; total: number; returned: number; has_more: boolean; }; export type BatchSummary = { id: string; kind: "837p" | "835"; inputFilename: string; parsedAt: string; claimCount: number; }; function qs(params: Record | undefined): string { if (!params) return ""; const u = new URLSearchParams(); for (const [k, v] of Object.entries(params)) { if (v === undefined || v === null || v === "") continue; u.set(k, String(v)); } const s = u.toString(); return s ? `?${s}` : ""; } async function listBatches(): Promise { const res = await fetch(joinUrl("/api/batches")); if (!res.ok) { const detail = await readErrorBody(res); throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`); } const body = (await res.json()) as { items: BatchSummary[] }; return body.items; } async function getBatch(id: string): Promise { const res = await fetch(joinUrl(`/api/batches/${id}`)); if (!res.ok) { const detail = await readErrorBody(res); throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`); } return (await res.json()) as ParseResult837 | ParseResult835; } async function listClaims( params: ListClaimsParams ): Promise> { const res = await fetch(joinUrl(`/api/claims${qs(params as Record)}`)); if (!res.ok) { const detail = await readErrorBody(res); throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`); } return (await res.json()) as PaginatedResponse; } async function listRemittances( params: ListRemittancesParams ): Promise> { const res = await fetch(joinUrl(`/api/remittances${qs(params as Record)}`)); if (!res.ok) { const detail = await readErrorBody(res); throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`); } return (await res.json()) as PaginatedResponse; } async function listProviders( params: ListProvidersParams = {} ): Promise> { const res = await fetch(joinUrl(`/api/providers${qs(params as Record)}`)); if (!res.ok) { const detail = await readErrorBody(res); throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`); } return (await res.json()) as PaginatedResponse; } async function listActivity( params: ListActivityParams = {} ): Promise> { const res = await fetch(joinUrl(`/api/activity${qs(params as Record)}`)); if (!res.ok) { const detail = await readErrorBody(res); throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`); } return (await res.json()) as PaginatedResponse; ``` Then update the `export const api = {` block to add the new methods: ```ts export const api = { isConfigured, baseUrl: BASE_URL, health, parse837, parse835, listBatches, getBatch, listClaims, listRemittances, listProviders, listActivity, // Legacy placeholders (removed in a follow-up): getClaims: () => legacyNotImplemented("getClaims"), createClaim: (_payload: unknown) => legacyNotImplemented("createClaim"), getRemittances: () => legacyNotImplemented("getRemittances"), getProviders: () => legacyNotImplemented("getProviders"), getActivity: () => legacyNotImplemented("getActivity"), }; ``` - [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).mockResolvedValueOnce( new Response(JSON.stringify({ items: [] }), { status: 200 }) ); const out = await api.listBatches(); expect(out).toEqual([]); expect(global.fetch).toHaveBeenCalledWith( expect.stringContaining("/api/batches") ); }); it("listClaims builds the right URL with status + sort", async () => { (global.fetch as ReturnType).mockResolvedValueOnce( new Response(JSON.stringify({ items: [], total: 0 }), { status: 200 }) ); await api.listClaims({ status: "submitted", sort: "billedAmount", order: "desc" }); const called = (global.fetch as ReturnType).mock.calls[0][0] as string; expect(called).toContain("/api/claims?"); expect(called).toContain("status=submitted"); expect(called).toContain("sort=billedAmount"); expect(called).toContain("order=desc"); }); it("getBatch returns the result with the right id", async () => { (global.fetch as ReturnType).mockResolvedValueOnce( new Response( JSON.stringify({ envelope: null, claims: [], summary: {}, kind: "835" }), { status: 200 } ) ); const out = await api.getBatch("abc"); expect(out).toBeTruthy(); expect(global.fetch).toHaveBeenCalledWith( expect.stringContaining("/api/batches/abc") ); }); }); ``` > If `vitest` is not already configured, add `"test": "vitest run"` to `package.json` `scripts` and `vitest` to devDependencies. Run `npm install -D vitest` if needed. - [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({ 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 = { items: [], total: 0, returned: 0, has_more: false, }; export function useClaims(params: ListClaimsParams) { const fallback = useSyncExternalStore( (cb) => useAppStore.subscribe(cb), () => useAppStore.getState().claims, () => useAppStore.getState().claims, ); const q = useQuery>({ queryKey: ["claims", params], queryFn: () => api.listClaims(params), enabled: api.isConfigured, }); if (!api.isConfigured) { const all = fallback; const filtered = all.filter((c) => { if (params.status && c.status !== params.status) return false; if (params.provider_npi && c.providerNpi !== params.provider_npi) return false; if (params.batch_id && c.batchId !== params.batch_id) return false; return true; }); const offset = params.offset ?? 0; const limit = params.limit ?? 100; return { data: { items: filtered.slice(offset, offset + limit), total: filtered.length, returned: Math.min(limit, filtered.length - offset), has_more: filtered.length > offset + limit, } satisfies PaginatedResponse, isLoading: false, isError: false, error: null, refetch: () => Promise.resolve(), } as const; } return q; } export { EMPTY as EMPTY_CLAIMS }; ``` - [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>({ queryKey: ["remittances", params], queryFn: () => api.listRemittances(params), enabled: api.isConfigured, }); if (!api.isConfigured) { const offset = params.offset ?? 0; const limit = params.limit ?? 100; return { data: { items: fallback.slice(offset, offset + limit), total: fallback.length, returned: Math.min(limit, fallback.length - offset), has_more: fallback.length > offset + limit, } satisfies PaginatedResponse, isLoading: false, isError: false, error: null, refetch: () => Promise.resolve(), } as const; } return q; } ``` - [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>({ queryKey: ["providers", params], queryFn: () => api.listProviders(params), enabled: api.isConfigured, }); if (!api.isConfigured) { return { data: { items: fallback, total: fallback.length, returned: fallback.length, has_more: false } satisfies PaginatedResponse, isLoading: false, isError: false, error: null, refetch: () => Promise.resolve(), } as const; } return q; } ``` - [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>({ queryKey: ["activity", params], queryFn: () => api.listActivity(params), enabled: api.isConfigured, refetchInterval: api.isConfigured ? 30_000 : false, }); if (!api.isConfigured) { return { data: { items: fallback, total: fallback.length, returned: fallback.length, has_more: false } satisfies PaginatedResponse, isLoading: false, isError: false, error: null, refetch: () => Promise.resolve(), } as const; } return q; } ``` - [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(ALL); const [npi, setNpi] = useState(ALL); const [query, setQuery] = useState(""); const [page, setPage] = useState(1); const searchRef = useRef(null); const providers = useAppStore((s) => s.providers); const providerMap = useMemo( () => Object.fromEntries(providers.map((p) => [p.npi, p])), [providers] ); const params = { status: status === ALL ? undefined : status, provider_npi: npi === ALL ? undefined : npi, sort: "billedAmount", order: "desc" as const, limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE, }; const { data, isLoading, isError, error, refetch } = useClaims(params); const items = data?.items ?? []; const activeChips = [ status !== ALL ? { key: "Status", label: status } : null, npi !== ALL ? { key: "NPI", label: `${npi}${providerMap[npi] ? ` · ${providerMap[npi]!.name}` : ""}`, } : null, ].filter(Boolean) as { key: string; label: string }[]; const totals = useMemo( () => ({ billed: items.reduce((s, c) => s + c.billedAmount, 0), received: items.reduce((s, c) => s + c.receivedAmount, 0), }), [items] ); // Local search filter on the current page (cheap; no extra fetch). const visible = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return items; return items.filter( (c) => c.id.toLowerCase().includes(q) || c.patientName.toLowerCase().includes(q) || c.payerName.toLowerCase().includes(q) || c.cptCode.toLowerCase().includes(q) ); }, [items, query]); function clearFilter(key: string) { if (key === "Status") setStatus(ALL); if (key === "NPI") setNpi(ALL); setPage(1); } return (
Claims

All claims

{isError && error ? ( refetch()} /> ) : null}
setQuery(e.target.value)} className="pl-9 pr-16" /> {query ? ( ) : null}
{ setStatus(ALL); setNpi(ALL); setPage(1); }} />
{fmt.num(data?.total ?? 0)} {" "} claims Billed{" "} {fmt.usd(totals.billed)} Received{" "} {fmt.usd(totals.received)}
{isLoading ? (
{Array.from({ length: 5 }).map((_, i) => ( ))}
) : items.length === 0 ? ( ) : ( <> Claim Patient Provider Payer Billed Received Status Submitted {visible.map((c) => { const provider = providerMap[c.providerNpi]; return (
{c.id}
CPT {c.cptCode}
{c.patientName}
{provider?.name ?? "Unknown"}
{c.providerNpi}
{c.payerName} {fmt.usd(c.billedAmount)} {c.receivedAmount > 0 ? fmt.usd(c.receivedAmount) : "—"} {fmt.dateShort(c.submissionDate)}
); })}
)}
); } ``` - [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 (
Remittances

835 remittances

Electronic remittance advice from payers, matched to submitted claims.

{isError && error ? ( refetch()} /> ) : null}
Remits
{fmt.num(data?.total ?? 0)}
Total paid
{fmt.usd(total.paid)}
Adjustments
{fmt.usd(total.adjustments)}
{isLoading ? (
{Array.from({ length: 5 }).map((_, i) => ( ))}
) : items.length === 0 ? ( ) : ( <> Remit Claim Payer Paid Adjustment Status Received {items.map((r) => ( {r.id} {r.claimId} {r.payerName} {fmt.usdPrecise(r.paidAmount)} {fmt.usdPrecise(r.adjustmentAmount)} {fmt.dateShort(r.receivedDate)} ))}
)}
); } ``` - [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 (
Providers

Provider directory

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

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

{p.name}

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

{p.address}, {p.city}, {p.state} {p.zip}
{p.phone}
Claims
{fmt.num(p.claimCount)}
Outstanding AR
{fmt.usd(p.outstandingAr)}
))}
)}
); } ``` - [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 (
Activity

Activity log

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

{isError && error ? ( refetch()} /> ) : null}
{isLoading ? (
{Array.from({ length: 5 }).map((_, i) => ( ))}
) : items.length === 0 ? ( ) : ( )}
); } ``` - [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 ( Passed ); if (hasWarnings) return ( Warnings ); return ( Failed ); } function StatPill({ label, value, mono = true }: { label: string; value: React.ReactNode; mono?: boolean }) { return (
{label}
{value}
); } function ClaimCard837({ claim }: { claim: ClaimOutput }) { const [open, setOpen] = useState(false); const passed = claim.validation.passed; const hasWarnings = claim.validation.warnings.length > 0; return (
{open ? (
) : null}
); } function ClaimCard835({ claim }: { claim: ClaimPayment }) { const [open, setOpen] = useState(false); const passed = claim.service_payments.length > 0; return (
); } export function Upload() { const inputRef = useRef(null); const [file, setFile] = useState(null); const [kind, setKind] = useState("837p"); const [payer, setPayer] = useState(PAYERS_837[0]!.value); const [stream, setStream] = useState({ items: [], expectedTotal: null, passed: 0, failed: 0, }); const addParsedBatch = useAppStore((s) => s.addParsedBatch); const parsedBatches = useAppStore((s) => s.parsedBatches); const parseMutation = useParse(kind); const payerOptions = kind === "837p" ? PAYERS_837 : PAYERS_835; const totalSoFar = stream.items.length; const progressPct = useMemo(() => { if (!stream.expectedTotal || stream.expectedTotal <= 0) return 0; return Math.min(100, Math.round((totalSoFar / stream.expectedTotal) * 100)); }, [stream.expectedTotal, totalSoFar]); function pickFile(f: File | null) { setFile(f); setStream({ items: [], expectedTotal: null, passed: 0, failed: 0 }); } async function onParse() { if (!file) return; if (!api.isConfigured) { toast.error("Backend not configured. Set VITE_API_BASE_URL in .env.local to use the Upload page."); return; } setStream({ items: [], expectedTotal: null, passed: 0, failed: 0 }); const items: StreamedClaim[] = []; const onProgress: ParseProgress = (event) => { if (event.type === "claim") { items.push({ kind: "837p", data: event.data }); setStream((s) => ({ ...s, items: [...items] })); } else if (event.type === "claim_payment") { items.push({ kind: "835", data: event.data }); setStream((s) => ({ ...s, items: [...items] })); } else if (event.type === "summary") { setStream((s) => ({ ...s, expectedTotal: event.data.total_claims, passed: event.data.passed, failed: event.data.failed, })); } }; try { const summary = await parseMutation.mutateAsync({ file, options: { payer, onProgress }, }); if (summary && typeof summary === "object" && "total_claims" in summary) { const claimIds = items.map((c) => c.kind === "837p" ? c.data.claim_id : c.data.payer_claim_control_number).filter(Boolean); const batch: ParsedBatch = { id: `BATCH-${Date.now()}`, kind, inputFilename: file.name, parsedAt: new Date().toISOString(), claimCount: summary.total_claims, passed: summary.passed, failed: summary.failed, claimIds, summary, }; addParsedBatch(batch); toast.success(`Parsed ${summary.total_claims} ${kind === "837p" ? "claims" : "payments"} · ${summary.passed} passed · ${summary.failed} failed`, { description: file.name, }); } } catch (err) { toast.error(err instanceof Error ? err.message : "Failed to parse file"); } } return (
Upload · EDI parser

Parse an X12 file

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

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

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

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

Parsed files, newest first.

    {parsedBatches.map((b) => (
  • {b.kind === "837p" ? "837P" : "835"}
    {b.inputFilename}
    {fmt.dateShort(b.parsedAt)} · {b.claimCount} {b.kind === "837p" ? "claims" : "payments"}
    {b.passed} / {b.claimCount}
    passed
  • ))}
) : null}
); } ``` - [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: `::` - 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 103–106 in the ISA fixed-width header) and reused throughout the file. Cyclone's tokenizer in `cyclone/parsers/segments.py` reads the ISA first, then splits the rest of the file by those characters. - `*` — element separator - `:` — component (sub-element) separator - `~` — segment terminator - `^` — repetition separator ``` - [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 `
` 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.1–7.7.8 live-data UX | T13 (keyframes), T15 (Empty), T16 (Error), T17 (Chips), T18 (Pagination), T19 (refetch indicator), T22–T26 (row flash) | | §8 docs (4 notes + README) | T28–T32 | | §10 testing (~23 backend, 3 frontend) | T1–T8 (backend), T20 (frontend) | | §11 migration/rollout | T9 (uvicorn bind) + T4 (store populated on parse) | | §13 acceptance checklist | T33 (manual) | | §13.1 visual / aesthetic checks | T13, T14, T15, T16, T17, T18, T19, T22–T26 | **No placeholders.** No "TBD", "TODO", "implement later", or vague "add appropriate error handling" — every step has concrete code. **Type consistency.** 837P statuses `draft|submitted|accepted|denied|paid|pending` and 835 statuses `received|posted|reconciled` appear consistently across T2, T21, T22, T23, T25, T26 and the 7.7.7 spec section. Color tokens (`success|accent|warning|destructive|muted`) match the existing Tailwind config. Keyframe names `shimmer|scan|row-flash` are defined once in T13 and referenced consistently in T14, T19, T22, T23, T25. **File path accuracy.** All paths verified against the current repo: `backend/src/cyclone/store.py` (new), `backend/src/cyclone/api.py` (modify), `backend/src/cyclone/__main__.py` (modify), `src/main.tsx` (modify), `src/components/Layout.tsx` (modify), `src/lib/api.ts` (modify), `src/pages/{Claims,Remittances,Providers,ActivityLog,Upload}.tsx` (modify), `docs/reference/{837p,835,x12naming,co-medicaid}.md` (new), `README.md` (modify). --- ## Execution This plan is ready for the **subagent-driven-development** skill. Each backend task is small enough to fit in a single subagent (≤ 30 minutes); the frontend tasks are similarly scoped. The docs tasks (T28–T32) are mechanical markdown and can be dispatched in parallel from a single subagent. Approximate effort (subagent wall-clock): - Phase 1 (backend): 6 subagents, ~90 min total with parallelism - Phase 2 (frontend): 8 subagents, ~120 min total - Phase 3 (docs): 1 subagent, ~20 min - Phase 4 (smoke): manual, ~10 min Total: ~4 hours of subagent work + review. **Review checkpoints** (built into the subagent-driven-development skill): - After every 3 tasks, the orchestrator runs `requesting-code-review` on the cumulative diff. - After Phase 1 completes, run the full backend test suite as a gate before starting Phase 2. - After Phase 2 completes, run `npm run build` as a gate before starting Phase 3. - After Phase 3, run the T33 end-to-end smoke as the final acceptance.