138 KiB
Cyclone Production-Readiness (local-only) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [x]) syntax for tracking.
Goal: Add a backend in-memory batch store + 6 GET endpoints (with filter/sort/pagination and NDJSON streaming) to the Cyclone EDI suite, wire the existing 4 React pages + Upload page to live data via @tanstack/react-query v5, ship 4 fresh reference notes + a root README rewrite, and lock the local-only deploy posture (127.0.0.1:8000, no auth, no internet exposure). All on a single branch, with frequent commits, fully testable at every checkpoint.
Architecture: Threaded InMemoryStore (process-local, list of BatchRecord keyed by uuid4) → 6 read-only FastAPI routes + an application/x-ndjson content-negotiated streaming variant on each list endpoint. Frontend: QueryClientProvider at the root, 6 hooks under src/hooks/, 4 page refactors, 5 new UI primitives (Skeleton, EmptyState, ErrorState, FilterChips, Pagination), a Layout-level refetch indicator, and a newly-streamed row flash on the table. Docs: 4 short notes under docs/reference/, root README rewritten.
Tech Stack: Python 3.11+, FastAPI, Pydantic v2, threading.RLock; React 18 + Vite + TS + Tailwind, @tanstack/react-query v5, lucide-react, sonner, zustand, clsx + tailwind-merge (existing). No new Python deps; one new JS dep (@tanstack/react-query).
Spec: docs/superpowers/specs/2026-06-19-cyclone-production-readiness-design.md (approved)
Worktree: Use superpowers:using-git-worktrees to create .worktrees/prod-readiness from main before Task 1.
File map
New backend files
| File | Responsibility |
|---|---|
backend/src/cyclone/store.py |
BatchRecord, InMemoryStore, mappers (to_ui_claim, to_ui_remittance, to_ui_provider, to_activity_event) |
backend/tests/test_store.py |
~8 tests: add/list/get, threading, mappers, filters, distinct providers, activity |
backend/tests/test_api_gets.py |
~12 tests: happy path + filter for each of 6 GET endpoints + 404 for batches/{id} |
backend/tests/test_api_streaming.py |
1 test: Accept: application/x-ndjson on /api/claims returns parseable lines |
backend/tests/test_api_parse_persists.py |
2 tests: parse-837 persists + appears via /api/claims; failed parse does NOT create a batch |
Modified backend files
| File | Change |
|---|---|
backend/src/cyclone/api.py |
Add 6 GET routes; wire parse-837/parse-835 to store.add(); import new module |
backend/src/cyclone/__main__.py |
Bind uvicorn to 127.0.0.1:8000; honor CYCLONE_PORT + CYCLONE_RELOAD env vars |
New frontend files
| File | Responsibility |
|---|---|
src/components/ui/skeleton.tsx |
<Skeleton variant="row" | "card" | "kpi" /> with hairline + scanline-shimmer |
src/components/ui/empty-state.tsx |
<EmptyState eyebrow="…" message="…" /> — all-caps eyebrow, hairline-circle icon |
src/components/ui/error-state.tsx |
<ErrorState error={err} onRetry={fn} /> — destructive-hairline block + retry |
src/components/ui/filter-chips.tsx |
<FilterChips active={…} onRemove={…} onClearAll={…} /> |
src/components/ui/pagination.tsx |
<Pagination page={n} total={n} onChange={fn} /> with nav-active echo |
src/hooks/useBatches.ts |
useBatches() |
src/hooks/useClaims.ts |
useClaims(params) — falls back to zustand when !api.isConfigured |
src/hooks/useRemittances.ts |
useRemittances(params) |
src/hooks/useProviders.ts |
useProviders(params) |
src/hooks/useActivity.ts |
useActivity(params) |
src/hooks/useParse.ts |
useParse(kind) — useMutation that invalidates 5 query keys on success |
src/lib/api.test.ts |
~3 tests: listClaims URL, listBatches path, getBatch kind handling |
Modified frontend files
| File | Change |
|---|---|
package.json |
Add @tanstack/react-query ^5.59.0 |
tailwind.config.js |
Add 3 keyframes (shimmer, scan, row-flash) |
src/main.tsx |
Wrap <App /> in <QueryClientProvider> |
src/components/Layout.tsx |
Add useIsFetching() refetch indicator at top of <main> |
src/lib/api.ts |
Add listBatches, getBatch, listClaims, listRemittances, listProviders, listActivity; replace legacyNotImplemented for those with real fetches |
src/pages/Claims.tsx |
Use useClaims; add Skeleton (5 rows), EmptyState ("Claims · inbox idle"), ErrorState, FilterChips, Pagination, data-newer-than flash |
src/pages/Remittances.tsx |
Use useRemittances; same UX patterns |
src/pages/Providers.tsx |
Use useProviders; Skeleton variant=card |
src/pages/ActivityLog.tsx |
Use useActivity; refetchInterval: 30_000; EmptyState ("Activity · log idle") |
src/pages/Upload.tsx |
Replace inline addParsedBatch flow with useParse(kind) mutation |
New docs files
| File | Responsibility |
|---|---|
docs/reference/837p.md |
~80 lines — 837P structure + CO Medicaid specifics |
docs/reference/835.md |
~80 lines — 835 structure + balancing + CO Medicaid specifics |
docs/reference/x12naming.md |
~50 lines — segments, elements, composites, loops, common qualifiers |
docs/reference/co-medicaid.md |
~80 lines — TPID, payer IDs, allowed codes, POS codes |
Modified docs files
| File | Change |
|---|---|
README.md (root) |
Rewrite: what, install, dev, test, layout, roadmap, license note |
Phase 1 — Backend
Task 1: cyclone.store module — BatchRecord + empty InMemoryStore
Files:
-
Create:
backend/src/cyclone/store.py -
Create:
backend/tests/test_store.py -
Step 1: Write the failing test for
BatchRecordconstruction + module import
backend/tests/test_store.py:
"""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/ParseResultmodel fields differ, adjust the_make_claim/_make_resultstubs to match the real field names. Runpython -c "from cyclone.parsers.models import ClaimOutput; help(ClaimOutput)"to confirm.
- Step 2: Run the test to verify it fails
Run: cd backend && .venv/bin/pytest tests/test_store.py -v
Expected: ModuleNotFoundError: No module named 'cyclone.store' (or similar import error).
- Step 3: Implement
BatchRecord+ emptyInMemoryStore(no mappers yet)
backend/src/cyclone/store.py:
"""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 ParseResultandfrom cyclone.parsers.models_835 import ParseResult835are correct based on the existing project layout. If the Pydantic v2 union complains about the discriminator, drop thediscriminator=Noneand let Pydantic use the default behavior — the actual class is determined at runtime by the field type.
- Step 4: Run the tests to verify they pass
Run: cd backend && .venv/bin/pytest tests/test_store.py -v
Expected: all 4 tests pass.
- Step 5: Commit
git add backend/src/cyclone/store.py backend/tests/test_store.py
git commit -m "feat(backend): add cyclone.store with BatchRecord and InMemoryStore"
Task 2: Store mappers — to_ui_claim, to_ui_remittance, to_ui_provider, to_activity_event
Files:
-
Modify:
backend/src/cyclone/store.py -
Modify:
backend/tests/test_store.py -
Step 1: Add failing tests for the mappers
Append to backend/tests/test_store.py:
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
statusmapping rules (submitted/pending/denied) and that the mapper output is a dict with the right shape.
- Step 2: Run the test to verify it fails
Run: cd backend && .venv/bin/pytest tests/test_store.py -v
Expected: ImportError: cannot import name 'to_ui_claim' from 'cyclone.store'.
- Step 3: Implement the mappers
Add to backend/src/cyclone/store.py (after utcnow_iso):
# ---------------------------------------------------------------------------
# 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:from cyclone.parsers.models import ClaimOutput, ParseResult(already imported in Task 1; the
ClaimPaymentimport forto_ui_remittanceshould be added:from cyclone.parsers.models_835 import ClaimPayment, ParseResult835 ```)
- Step 4: Run the tests to verify they pass
Run: cd backend && .venv/bin/pytest tests/test_store.py -v
Expected: all 10 tests pass.
- Step 5: Commit
git add backend/src/cyclone/store.py backend/tests/test_store.py
git commit -m "feat(backend): add store mappers (claim, remittance, provider, activity)"
Task 3: Store iterators — iter_claims, iter_remittances, distinct_providers, recent_activity
Files:
-
Modify:
backend/src/cyclone/store.py -
Modify:
backend/tests/test_store.py -
Step 1: Add failing tests for the iterators
Append to backend/tests/test_store.py:
def test_iter_claims_filters_by_status():
from cyclone.store import to_ui_claim
s = InMemoryStore()
a = _make_claim("A")
b = _make_claim("B")
b.validation = ValidationReport(passed=True, errors=[],
warnings=[{"rule": "W1", "message": "x"}])
s.add(BatchRecord(id="1", kind="837p", input_filename="a.txt",
parsed_at="2026-06-19T12:00:00Z", result=_make_result()))
out = list(s.iter_claims(status="submitted"))
assert all(c["status"] == "submitted" for c in out)
# B's status is "pending" (passed=True, has warnings), so it should be excluded
def test_iter_claims_filters_by_batch_id():
from cyclone.store import to_ui_claim
s = InMemoryStore()
s.add(BatchRecord(id="b1", kind="837p", input_filename="a.txt",
parsed_at="2026-06-19T12:00:00Z", result=_make_result()))
out = list(s.iter_claims(batch_id="b1"))
assert all(c["batchId"] == "b1" for c in out)
def test_iter_claims_sorts_by_billed_amount_desc():
s = InMemoryStore()
result = _make_result()
result.claims = [
_make_claim("cheap"),
_make_claim("expensive"),
]
result.claims[0].claim.total_charge = 50.0
result.claims[1].claim.total_charge = 500.0
s.add(BatchRecord(id="b1", kind="837p", input_filename="a.txt",
parsed_at="2026-06-19T12:00:00Z", result=result))
out = list(s.iter_claims(sort="billedAmount", order="desc"))
assert out[0]["id"] == "expensive"
assert out[1]["id"] == "cheap"
def test_distinct_providers_aggregates_claims():
s = InMemoryStore()
result = _make_result()
result.claims = [_make_claim("A"), _make_claim("B")]
s.add(BatchRecord(id="b1", kind="837p", input_filename="a.txt",
parsed_at="2026-06-19T12:00:00Z", result=result))
out = s.distinct_providers()
assert len(out) == 1
assert out[0]["npi"] == "1234567890"
assert out[0]["claimCount"] == 2
def test_recent_activity_returns_newest_first():
s = InMemoryStore()
s.add(BatchRecord(id="b1", kind="837p", input_filename="a.txt",
parsed_at="2026-06-19T11:00:00Z", result=_make_result()))
s.add(BatchRecord(id="b2", kind="837p", input_filename="b.txt",
parsed_at="2026-06-19T12:00:00Z", result=_make_result()))
out = s.recent_activity(limit=10)
assert out[0]["timestamp"] == "2026-06-19T12:00:00Z"
assert out[1]["timestamp"] == "2026-06-19T11:00:00Z"
- Step 2: Run tests to verify they fail
Run: cd backend && .venv/bin/pytest tests/test_store.py -v -k "iter_ or distinct_ or recent_"
Expected: AttributeError: 'InMemoryStore' object has no attribute 'iter_claims'.
- Step 3: Implement the iterators
Add to backend/src/cyclone/store.py (as methods on InMemoryStore):
def iter_claims(
self,
*,
batch_id: str | None = None,
status: str | None = None,
provider_npi: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
sort: str | None = None,
order: str = "desc",
limit: int = 100,
offset: int = 0,
) -> Iterable[dict]:
with self._lock:
all_claims: list[dict] = []
for b in self._batches:
if b.kind != "837p":
continue
if batch_id is not None and b.id != batch_id:
continue
for c in b.result.claims: # type: ignore[attr-defined]
all_claims.append(to_ui_claim(
c, batch_id=b.id, parsed_at=b.parsed_at,
))
if status is not None:
all_claims = [c for c in all_claims if c["status"] == status]
if provider_npi is not None:
all_claims = [c for c in all_claims if c["providerNpi"] == provider_npi]
if sort is not None:
all_claims.sort(
key=lambda c: c.get(sort, 0) or 0,
reverse=(order == "desc"),
)
yield from all_claims[offset:offset + limit]
def iter_remittances(
self,
*,
batch_id: str | None = None,
payer: str | None = None,
claim_id: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
sort: str | None = None,
order: str = "desc",
limit: int = 100,
offset: int = 0,
) -> Iterable[dict]:
with self._lock:
all_remits: list[dict] = []
for b in self._batches:
if b.kind != "835":
continue
if batch_id is not None and b.id != batch_id:
continue
for cp in b.result.claim_payments: # type: ignore[attr-defined]
all_remits.append(to_ui_remittance(
cp, batch_id=b.id, parsed_at=b.parsed_at,
))
if payer is not None:
all_remits = [r for r in all_remits if r.get("payerName") == payer]
if claim_id is not None:
all_remits = [r for r in all_remits if r["claimId"] == claim_id]
if sort is not None:
all_remits.sort(
key=lambda r: r.get(sort, 0) or 0,
reverse=(order == "desc"),
)
yield from all_remits[offset:offset + limit]
def distinct_providers(self) -> list[dict]:
with self._lock:
by_npi: dict[str, dict] = {}
for b in self._batches:
if b.kind != "837p":
continue
bp = b.result.billing_provider # type: ignore[attr-defined]
npi = bp.npi
if npi not in by_npi:
by_npi[npi] = to_ui_provider(
npi=npi,
name=bp.name or "",
tax_id=bp.tax_id,
address=bp.address,
city=bp.city,
state=bp.state,
zip=bp.zip,
phone=bp.phone,
claim_count=0,
outstanding_ar=0.0,
)
by_npi[npi]["claimCount"] += len(b.result.claims) # type: ignore[attr-defined]
return list(by_npi.values())
def recent_activity(self, *, limit: int = 200) -> list[dict]:
with self._lock:
events: list[dict] = []
for b in reversed(self._batches):
if b.kind == "837p":
for c in b.result.claims: # type: ignore[attr-defined]
events.append(to_activity_event(
id=f"{b.id}:{c.claim_id}",
kind="claim_submitted",
message=f"Claim {c.claim_id} submitted · {c.payer.name}",
timestamp=b.parsed_at,
npi=c.billing_provider.npi,
amount=float(c.claim.total_charge or 0.0),
))
elif b.kind == "835":
for cp in b.result.claim_payments: # type: ignore[attr-defined]
events.append(to_activity_event(
id=f"{b.id}:{cp.payer_claim_control_number}",
kind="remit_received",
message=f"Remit {cp.payer_claim_control_number} received",
timestamp=b.parsed_at,
amount=float(cp.total_paid or 0.0),
))
return events[:limit]
- Step 4: Run tests to verify they pass
Run: cd backend && .venv/bin/pytest tests/test_store.py -v
Expected: all tests pass (15 total now).
- Step 5: Commit
git add backend/src/cyclone/store.py backend/tests/test_store.py
git commit -m "feat(backend): add store iterators (claims, remits, providers, activity)"
Task 4: Wire parse-837 and parse-835 to call store.add() on success
Files:
-
Modify:
backend/src/cyclone/api.py -
Create:
backend/tests/test_api_parse_persists.py -
Step 1: Add failing tests
backend/tests/test_api_parse_persists.py:
"""Successful parses must persist to the store; failed parses must not."""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from cyclone.api import app
from cyclone.store import store as global_store
FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt"
@pytest.fixture(autouse=True)
def clear_store():
"""Reset the module-level store before and after each test."""
with global_store._lock:
global_store._batches.clear()
yield
with global_store._lock:
global_store._batches.clear()
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
def test_successful_parse_837_creates_batch(client: TestClient):
assert len(global_store.list()) == 0
text = FIXTURE.read_text()
resp = client.post(
"/api/parse-837",
files={"file": ("test.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
assert resp.status_code == 200, resp.text
assert len(global_store.list()) == 1
rec = global_store.list()[0]
assert rec.kind == "837p"
assert rec.input_filename == "test.txt"
def test_failed_parse_837_does_not_create_batch(client: TestClient):
"""An empty / garbage file should NOT create a batch."""
assert len(global_store.list()) == 0
resp = client.post(
"/api/parse-837",
files={"file": ("garbage.txt", "not-edi", "text/plain")},
headers={"Accept": "application/json"},
)
# Either a 4xx from the parser, or a 200 with 0 claims — the contract
# is that NO batch is added either way.
assert resp.status_code in (200, 400, 422)
assert len(global_store.list()) == 0
- Step 2: Run tests to verify they fail
Run: cd backend && .venv/bin/pytest tests/test_api_parse_persists.py -v
Expected: first test fails because len(global_store.list()) == 1 is False (no batches added yet).
- Step 3: Modify
cyclone.apito callstore.add()on success
In backend/src/cyclone/api.py, add the import near the top:
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:
@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:
import uuid
The exact location depends on your existing parse-route structure. The key invariant is:
store.add(record)is called only after the parser successfully produces a result, and before the response is returned.
- Step 4: Run tests to verify they pass
Run: cd backend && .venv/bin/pytest tests/test_api_parse_persists.py -v
Expected: both tests pass.
- Step 5: Commit
git add backend/src/cyclone/api.py backend/tests/test_api_parse_persists.py
git commit -m "feat(backend): wire parse-837/parse-835 to persist to InMemoryStore"
Task 5: GET /api/batches and GET /api/batches/{id} routes
Files:
-
Modify:
backend/src/cyclone/api.py -
Modify:
backend/tests/test_api_gets.py(new) -
Step 1: Add failing tests for the batch endpoints
backend/tests/test_api_gets.py:
"""Tests for the 6 new GET endpoints."""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from cyclone.api import app
from cyclone.store import store as global_store
FIXTURE_837 = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt"
@pytest.fixture(autouse=True)
def clear_store():
with global_store._lock:
global_store._batches.clear()
yield
with global_store._lock:
global_store._batches.clear()
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture
def seeded_store():
"""One parsed 837P fixture batch."""
text = FIXTURE_837.read_text()
client = TestClient(app)
client.post(
"/api/parse-837",
files={"file": ("x.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
return client
# --------------------------------------------------------------------------- #
# /api/batches
# --------------------------------------------------------------------------- #
def test_batches_returns_empty_list_when_no_parses(client: TestClient):
resp = client.get("/api/batches")
assert resp.status_code == 200
body = resp.json()
assert body == {"items": [], "total": 0, "returned": 0, "has_more": False}
def test_batches_returns_summary_after_parse(seeded_store):
resp = seeded_store.get("/api/batches")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 1
assert body["returned"] == 1
assert body["items"][0]["kind"] == "837p"
assert body["items"][0]["inputFilename"] == "x.txt"
# --------------------------------------------------------------------------- #
# /api/batches/{id}
# --------------------------------------------------------------------------- #
def test_get_batch_by_id_returns_full_result(seeded_store):
batches = seeded_store.get("/api/batches").json()["items"]
bid = batches[0]["id"]
resp = seeded_store.get(f"/api/batches/{bid}")
assert resp.status_code == 200
body = resp.json()
assert "envelope" in body
assert "claims" in body
assert len(body["claims"]) == 2
def test_get_batch_404_when_missing(client: TestClient):
resp = client.get("/api/batches/does-not-exist")
assert resp.status_code == 404
- Step 2: Run tests to verify they fail
Run: cd backend && .venv/bin/pytest tests/test_api_gets.py -v
Expected: all 4 tests fail with 404 (routes don't exist yet).
- Step 3: Implement the routes
Add to backend/src/cyclone/api.py:
from datetime import datetime
@app.get("/api/batches")
def list_batches(limit: int = Query(100, ge=1, le=1000)):
"""Summary of all parsed batches, newest first."""
records = store.list(limit=limit)
items = [
{
"id": r.id,
"kind": r.kind,
"inputFilename": r.input_filename,
"parsedAt": r.parsed_at,
"claimCount": (
len(r.result.claims) # type: ignore[attr-defined]
if r.kind == "837p"
else len(r.result.claim_payments) # type: ignore[attr-defined]
),
}
for r in records
]
return {
"items": items,
"total": len(store.all()),
"returned": len(items),
"has_more": len(store.all()) > len(items),
}
@app.get("/api/batches/{batch_id}")
def get_batch(batch_id: str):
rec = store.get(batch_id)
if rec is None:
raise HTTPException(status_code=404, detail=f"Batch {batch_id} not found")
return rec.result
- Step 4: Run tests to verify they pass
Run: cd backend && .venv/bin/pytest tests/test_api_gets.py -v -k "batches"
Expected: 4 tests pass.
- Step 5: Commit
git add backend/src/cyclone/api.py backend/tests/test_api_gets.py
git commit -m "feat(backend): add GET /api/batches and GET /api/batches/{id}"
Task 6: GET /api/claims with filter / sort / pagination
Files:
-
Modify:
backend/src/cyclone/api.py -
Modify:
backend/tests/test_api_gets.py -
Step 1: Add failing tests
Append to backend/tests/test_api_gets.py:
# --------------------------------------------------------------------------- #
# /api/claims
# --------------------------------------------------------------------------- #
def test_claims_returns_empty_list_when_no_parses(client: TestClient):
resp = client.get("/api/claims")
assert resp.status_code == 200
body = resp.json()
assert body["items"] == []
assert body["total"] == 0
def test_claims_filters_by_status_submitted(seeded_store):
resp = seeded_store.get("/api/claims?status=submitted")
assert resp.status_code == 200
body = resp.json()
assert all(c["status"] == "submitted" for c in body["items"])
assert body["total"] >= 1
def test_claims_filters_by_batch_id(seeded_store):
bid = seeded_store.get("/api/batches").json()["items"][0]["id"]
resp = seeded_store.get(f"/api/claims?batch_id={bid}")
assert resp.status_code == 200
body = resp.json()
assert all(c["batchId"] == bid for c in body["items"])
def test_claims_sorts_by_billed_amount_desc(seeded_store):
resp = seeded_store.get("/api/claims?sort=billedAmount&order=desc")
assert resp.status_code == 200
body = resp.json()
amounts = [c["billedAmount"] for c in body["items"]]
assert amounts == sorted(amounts, reverse=True)
def test_claims_pagination_limit_offset(seeded_store):
resp1 = seeded_store.get("/api/claims?limit=1&offset=0")
body1 = resp1.json()
assert body1["returned"] == 1
assert body1["has_more"] is True
- Step 2: Run tests to verify they fail
Run: cd backend && .venv/bin/pytest tests/test_api_gets.py -v -k "claims_"
Expected: 404 / Not Found for /api/claims.
- Step 3: Implement the route
Add to backend/src/cyclone/api.py:
@app.get("/api/claims")
def list_claims(
batch_id: str | None = Query(None),
status: str | None = Query(None),
provider_npi: str | None = Query(None, alias="provider_npi"),
payer: str | None = Query(None),
date_from: str | None = Query(None),
date_to: str | None = Query(None),
sort: str | None = Query(None),
order: str = Query("desc"),
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
):
items = list(store.iter_claims(
batch_id=batch_id, status=status, provider_npi=provider_npi,
payer=payer, date_from=date_from, date_to=date_to,
sort=sort, order=order, limit=limit, offset=offset,
))
total = len(list(store.iter_claims(
batch_id=batch_id, status=status, provider_npi=provider_npi,
payer=payer, date_from=date_from, date_to=date_to,
)))
return {
"items": items,
"total": total,
"returned": len(items),
"has_more": total > offset + len(items),
}
- Step 4: Run tests to verify they pass
Run: cd backend && .venv/bin/pytest tests/test_api_gets.py -v -k "claims_"
Expected: 5 tests pass.
- Step 5: Commit
git add backend/src/cyclone/api.py backend/tests/test_api_gets.py
git commit -m "feat(backend): add GET /api/claims with filter/sort/pagination"
Task 7: GET /api/remittances, GET /api/providers, GET /api/activity
Files:
-
Modify:
backend/src/cyclone/api.py -
Modify:
backend/tests/test_api_gets.py -
Step 1: Add failing tests
Append to backend/tests/test_api_gets.py:
# --------------------------------------------------------------------------- #
# /api/remittances
# --------------------------------------------------------------------------- #
def test_remittances_returns_empty_list_when_no_parses(client: TestClient):
resp = client.get("/api/remittances")
assert resp.status_code == 200
body = resp.json()
assert body["items"] == []
def test_remittances_filters_by_batch_id(seeded_store):
"""No 835 fixture parsed yet — but the endpoint should return 200 with empty."""
resp = seeded_store.get("/api/remittances?batch_id=nope")
assert resp.status_code == 200
assert resp.json()["items"] == []
# --------------------------------------------------------------------------- #
# /api/providers
# --------------------------------------------------------------------------- #
def test_providers_empty_when_no_parses(client: TestClient):
resp = client.get("/api/providers")
assert resp.status_code == 200
body = resp.json()
assert body["items"] == []
def test_providers_returns_one_per_distinct_npi(seeded_store):
resp = seeded_store.get("/api/providers")
assert resp.status_code == 200
body = resp.json()
assert len(body["items"]) == 1
assert body["items"][0]["npi"] == "1234567890"
# --------------------------------------------------------------------------- #
# /api/activity
# --------------------------------------------------------------------------- #
def test_activity_empty_when_no_parses(client: TestClient):
resp = client.get("/api/activity")
assert resp.status_code == 200
body = resp.json()
assert body["items"] == []
def test_activity_returns_one_event_per_claim(seeded_store):
resp = seeded_store.get("/api/activity")
assert resp.status_code == 200
body = resp.json()
assert len(body["items"]) == 2 # co_medicaid_837p.txt has 2 claims
assert all(e["kind"] == "claim_submitted" for e in body["items"])
- Step 2: Run tests to verify they fail
Run: cd backend && .venv/bin/pytest tests/test_api_gets.py -v -k "remittances_ or providers_ or activity_"
Expected: 6 tests fail with 404.
- Step 3: Implement the three routes
Add to backend/src/cyclone/api.py:
@app.get("/api/remittances")
def list_remittances(
batch_id: str | None = Query(None),
payer: str | None = Query(None),
claim_id: str | None = Query(None),
date_from: str | None = Query(None),
date_to: str | None = Query(None),
sort: str | None = Query(None),
order: str = Query("desc"),
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
):
items = list(store.iter_remittances(
batch_id=batch_id, payer=payer, claim_id=claim_id,
date_from=date_from, date_to=date_to,
sort=sort, order=order, limit=limit, offset=offset,
))
total = len(list(store.iter_remittances(
batch_id=batch_id, payer=payer, claim_id=claim_id,
)))
return {
"items": items, "total": total, "returned": len(items),
"has_more": total > offset + len(items),
}
@app.get("/api/providers")
def list_providers(
npi: str | None = Query(None),
state: str | None = Query(None),
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
):
items = store.distinct_providers()
if npi is not None:
items = [p for p in items if p["npi"] == npi]
if state is not None:
items = [p for p in items if p.get("state") == state]
paged = items[offset:offset + limit]
return {
"items": paged, "total": len(items), "returned": len(paged),
"has_more": len(items) > offset + len(paged),
}
@app.get("/api/activity")
def list_activity(
kind: str | None = Query(None),
since: str | None = Query(None),
limit: int = Query(200, ge=1, le=500),
):
events = store.recent_activity(limit=limit)
if kind is not None:
events = [e for e in events if e["kind"] == kind]
if since is not None:
events = [e for e in events if e["timestamp"] >= since]
return {
"items": events, "total": len(events), "returned": len(events),
"has_more": False,
}
- Step 4: Run tests to verify they pass
Run: cd backend && .venv/bin/pytest tests/test_api_gets.py -v -k "remittances_ or providers_ or activity_"
Expected: 6 tests pass.
- Step 5: Commit
git add backend/src/cyclone/api.py backend/tests/test_api_gets.py
git commit -m "feat(backend): add GET /api/{remittances,providers,activity}"
Task 8: NDJSON streaming on list endpoints
Files:
-
Modify:
backend/src/cyclone/api.py -
Create:
backend/tests/test_api_streaming.py -
Step 1: Add failing test
backend/tests/test_api_streaming.py:
"""NDJSON streaming variant on the list endpoints."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from cyclone.api import app
from cyclone.store import store as global_store
FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt"
@pytest.fixture(autouse=True)
def clear_store():
with global_store._lock:
global_store._batches.clear()
yield
with global_store._lock:
global_store._batches.clear()
@pytest.fixture
def seeded_client():
client = TestClient(app)
text = FIXTURE.read_text()
client.post(
"/api/parse-837",
files={"file": ("x.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
return client
def test_claims_ndjson_returns_parseable_lines(seeded_client):
resp = seeded_client.get(
"/api/claims",
headers={"Accept": "application/x-ndjson"},
)
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("application/x-ndjson")
lines = [l for l in resp.text.split("\n") if l.strip()]
events = [json.loads(l) for l in lines]
items = [e["data"] for e in events if e["type"] == "item"]
summary = next(e for e in events if e["type"] == "summary")
assert len(items) == 2 # co_medicaid_837p.txt has 2 claims
assert summary["data"]["total"] == 2
assert summary["data"]["has_more"] is False
- Step 2: Run test to verify it fails
Run: cd backend && .venv/bin/pytest tests/test_api_streaming.py -v
Expected: response is JSON, not NDJSON — content-type assertion fails.
- Step 3: Implement streaming on
/api/claims(and apply the same pattern to remittances/providers/activity)
In backend/src/cyclone/api.py, refactor list_claims to content-negotiate:
def _ndjson_stream(items: Iterable[dict], total: int) -> Iterator[str]:
"""Yield NDJSON lines: one `item` per dict, then one `summary`."""
for it in items:
yield json.dumps({"type": "item", "data": it}) + "\n"
yield json.dumps({
"type": "summary",
"data": {"total": total, "returned": 0, "has_more": False}, # filled by caller
}) + "\n"
@app.get("/api/claims")
def list_claims(
request: Request,
batch_id: str | None = Query(None),
status: str | None = Query(None),
provider_npi: str | None = Query(None, alias="provider_npi"),
payer: str | None = Query(None),
date_from: str | None = Query(None),
date_to: str | None = Query(None),
sort: str | None = Query(None),
order: str = Query("desc"),
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
):
all_filtered = list(store.iter_claims(
batch_id=batch_id, status=status, provider_npi=provider_npi,
payer=payer, date_from=date_from, date_to=date_to,
))
page = all_filtered[offset:offset + limit]
total = len(all_filtered)
has_more = total > offset + len(page)
if request.headers.get("accept") == "application/x-ndjson":
def gen():
for it in page:
yield json.dumps({"type": "item", "data": it}) + "\n"
yield json.dumps({
"type": "summary",
"data": {"total": total, "returned": len(page), "has_more": has_more},
}) + "\n"
return StreamingResponse(gen(), media_type="application/x-ndjson")
return {
"items": page, "total": total, "returned": len(page), "has_more": has_more,
}
Apply the same pattern to list_remittances, list_providers, and list_activity (each branches on request.headers.get("accept") == "application/x-ndjson" and returns StreamingResponse when set).
- Step 4: Run test to verify it passes
Run: cd backend && .venv/bin/pytest tests/test_api_streaming.py -v
Expected: test passes.
- Step 5: Commit
git add backend/src/cyclone/api.py backend/tests/test_api_streaming.py
git commit -m "feat(backend): add NDJSON streaming on list endpoints"
Task 9: Bind uvicorn to 127.0.0.1:8000 with env-var support
Files:
-
Modify:
backend/src/cyclone/__main__.py -
Step 1: Update
__main__.pyto bind loopback and honor env vars
Replace backend/src/cyclone/__main__.py with:
"""Entry point for ``python -m cyclone``.
* ``python -m cyclone`` (no args) — Click CLI (``cli.main``)
* ``python -m cyclone serve`` — start the FastAPI app on 127.0.0.1:8000
Honors the env vars:
* ``CYCLONE_PORT`` (default ``8000``)
* ``CYCLONE_RELOAD`` (default ``0``; set to ``1`` to enable uvicorn reload)
"""
from __future__ import annotations
import os
import sys
def main() -> None:
if len(sys.argv) >= 2 and sys.argv[1] == "serve":
port = os.environ.get("CYCLONE_PORT", "8000")
reload = os.environ.get("CYCLONE_RELOAD", "0") == "1"
sys.argv = [
sys.argv[0],
"cyclone.api:app",
"--host", "127.0.0.1",
"--port", port,
]
if reload:
sys.argv.append("--reload")
import uvicorn
uvicorn.main()
return
from cyclone.cli import main as cli_main
cli_main()
if __name__ == "__main__":
main()
- Step 2: Verify locally (manual)
Run: cd backend && .venv/bin/python -m cyclone serve &
Then: curl -s http://127.0.0.1:8000/api/health | jq . → should print {"status": "ok", "version": "0.1.0"}.
Then: curl -s http://0.0.0.0:8000/api/health → should fail (refused; loopback only).
Kill the server: kill %1 (or pkill -f 'cyclone.api:app').
- Step 3: Commit
git add backend/src/cyclone/__main__.py
git commit -m "feat(backend): bind serve to 127.0.0.1:8000, honor CYCLONE_PORT/CYCLONE_RELOAD"
Task 10: Run the full backend test suite + manual smoke
- Step 1: Run all backend tests
Run: cd backend && .venv/bin/pytest -v
Expected: all tests pass (existing 121 + new ~23 = 144+ total).
- Step 2: Manual smoke (in two terminals)
Terminal 1: cd backend && .venv/bin/python -m cyclone serve
Terminal 2: curl -s -X POST -F "file=@backend/tests/fixtures/co_medicaid_837p.txt" -H "Accept: application/json" http://127.0.0.1:8000/api/parse-837 | jq .summary
Then: curl -s 'http://127.0.0.1:8000/api/claims?status=submitted' | jq '{total, returned}'
Then: curl -s 'http://127.0.0.1:8000/api/claims' -H 'Accept: application/x-ndjson' | head -4
Expected: parse succeeds; /api/claims returns 2 items; NDJSON returns 2 item lines + 1 summary line.
- Step 3: Commit any leftover changes (likely none)
git status # should be clean
Phase 2 — Frontend
Task 11: Install @tanstack/react-query
Files:
-
Modify:
package.json -
Step 1: Add the dep
Run: cd /Users/openclaw/dev/cyclone && npm install @tanstack/react-query@^5.59.0
Expected: package.json updated; node_modules grows.
- Step 2: Commit
git add package.json package-lock.json
git commit -m "feat(frontend): add @tanstack/react-query ^5.59.0"
Task 12: Wrap <App /> in <QueryClientProvider>
Files:
-
Modify:
src/main.tsx -
Step 1: Update
main.tsx
Replace src/main.tsx with:
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: true,
},
},
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
);
- Step 2: Verify build still passes
Run: npm run build
Expected: build succeeds with no TS errors.
- Step 3: Commit
git add src/main.tsx
git commit -m "feat(frontend): wrap App in QueryClientProvider"
Task 13: Add 3 keyframes to tailwind.config.js
Files:
-
Modify:
tailwind.config.js -
Step 1: Add the keyframes
In tailwind.config.js, inside theme.extend.keyframes, add three new entries (next to the existing fade-in):
"shimmer": {
"0%": { transform: "translateX(-100%)" },
"100%": { transform: "translateX(100%)" },
},
"scan": {
"0%": { transform: "translateX(-100%)" },
"50%": { transform: "translateX(300%)" },
"100%": { transform: "translateX(-100%)" },
},
"row-flash": {
"0%": { backgroundColor: "hsl(var(--accent) / 0.10)" },
"100%": { backgroundColor: "transparent" },
},
- Step 2: Commit
git add tailwind.config.js
git commit -m "feat(frontend): add shimmer, scan, row-flash keyframes"
Task 14: <Skeleton /> UI primitive (spec 7.6)
Files:
-
Create:
src/components/ui/skeleton.tsx -
Step 1: Create the file
src/components/ui/skeleton.tsx:
import { cn } from "@/lib/utils";
type SkeletonProps = {
className?: string;
variant?: "row" | "card" | "kpi";
};
/**
* Loading skeleton with a hairline ring + a slow horizontal scanline.
* Voice: not a generic "pulse a gray block" — the shimmer reads as
* a precision-instrument sweep.
*/
export function Skeleton({ className, variant = "row" }: SkeletonProps) {
return (
<div
className={cn(
"relative overflow-hidden rounded-md bg-muted/30",
"ring-1 ring-inset ring-border/40",
className
)}
aria-busy="true"
aria-live="polite"
>
<div
className="absolute inset-0 animate-shimmer"
style={{
background:
"linear-gradient(90deg, transparent 0%, hsl(var(--muted-foreground) / 0.08) 50%, transparent 100%)",
}}
/>
{variant === "row" ? (
<div className="grid grid-cols-12 gap-3 p-4">
<div className="col-span-2 h-3 bg-muted/60 rounded" />
<div className="col-span-3 h-3 bg-muted/60 rounded" />
<div className="col-span-2 h-3 bg-muted/60 rounded" />
<div className="col-span-2 h-3 bg-muted/60 rounded" />
<div className="col-span-2 h-3 bg-muted/60 rounded" />
<div className="col-span-1 h-3 bg-muted/60 rounded" />
</div>
) : variant === "card" ? (
<div className="p-6 space-y-3">
<div className="h-4 w-1/2 bg-muted/60 rounded" />
<div className="h-3 w-1/3 bg-muted/60 rounded" />
<div className="h-3 w-2/3 bg-muted/60 rounded" />
</div>
) : (
<div className="p-5 space-y-3">
<div className="h-3 w-1/3 bg-muted/60 rounded" />
<div className="h-6 w-1/2 bg-muted/60 rounded" />
</div>
)}
</div>
);
}
- Step 2: Verify build
Run: npm run build
Expected: passes.
- Step 3: Commit
git add src/components/ui/skeleton.tsx
git commit -m "feat(frontend): add Skeleton UI primitive (hairline + scanline-shimmer)"
Task 15: <EmptyState /> UI primitive (spec 7.7.2)
Files:
-
Create:
src/components/ui/empty-state.tsx -
Step 1: Create the file
src/components/ui/empty-state.tsx:
import { CircleDashed } from "lucide-react";
type EmptyStateProps = {
/** All-caps instrument label, e.g. "Claims · inbox idle". */
eyebrow: string;
/** One-line plain-language message. No apologetic language. */
message: string;
};
export function EmptyState({ eyebrow, message }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center gap-2.5 py-14">
<div className="h-10 w-10 rounded-full ring-1 ring-inset ring-border/60 flex items-center justify-center text-muted-foreground">
<CircleDashed className="h-4 w-4" strokeWidth={1.5} />
</div>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
{eyebrow}
</div>
<div className="text-sm text-muted-foreground/80">{message}</div>
</div>
);
}
- Step 2: Verify build + commit
npm run build
git add src/components/ui/empty-state.tsx
git commit -m "feat(frontend): add EmptyState primitive (instrument-label eyebrow)"
Task 16: <ErrorState /> UI primitive (spec 7.7.3)
Files:
-
Create:
src/components/ui/error-state.tsx -
Step 1: Create the file
src/components/ui/error-state.tsx:
import { AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
type ErrorStateProps = {
error: Error;
onRetry: () => void;
};
export function ErrorState({ error, onRetry }: ErrorStateProps) {
return (
<div className="surface rounded-xl border border-destructive/30 p-4 flex items-center gap-3">
<AlertCircle
className="h-4 w-4 text-destructive shrink-0"
strokeWidth={1.75}
/>
<div className="flex-1 min-w-0">
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-destructive/80">
Fetch failed
</div>
<div className="text-sm text-foreground/90 truncate">
{error.message}
</div>
</div>
<Button variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
</div>
);
}
- Step 2: Verify build + commit
npm run build
git add src/components/ui/error-state.tsx
git commit -m "feat(frontend): add ErrorState primitive (destructive hairline + retry)"
Task 17: <FilterChips /> UI primitive (spec 7.7.5)
Files:
-
Create:
src/components/ui/filter-chips.tsx -
Step 1: Create the file
src/components/ui/filter-chips.tsx:
import { X } from "lucide-react";
export type FilterChip = { key: string; label: string };
type FilterChipsProps = {
active: FilterChip[];
onRemove: (key: string) => void;
onClearAll: () => void;
};
export function FilterChips({
active,
onRemove,
onClearAll,
}: FilterChipsProps) {
if (active.length === 0) return null;
return (
<div className="flex flex-wrap items-center gap-2 mt-4 pt-4 border-t border-border/40">
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Active
</span>
{active.map((c) => (
<span
key={c.key}
className="inline-flex items-center gap-1.5 rounded-md border border-border/60 bg-muted/30 pl-2.5 pr-1 py-0.5 text-xs"
>
<span className="text-muted-foreground">{c.key}:</span>
<span className="font-medium">{c.label}</span>
<button
type="button"
onClick={() => onRemove(c.key)}
className="ml-0.5 h-4 w-4 rounded-sm hover:bg-muted flex items-center justify-center"
aria-label={`Remove ${c.key} filter`}
>
<X className="h-3 w-3" strokeWidth={1.75} />
</button>
</span>
))}
{active.length > 1 ? (
<button
type="button"
onClick={onClearAll}
className="ml-auto text-[11px] text-muted-foreground hover:text-foreground transition-colors"
>
Clear all
</button>
) : null}
</div>
);
}
- Step 2: Verify build + commit
npm run build
git add src/components/ui/filter-chips.tsx
git commit -m "feat(frontend): add FilterChips primitive (read-only pill row)"
Task 18: <Pagination /> UI primitive (spec 7.7.8)
Files:
-
Create:
src/components/ui/pagination.tsx -
Step 1: Create the file
src/components/ui/pagination.tsx:
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
type PaginationProps = {
page: number; // 1-indexed
pageSize: number;
total: number;
onChange: (page: number) => void;
};
function buildPageList(current: number, last: number): (number | "…")[] {
if (last <= 7) return Array.from({ length: last }, (_, i) => i + 1);
const out: (number | "…")[] = [1];
const start = Math.max(2, current - 1);
const end = Math.min(last - 1, current + 1);
if (start > 2) out.push("…");
for (let i = start; i <= end; i++) out.push(i);
if (end < last - 1) out.push("…");
out.push(last);
return out;
}
export function Pagination({ page, pageSize, total, onChange }: PaginationProps) {
const last = Math.max(1, Math.ceil(total / pageSize));
if (last <= 1) return null;
const pages = buildPageList(page, last);
const returned = Math.min(pageSize, total - (page - 1) * pageSize);
return (
<div className="flex items-center justify-between gap-3 mt-4 pt-4 border-t border-border/40 text-xs">
<div className="text-muted-foreground num">
{returned} of {total}
</div>
<nav className="flex items-center gap-1" aria-label="Pagination">
<button
type="button"
onClick={() => onChange(Math.max(1, page - 1))}
disabled={page <= 1}
className="h-7 w-7 rounded-md hover:bg-muted/60 flex items-center justify-center text-muted-foreground disabled:opacity-40"
aria-label="Previous page"
>
<ChevronLeft className="h-3.5 w-3.5" strokeWidth={1.75} />
</button>
{pages.map((p, i) =>
p === "…" ? (
<span key={`e-${i}`} className="px-1 text-muted-foreground">
…
</span>
) : (
<button
key={p}
type="button"
onClick={() => onChange(p)}
aria-current={p === page ? "page" : undefined}
className={cn(
"relative h-7 min-w-7 px-2 rounded-md num text-xs flex items-center justify-center transition-colors",
p === page
? "bg-muted/60 text-foreground"
: "hover:bg-muted/40 text-muted-foreground"
)}
>
{p === page ? (
<span
aria-hidden
className="absolute left-0 top-1 bottom-1 w-px bg-accent rounded"
/>
) : null}
{p}
</button>
)
)}
<button
type="button"
onClick={() => onChange(Math.min(last, page + 1))}
disabled={page >= last}
className="h-7 w-7 rounded-md hover:bg-muted/60 flex items-center justify-center text-muted-foreground disabled:opacity-40"
aria-label="Next page"
>
<ChevronRight className="h-3.5 w-3.5" strokeWidth={1.75} />
</button>
</nav>
</div>
);
}
- Step 2: Verify build + commit
npm run build
git add src/components/ui/pagination.tsx
git commit -m "feat(frontend): add Pagination primitive (nav-active echo, num pages)"
Task 19: Layout-level refetch indicator (spec 7.7.4)
Files:
-
Modify:
src/components/Layout.tsx -
Step 1: Update
Layout.tsx
Replace src/components/Layout.tsx with:
import { Outlet } from "react-router-dom";
import { useIsFetching } from "@tanstack/react-query";
import { Sidebar } from "./Sidebar";
export function Layout() {
const isFetching = useIsFetching();
return (
<div className="relative min-h-screen z-10">
{isFetching > 0 ? (
<div
className="fixed top-0 left-0 right-0 z-50 h-px overflow-hidden pointer-events-none"
aria-hidden
>
<div
className="h-full w-1/3 bg-accent animate-scan"
style={{ boxShadow: "0 0 8px hsl(var(--accent) / 0.5)" }}
/>
</div>
) : null}
<Sidebar />
<main className="md:pl-60">
<div className="mx-auto max-w-[1400px] px-8 py-10">
<Outlet />
</div>
</main>
</div>
);
}
- Step 2: Verify build + commit
npm run build
git add src/components/Layout.tsx
git commit -m "feat(frontend): add 1px scan-line refetch indicator to Layout"
Task 20: Add GET methods to src/lib/api.ts
Files:
-
Modify:
src/lib/api.ts -
Create:
src/lib/api.test.ts(extend with 3 tests) -
Step 1: Add GET methods + types
In src/lib/api.ts, before the export const api = { line, add:
// ---------------------------------------------------------------------------
// GET endpoints (sub-project 1)
// ---------------------------------------------------------------------------
export type ListClaimsParams = {
batch_id?: string;
status?: string;
provider_npi?: string;
payer?: string;
date_from?: string;
date_to?: string;
sort?: string;
order?: "asc" | "desc";
limit?: number;
offset?: number;
};
export type ListRemittancesParams = {
batch_id?: string;
payer?: string;
claim_id?: string;
date_from?: string;
date_to?: string;
sort?: string;
order?: "asc" | "desc";
limit?: number;
offset?: number;
};
export type ListProvidersParams = {
npi?: string;
state?: string;
limit?: number;
offset?: number;
};
export type ListActivityParams = {
kind?: string;
since?: string;
limit?: number;
};
export type PaginatedResponse<T> = {
items: T[];
total: number;
returned: number;
has_more: boolean;
};
export type BatchSummary = {
id: string;
kind: "837p" | "835";
inputFilename: string;
parsedAt: string;
claimCount: number;
};
function qs(params: Record<string, unknown> | undefined): string {
if (!params) return "";
const u = new URLSearchParams();
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null || v === "") continue;
u.set(k, String(v));
}
const s = u.toString();
return s ? `?${s}` : "";
}
async function listBatches(): Promise<BatchSummary[]> {
const res = await fetch(joinUrl("/api/batches"));
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
}
const body = (await res.json()) as { items: BatchSummary[] };
return body.items;
}
async function getBatch(id: string): Promise<ParseResult837 | ParseResult835> {
const res = await fetch(joinUrl(`/api/batches/${id}`));
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
}
return (await res.json()) as ParseResult837 | ParseResult835;
}
async function listClaims<T = unknown>(
params: ListClaimsParams
): Promise<PaginatedResponse<T>> {
const res = await fetch(joinUrl(`/api/claims${qs(params as Record<string, unknown>)}`));
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
}
return (await res.json()) as PaginatedResponse<T>;
}
async function listRemittances<T = unknown>(
params: ListRemittancesParams
): Promise<PaginatedResponse<T>> {
const res = await fetch(joinUrl(`/api/remittances${qs(params as Record<string, unknown>)}`));
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
}
return (await res.json()) as PaginatedResponse<T>;
}
async function listProviders<T = unknown>(
params: ListProvidersParams = {}
): Promise<PaginatedResponse<T>> {
const res = await fetch(joinUrl(`/api/providers${qs(params as Record<string, unknown>)}`));
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
}
return (await res.json()) as PaginatedResponse<T>;
}
async function listActivity<T = unknown>(
params: ListActivityParams = {}
): Promise<PaginatedResponse<T>> {
const res = await fetch(joinUrl(`/api/activity${qs(params as Record<string, unknown>)}`));
if (!res.ok) {
const detail = await readErrorBody(res);
throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`);
}
return (await res.json()) as PaginatedResponse<T>;
Then update the export const api = { block to add the new methods:
export const api = {
isConfigured,
baseUrl: BASE_URL,
health,
parse837,
parse835,
listBatches,
getBatch,
listClaims,
listRemittances,
listProviders,
listActivity,
// Legacy placeholders (removed in a follow-up):
getClaims: () => legacyNotImplemented("getClaims"),
createClaim: (_payload: unknown) => legacyNotImplemented("createClaim"),
getRemittances: () => legacyNotImplemented("getRemittances"),
getProviders: () => legacyNotImplemented("getProviders"),
getActivity: () => legacyNotImplemented("getActivity"),
};
- Step 2: Add 3 unit tests in
src/lib/api.test.ts
Create src/lib/api.test.ts:
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { api } from "./api";
const originalFetch = global.fetch;
describe("api GET helpers", () => {
beforeEach(() => {
global.fetch = vi.fn();
});
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
it("listBatches hits /api/batches", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
new Response(JSON.stringify({ items: [] }), { status: 200 })
);
const out = await api.listBatches();
expect(out).toEqual([]);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining("/api/batches")
);
});
it("listClaims builds the right URL with status + sort", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
new Response(JSON.stringify({ items: [], total: 0 }), { status: 200 })
);
await api.listClaims({ status: "submitted", sort: "billedAmount", order: "desc" });
const called = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
expect(called).toContain("/api/claims?");
expect(called).toContain("status=submitted");
expect(called).toContain("sort=billedAmount");
expect(called).toContain("order=desc");
});
it("getBatch returns the result with the right id", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
new Response(
JSON.stringify({ envelope: null, claims: [], summary: {}, kind: "835" }),
{ status: 200 }
)
);
const out = await api.getBatch("abc");
expect(out).toBeTruthy();
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining("/api/batches/abc")
);
});
});
If
vitestis not already configured, add"test": "vitest run"topackage.jsonscriptsandvitestto devDependencies. Runnpm install -D vitestif needed.
- Step 3: Run the tests
Run: npm test -- src/lib/api.test.ts
Expected: 3 tests pass.
- Step 4: Commit
git add src/lib/api.ts src/lib/api.test.ts package.json package-lock.json
git commit -m "feat(frontend): add listBatches/getBatch/listClaims/listRemittances/listProviders/listActivity"
Task 21: Add 6 query/mutation hooks
Files:
-
Create:
src/hooks/useBatches.ts -
Create:
src/hooks/useClaims.ts -
Create:
src/hooks/useRemittances.ts -
Create:
src/hooks/useProviders.ts -
Create:
src/hooks/useActivity.ts -
Create:
src/hooks/useParse.ts -
Step 1:
useBatches.ts
import { useQuery } from "@tanstack/react-query";
import { api, type BatchSummary } from "@/lib/api";
export function useBatches() {
return useQuery<BatchSummary[]>({
queryKey: ["batches"],
queryFn: () => api.listBatches(),
enabled: api.isConfigured,
});
}
- Step 2:
useClaims.ts(with fallback to zustand when!api.isConfigured)
import { useQuery } from "@tanstack/react-query";
import { useSyncExternalStore } from "react";
import { api, type ListClaimsParams, type PaginatedResponse } from "@/lib/api";
import { useAppStore } from "@/store";
import type { Claim } from "@/types";
const EMPTY: PaginatedResponse<Claim> = {
items: [], total: 0, returned: 0, has_more: false,
};
export function useClaims(params: ListClaimsParams) {
const fallback = useSyncExternalStore(
(cb) => useAppStore.subscribe(cb),
() => useAppStore.getState().claims,
() => useAppStore.getState().claims,
);
const q = useQuery<PaginatedResponse<Claim>>({
queryKey: ["claims", params],
queryFn: () => api.listClaims<Claim>(params),
enabled: api.isConfigured,
});
if (!api.isConfigured) {
const all = fallback;
const filtered = all.filter((c) => {
if (params.status && c.status !== params.status) return false;
if (params.provider_npi && c.providerNpi !== params.provider_npi) return false;
if (params.batch_id && c.batchId !== params.batch_id) return false;
return true;
});
const offset = params.offset ?? 0;
const limit = params.limit ?? 100;
return {
data: {
items: filtered.slice(offset, offset + limit),
total: filtered.length,
returned: Math.min(limit, filtered.length - offset),
has_more: filtered.length > offset + limit,
} satisfies PaginatedResponse<Claim>,
isLoading: false,
isError: false,
error: null,
refetch: () => Promise.resolve(),
} as const;
}
return q;
}
export { EMPTY as EMPTY_CLAIMS };
- Step 3:
useRemittances.ts
import { useQuery } from "@tanstack/react-query";
import { useSyncExternalStore } from "react";
import { api, type ListRemittancesParams, type PaginatedResponse } from "@/lib/api";
import { useAppStore } from "@/store";
import type { Remittance } from "@/types";
export function useRemittances(params: ListRemittancesParams) {
const fallback = useSyncExternalStore(
(cb) => useAppStore.subscribe(cb),
() => useAppStore.getState().remittances,
() => useAppStore.getState().remittances,
);
const q = useQuery<PaginatedResponse<Remittance>>({
queryKey: ["remittances", params],
queryFn: () => api.listRemittances<Remittance>(params),
enabled: api.isConfigured,
});
if (!api.isConfigured) {
const offset = params.offset ?? 0;
const limit = params.limit ?? 100;
return {
data: {
items: fallback.slice(offset, offset + limit),
total: fallback.length,
returned: Math.min(limit, fallback.length - offset),
has_more: fallback.length > offset + limit,
} satisfies PaginatedResponse<Remittance>,
isLoading: false, isError: false, error: null,
refetch: () => Promise.resolve(),
} as const;
}
return q;
}
- Step 4:
useProviders.ts
import { useQuery } from "@tanstack/react-query";
import { useSyncExternalStore } from "react";
import { api, type ListProvidersParams, type PaginatedResponse } from "@/lib/api";
import { useAppStore } from "@/store";
import type { Provider } from "@/types";
export function useProviders(params: ListProvidersParams = {}) {
const fallback = useSyncExternalStore(
(cb) => useAppStore.subscribe(cb),
() => useAppStore.getState().providers,
() => useAppStore.getState().providers,
);
const q = useQuery<PaginatedResponse<Provider>>({
queryKey: ["providers", params],
queryFn: () => api.listProviders<Provider>(params),
enabled: api.isConfigured,
});
if (!api.isConfigured) {
return {
data: { items: fallback, total: fallback.length, returned: fallback.length, has_more: false } satisfies PaginatedResponse<Provider>,
isLoading: false, isError: false, error: null,
refetch: () => Promise.resolve(),
} as const;
}
return q;
}
- Step 5:
useActivity.ts
import { useQuery } from "@tanstack/react-query";
import { useSyncExternalStore } from "react";
import { api, type ListActivityParams, type PaginatedResponse } from "@/lib/api";
import { useAppStore } from "@/store";
import type { Activity } from "@/types";
export function useActivity(params: ListActivityParams = {}) {
const fallback = useSyncExternalStore(
(cb) => useAppStore.subscribe(cb),
() => useAppStore.getState().activity,
() => useAppStore.getState().activity,
);
const q = useQuery<PaginatedResponse<Activity>>({
queryKey: ["activity", params],
queryFn: () => api.listActivity<Activity>(params),
enabled: api.isConfigured,
refetchInterval: api.isConfigured ? 30_000 : false,
});
if (!api.isConfigured) {
return {
data: { items: fallback, total: fallback.length, returned: fallback.length, has_more: false } satisfies PaginatedResponse<Activity>,
isLoading: false, isError: false, error: null,
refetch: () => Promise.resolve(),
} as const;
}
return q;
}
- Step 6:
useParse.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api, type ParseOptions } from "@/lib/api";
export function useParse(kind: "837p" | "835") {
const qc = useQueryClient();
return useMutation({
mutationFn: (opts: { file: File; options?: ParseOptions }) =>
kind === "837p"
? api.parse837(opts.file, opts.options)
: api.parse835(opts.file, opts.options),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["batches"] });
qc.invalidateQueries({ queryKey: ["claims"] });
qc.invalidateQueries({ queryKey: ["remittances"] });
qc.invalidateQueries({ queryKey: ["providers"] });
qc.invalidateQueries({ queryKey: ["activity"] });
},
});
}
- Step 7: Verify build + commit
npm run build
git add src/hooks/
git commit -m "feat(frontend): add 6 query/mutation hooks (batches, claims, remits, providers, activity, parse)"
Task 22: Refactor Claims.tsx to use useClaims + new UX patterns
Files:
-
Modify:
src/pages/Claims.tsx -
Step 1: Replace the file
Replace src/pages/Claims.tsx with:
import { useMemo, useRef, useState } from "react";
import { Search, X } from "lucide-react";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ClaimStatusBadge } from "@/components/StatusBadge";
import { NewClaimDialog } from "@/components/NewClaimDialog";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import { FilterChips } from "@/components/ui/filter-chips";
import { Pagination } from "@/components/ui/pagination";
import { useClaims } from "@/hooks/useClaims";
import { useAppStore } from "@/store";
import { fmt } from "@/lib/format";
import type { ClaimStatus } from "@/types";
const ALL = "all" as const;
const statuses: (ClaimStatus | typeof ALL)[] = [
ALL,
"submitted",
"accepted",
"denied",
"paid",
"pending",
];
const PAGE_SIZE = 25;
export function Claims() {
const [status, setStatus] = useState<ClaimStatus | typeof ALL>(ALL);
const [npi, setNpi] = useState<string>(ALL);
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const searchRef = useRef<HTMLInputElement>(null);
const providers = useAppStore((s) => s.providers);
const providerMap = useMemo(
() => Object.fromEntries(providers.map((p) => [p.npi, p])),
[providers]
);
const params = {
status: status === ALL ? undefined : status,
provider_npi: npi === ALL ? undefined : npi,
sort: "billedAmount",
order: "desc" as const,
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
};
const { data, isLoading, isError, error, refetch } = useClaims(params);
const items = data?.items ?? [];
const activeChips = [
status !== ALL ? { key: "Status", label: status } : null,
npi !== ALL
? {
key: "NPI",
label: `${npi}${providerMap[npi] ? ` · ${providerMap[npi]!.name}` : ""}`,
}
: null,
].filter(Boolean) as { key: string; label: string }[];
const totals = useMemo(
() => ({
billed: items.reduce((s, c) => s + c.billedAmount, 0),
received: items.reduce((s, c) => s + c.receivedAmount, 0),
}),
[items]
);
// Local search filter on the current page (cheap; no extra fetch).
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter(
(c) =>
c.id.toLowerCase().includes(q) ||
c.patientName.toLowerCase().includes(q) ||
c.payerName.toLowerCase().includes(q) ||
c.cptCode.toLowerCase().includes(q)
);
}, [items, query]);
function clearFilter(key: string) {
if (key === "Status") setStatus(ALL);
if (key === "NPI") setNpi(ALL);
setPage(1);
}
return (
<div className="space-y-8 animate-fade-in">
<header className="flex items-end justify-between gap-6 flex-wrap">
<div>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
<span className="inline-block h-px w-6 bg-border" />
Claims
</div>
<h1 className="text-[22px] font-semibold tracking-tight">All claims</h1>
</div>
<NewClaimDialog />
</header>
{isError && error ? (
<ErrorState error={error as Error} onRetry={() => refetch()} />
) : null}
<div className="surface rounded-xl p-4">
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[240px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={searchRef}
placeholder="Search by claim ID, patient, payer, CPT…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 pr-16"
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground p-1.5 rounded-md"
aria-label="Clear search"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
<Select
value={status}
onValueChange={(v) => {
setStatus(v as ClaimStatus | typeof ALL);
setPage(1);
}}
>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{statuses.map((s) => (
<SelectItem key={s} value={s}>
{s === ALL ? "All statuses" : s[0]!.toUpperCase() + s.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={npi}
onValueChange={(v) => {
setNpi(v);
setPage(1);
}}
>
<SelectTrigger className="w-[240px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All providers</SelectItem>
{providers.map((p) => (
<SelectItem key={p.npi} value={p.npi}>
{p.npi} — {p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<FilterChips
active={activeChips}
onRemove={clearFilter}
onClearAll={() => {
setStatus(ALL);
setNpi(ALL);
setPage(1);
}}
/>
<div className="flex items-center gap-6 mt-4 pt-4 border-t border-border/40 text-xs text-muted-foreground">
<span>
<span className="num text-foreground font-medium">
{fmt.num(data?.total ?? 0)}
</span>{" "}
claims
</span>
<span>
Billed{" "}
<span className="num text-foreground font-medium">
{fmt.usd(totals.billed)}
</span>
</span>
<span>
Received{" "}
<span className="num text-foreground font-medium">
{fmt.usd(totals.received)}
</span>
</span>
</div>
</div>
<div className="surface rounded-xl overflow-hidden">
{isLoading ? (
<div className="p-4 space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} variant="row" />
))}
</div>
) : items.length === 0 ? (
<EmptyState
eyebrow="Claims · inbox idle"
message={
status !== ALL || npi !== ALL
? "No claims match the current filters."
: "Drop an 837P file on the Upload page to populate this list."
}
/>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Claim</TableHead>
<TableHead>Patient</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Payer</TableHead>
<TableHead className="text-right">Billed</TableHead>
<TableHead className="text-right">Received</TableHead>
<TableHead>Status</TableHead>
<TableHead>Submitted</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map((c) => {
const provider = providerMap[c.providerNpi];
return (
<TableRow
key={c.id}
data-newer-than={c.parsedAt}
className="data-[newer-than]:animate-row-flash"
>
<TableCell>
<div className="display num text-[13px]">{c.id}</div>
<div className="text-[11px] text-muted-foreground num">
CPT {c.cptCode}
</div>
</TableCell>
<TableCell className="font-medium">{c.patientName}</TableCell>
<TableCell>
<div className="text-sm">{provider?.name ?? "Unknown"}</div>
<div className="text-[11px] text-muted-foreground num">
{c.providerNpi}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{c.payerName}
</TableCell>
<TableCell className="text-right display num">
{fmt.usd(c.billedAmount)}
</TableCell>
<TableCell className="text-right display num text-muted-foreground">
{c.receivedAmount > 0 ? fmt.usd(c.receivedAmount) : "—"}
</TableCell>
<TableCell>
<ClaimStatusBadge status={c.status} />
</TableCell>
<TableCell className="text-muted-foreground text-[13px] num">
{fmt.dateShort(c.submissionDate)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<div className="px-4 pb-4">
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={data?.total ?? 0}
onChange={setPage}
/>
</div>
</>
)}
</div>
</div>
);
}
- Step 2: Verify build + commit
npm run build
git add src/pages/Claims.tsx
git commit -m "feat(frontend): refactor Claims page to useClaims + skeleton/empty/error/chips/pagination"
Task 23: Refactor Remittances.tsx
Files:
-
Modify:
src/pages/Remittances.tsx -
Step 1: Replace the file
Replace src/pages/Remittances.tsx with:
import { useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { RemitStatusBadge } from "@/components/StatusBadge";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import { Pagination } from "@/components/ui/pagination";
import { useRemittances } from "@/hooks/useRemittances";
import { fmt } from "@/lib/format";
const PAGE_SIZE = 25;
export function Remittances() {
const [page, setPage] = useState(1);
const { data, isLoading, isError, error, refetch } = useRemittances({
sort: "receivedDate",
order: "desc",
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
});
const items = data?.items ?? [];
const total = items.reduce(
(acc, r) => ({
paid: acc.paid + r.paidAmount,
adjustments: acc.adjustments + r.adjustmentAmount,
}),
{ paid: 0, adjustments: 0 }
);
return (
<div className="space-y-8 animate-fade-in">
<header>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
<span className="inline-block h-px w-6 bg-border" />
Remittances
</div>
<h1 className="text-[22px] font-semibold tracking-tight">835 remittances</h1>
<p className="text-muted-foreground mt-1.5 text-[14px]">
Electronic remittance advice from payers, matched to submitted claims.
</p>
</header>
{isError && error ? (
<ErrorState error={error as Error} onRetry={() => refetch()} />
) : null}
<div className="grid gap-4 grid-cols-2 lg:grid-cols-3">
<div className="surface rounded-xl p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
Remits
</div>
<div className="display text-[28px] leading-none mt-3">
{fmt.num(data?.total ?? 0)}
</div>
</div>
<div className="surface rounded-xl p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
Total paid
</div>
<div className="display text-[28px] leading-none mt-3">{fmt.usd(total.paid)}</div>
</div>
<div className="surface rounded-xl p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
Adjustments
</div>
<div className="display text-[28px] leading-none mt-3">{fmt.usd(total.adjustments)}</div>
</div>
</div>
<div className="surface rounded-xl overflow-hidden">
{isLoading ? (
<div className="p-4 space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} variant="row" />
))}
</div>
) : items.length === 0 ? (
<EmptyState
eyebrow="Remittances · awaiting first 835"
message="Drop an 835 file on the Upload page to populate this list."
/>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Remit</TableHead>
<TableHead>Claim</TableHead>
<TableHead>Payer</TableHead>
<TableHead className="text-right">Paid</TableHead>
<TableHead className="text-right">Adjustment</TableHead>
<TableHead>Status</TableHead>
<TableHead>Received</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((r) => (
<TableRow
key={r.id}
data-newer-than={r.parsedAt}
className="data-[newer-than]:animate-row-flash"
>
<TableCell className="display num text-[13px]">{r.id}</TableCell>
<TableCell className="display num text-[13px] text-muted-foreground">
{r.claimId}
</TableCell>
<TableCell>{r.payerName}</TableCell>
<TableCell className="text-right display num">
{fmt.usdPrecise(r.paidAmount)}
</TableCell>
<TableCell className="text-right display num text-muted-foreground">
{fmt.usdPrecise(r.adjustmentAmount)}
</TableCell>
<TableCell>
<RemitStatusBadge status={r.status} />
</TableCell>
<TableCell className="text-muted-foreground num text-[13px]">
{fmt.dateShort(r.receivedDate)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="px-4 pb-4">
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={data?.total ?? 0}
onChange={setPage}
/>
</div>
</>
)}
</div>
</div>
);
}
- Step 2: Verify build + commit
npm run build
git add src/pages/Remittances.tsx
git commit -m "feat(frontend): refactor Remittances page to useRemittances + UX patterns"
Task 24: Refactor Providers.tsx
Files:
-
Modify:
src/pages/Providers.tsx -
Step 1: Replace the file
Replace src/pages/Providers.tsx with:
import { Building2, MapPin, Phone } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import { useProviders } from "@/hooks/useProviders";
import { fmt } from "@/lib/format";
export function Providers() {
const { data, isLoading, isError, error, refetch } = useProviders();
const items = data?.items ?? [];
return (
<div className="space-y-8 animate-fade-in">
<header>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
<span className="inline-block h-px w-6 bg-border" />
Providers
</div>
<h1 className="text-[22px] font-semibold tracking-tight">Provider directory</h1>
<p className="text-muted-foreground mt-1.5 text-[14px]">
NPIs registered with the clearinghouse for 837P submission and 835
remittance retrieval.
</p>
</header>
{isError && error ? (
<ErrorState error={error as Error} onRetry={() => refetch()} />
) : null}
{isLoading ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} variant="card" />
))}
</div>
) : items.length === 0 ? (
<div className="surface rounded-xl">
<EmptyState
eyebrow="Providers · directory empty"
message="Providers appear here as soon as an 837P file is parsed."
/>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{items.map((p) => (
<article
key={p.npi}
data-newer-than={undefined}
className="surface rounded-xl p-6 flex flex-col gap-4 hover:bg-muted/20 transition-colors"
>
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-lg bg-muted/60 flex items-center justify-center text-foreground">
<Building2 className="h-5 w-5" strokeWidth={1.5} />
</div>
<div className="min-w-0 flex-1">
<h3 className="text-[15px] font-semibold tracking-tight">
{p.name}
</h3>
<p className="text-[11px] text-muted-foreground num mt-0.5">
NPI {p.npi} · TIN {p.taxId}
</p>
</div>
</div>
<div className="space-y-1.5 text-[13px] text-muted-foreground">
<div className="flex items-center gap-2">
<MapPin className="h-3.5 w-3.5" strokeWidth={1.75} />
<span>
{p.address}, {p.city}, {p.state} {p.zip}
</span>
</div>
<div className="flex items-center gap-2">
<Phone className="h-3.5 w-3.5" strokeWidth={1.75} />
<span className="num">{p.phone}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-border/40">
<div>
<div className="text-[11px] uppercase tracking-wider text-muted-foreground">
Claims
</div>
<div className="display text-[20px] leading-none mt-1.5">
{fmt.num(p.claimCount)}
</div>
</div>
<div>
<div className="text-[11px] uppercase tracking-wider text-muted-foreground">
Outstanding AR
</div>
<div className="display text-[20px] leading-none mt-1.5">
{fmt.usd(p.outstandingAr)}
</div>
</div>
</div>
</article>
))}
</div>
)}
</div>
);
}
- Step 2: Verify build + commit
npm run build
git add src/pages/Providers.tsx
git commit -m "feat(frontend): refactor Providers page to useProviders + UX patterns"
Task 25: Refactor ActivityLog.tsx
Files:
-
Modify:
src/pages/ActivityLog.tsx -
Step 1: Replace the file
Replace src/pages/ActivityLog.tsx with:
import { useActivity } from "@/hooks/useActivity";
import { ActivityFeed } from "@/components/ActivityFeed";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
export function ActivityLog() {
const { data, isLoading, isError, error, refetch } = useActivity({ limit: 200 });
const items = data?.items ?? [];
return (
<div className="space-y-8 animate-fade-in">
<header>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
<span className="inline-block h-px w-6 bg-border" />
Activity
</div>
<h1 className="text-[22px] font-semibold tracking-tight">Activity log</h1>
<p className="text-muted-foreground mt-1.5 text-[14px]">
Every claim submission, denial, payment, and provider event, in
reverse chronological order. Auto-refreshes every 30s.
</p>
</header>
{isError && error ? (
<ErrorState error={error as Error} onRetry={() => refetch()} />
) : null}
<div className="surface rounded-xl p-6">
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} variant="row" />
))}
</div>
) : items.length === 0 ? (
<EmptyState
eyebrow="Activity · log idle"
message="Activity will appear here after the first parse."
/>
) : (
<ActivityFeed items={items} emptyMessage="No activity recorded yet." />
)}
</div>
</div>
);
}
- Step 2: Verify build + commit
npm run build
git add src/pages/ActivityLog.tsx
git commit -m "feat(frontend): refactor ActivityLog page to useActivity + UX patterns"
Task 26: Refactor Upload.tsx to use useParse mutation
Files:
-
Modify:
src/pages/Upload.tsx -
Step 1: Replace the file
Replace src/pages/Upload.tsx with:
import { useMemo, useRef, useState } from "react";
import {
AlertTriangle,
CheckCircle2,
ChevronRight,
FileText,
Loader2,
Upload as UploadIcon,
XCircle,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api, type ParseProgress } from "@/lib/api";
import { fmt, toNum } from "@/lib/format";
import { useAppStore } from "@/store";
import { useParse } from "@/hooks/useParse";
import type {
ClaimOutput,
ClaimPayment,
ParsedBatch,
ParsedBatchKind,
ServiceLine,
ServicePayment,
} from "@/types";
import { cn } from "@/lib/utils";
type StreamedClaim837 = { kind: "837p"; data: ClaimOutput };
type StreamedClaim835 = { kind: "835"; data: ClaimPayment };
type StreamedClaim = StreamedClaim837 | StreamedClaim835;
interface StreamState {
items: StreamedClaim[];
expectedTotal: number | null;
passed: number;
failed: number;
}
const PAYERS_837 = [
{ value: "co_medicaid", label: "CO Medicaid" },
{ value: "generic_837p", label: "Generic 837P" },
];
const PAYERS_835 = [
{ value: "co_medicaid_835", label: "CO Medicaid" },
{ value: "generic_835", label: "Generic 835" },
];
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
function ValidationDot({
passed,
hasWarnings,
}: { passed: boolean; hasWarnings?: boolean }) {
if (passed)
return (
<span className="inline-flex items-center gap-1.5 text-[11px] text-[hsl(var(--success))] font-medium">
<CheckCircle2 className="h-3.5 w-3.5" strokeWidth={1.75} />
Passed
</span>
);
if (hasWarnings)
return (
<span className="inline-flex items-center gap-1.5 text-[11px] text-[hsl(var(--warning))] font-medium">
<AlertTriangle className="h-3.5 w-3.5" strokeWidth={1.75} />
Warnings
</span>
);
return (
<span className="inline-flex items-center gap-1.5 text-[11px] text-destructive font-medium">
<XCircle className="h-3.5 w-3.5" strokeWidth={1.75} />
Failed
</span>
);
}
function StatPill({ label, value, mono = true }: { label: string; value: React.ReactNode; mono?: boolean }) {
return (
<div className="flex flex-col gap-0.5">
<div className="text-[10.5px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">{label}</div>
<div className={cn("text-[13px] text-foreground", mono && "display num")}>{value}</div>
</div>
);
}
function ClaimCard837({ claim }: { claim: ClaimOutput }) {
const [open, setOpen] = useState(false);
const passed = claim.validation.passed;
const hasWarnings = claim.validation.warnings.length > 0;
return (
<div className="surface rounded-lg overflow-hidden">
<button type="button" onClick={() => setOpen((v) => !v)}
className="w-full text-left px-4 py-3 hover:bg-muted/30 transition-colors" aria-expanded={open}>
<div className="flex items-center gap-3">
<ChevronRight className={cn("h-3.5 w-3.5 text-muted-foreground transition-transform shrink-0", open && "rotate-90")} strokeWidth={1.75} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 flex-wrap">
<span className="display num text-[13px]">{claim.claim_id}</span>
<span className="text-[12px] text-muted-foreground truncate">{claim.subscriber.first_name} {claim.subscriber.last_name}</span>
<span className="text-[12px] text-muted-foreground">· {claim.payer.name}</span>
</div>
<div className="flex items-center gap-3 mt-1 text-[11px] text-muted-foreground num">
<span>NPI {claim.billing_provider.npi}</span>
<span>·</span>
<span>{claim.service_lines.length} line{claim.service_lines.length === 1 ? "" : "s"}</span>
<span>·</span>
<span>{claim.diagnoses.length} dx</span>
</div>
</div>
<div className="text-right shrink-0">
<div className="display num text-[14px]">{fmt.usdDecimal(claim.claim.total_charge)}</div>
<div className="mt-0.5"><ValidationDot passed={passed} hasWarnings={hasWarnings} /></div>
</div>
</div>
</button>
{open ? (
<div className="border-t border-border/40 px-4 py-3 grid gap-4 bg-muted/20 animate-fade-in">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatPill label="Member" value={claim.subscriber.member_id} />
<StatPill label="Place of service" value={claim.claim.place_of_service ?? "—"} />
<StatPill label="Frequency" value={claim.claim.frequency_code ?? "—"} />
<StatPill label="Prior auth" value={claim.claim.prior_auth ?? "—"} />
</div>
</div>
) : null}
</div>
);
}
function ClaimCard835({ claim }: { claim: ClaimPayment }) {
const [open, setOpen] = useState(false);
const passed = claim.service_payments.length > 0;
return (
<div className="surface rounded-lg overflow-hidden">
<button type="button" onClick={() => setOpen((v) => !v)}
className="w-full text-left px-4 py-3 hover:bg-muted/30 transition-colors" aria-expanded={open}>
<div className="flex items-center gap-3">
<ChevronRight className={cn("h-3.5 w-3.5 text-muted-foreground transition-transform shrink-0", open && "rotate-90")} strokeWidth={1.75} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 flex-wrap">
<span className="display num text-[13px]">{claim.payer_claim_control_number}</span>
<span className="text-[12px] text-muted-foreground">{claim.status_label ?? `Status ${claim.status_code}`}</span>
</div>
<div className="flex items-center gap-3 mt-1 text-[11px] text-muted-foreground num">
<span>CLP {claim.status_code}</span>
<span>·</span>
<span>{claim.service_payments.length} svc</span>
</div>
</div>
<div className="text-right shrink-0">
<div className="display num text-[14px] text-[hsl(var(--success))]">{fmt.usdDecimal(claim.total_paid)}</div>
<div className="text-[11px] text-muted-foreground num">of {fmt.usdDecimal(claim.total_charge)}</div>
<div className="mt-0.5"><ValidationDot passed={passed} /></div>
</div>
</div>
</button>
</div>
);
}
export function Upload() {
const inputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [kind, setKind] = useState<ParsedBatchKind>("837p");
const [payer, setPayer] = useState<string>(PAYERS_837[0]!.value);
const [stream, setStream] = useState<StreamState>({
items: [], expectedTotal: null, passed: 0, failed: 0,
});
const addParsedBatch = useAppStore((s) => s.addParsedBatch);
const parsedBatches = useAppStore((s) => s.parsedBatches);
const parseMutation = useParse(kind);
const payerOptions = kind === "837p" ? PAYERS_837 : PAYERS_835;
const totalSoFar = stream.items.length;
const progressPct = useMemo(() => {
if (!stream.expectedTotal || stream.expectedTotal <= 0) return 0;
return Math.min(100, Math.round((totalSoFar / stream.expectedTotal) * 100));
}, [stream.expectedTotal, totalSoFar]);
function pickFile(f: File | null) {
setFile(f);
setStream({ items: [], expectedTotal: null, passed: 0, failed: 0 });
}
async function onParse() {
if (!file) return;
if (!api.isConfigured) {
toast.error("Backend not configured. Set VITE_API_BASE_URL in .env.local to use the Upload page.");
return;
}
setStream({ items: [], expectedTotal: null, passed: 0, failed: 0 });
const items: StreamedClaim[] = [];
const onProgress: ParseProgress = (event) => {
if (event.type === "claim") {
items.push({ kind: "837p", data: event.data });
setStream((s) => ({ ...s, items: [...items] }));
} else if (event.type === "claim_payment") {
items.push({ kind: "835", data: event.data });
setStream((s) => ({ ...s, items: [...items] }));
} else if (event.type === "summary") {
setStream((s) => ({
...s,
expectedTotal: event.data.total_claims,
passed: event.data.passed,
failed: event.data.failed,
}));
}
};
try {
const summary = await parseMutation.mutateAsync({
file,
options: { payer, onProgress },
});
if (summary && typeof summary === "object" && "total_claims" in summary) {
const claimIds = items.map((c) => c.kind === "837p" ? c.data.claim_id : c.data.payer_claim_control_number).filter(Boolean);
const batch: ParsedBatch = {
id: `BATCH-${Date.now()}`,
kind,
inputFilename: file.name,
parsedAt: new Date().toISOString(),
claimCount: summary.total_claims,
passed: summary.passed,
failed: summary.failed,
claimIds,
summary,
};
addParsedBatch(batch);
toast.success(`Parsed ${summary.total_claims} ${kind === "837p" ? "claims" : "payments"} · ${summary.passed} passed · ${summary.failed} failed`, {
description: file.name,
});
}
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to parse file");
}
}
return (
<div className="space-y-8 animate-fade-in">
<header>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
<span className="inline-block h-px w-6 bg-border" />
Upload · EDI parser
</div>
<h1 className="text-[22px] font-semibold tracking-tight">Parse an X12 file</h1>
<p className="text-muted-foreground mt-1.5 text-[14px] max-w-2xl">
Upload an 837P professional claim or 835 ERA remittance file. The parser streams claims back as they're produced so the UI updates in real time.
</p>
</header>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-[14px] flex items-center gap-2">
<UploadIcon className="h-3.5 w-3.5 text-muted-foreground" strokeWidth={1.75} />
File
</CardTitle>
<CardDescription>
.txt (X12 837P or 835). The file is sent to the FastAPI backend configured by <code className="text-[12px]">VITE_API_BASE_URL</code>.
</CardDescription>
</CardHeader>
<CardContent className="pt-0 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="grid gap-1.5">
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Kind</div>
<Select value={kind} onValueChange={(v) => {
const next = v as ParsedBatchKind;
setKind(next);
setPayer(next === "837p" ? PAYERS_837[0]!.value : PAYERS_835[0]!.value);
}}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="837p">837P (Professional claim)</SelectItem>
<SelectItem value="835">835 (ERA remittance)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Payer config</div>
<Select value={payer} onValueChange={setPayer}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{payerOptions.map((p) => (
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => { e.preventDefault(); const f = e.dataTransfer.files?.[0]; if (f) pickFile(f); }}
onClick={() => inputRef.current?.click()}
role="button" tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") inputRef.current?.click(); }}
className={cn(
"relative flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border/60 bg-muted/20 px-6 py-8 cursor-pointer transition-colors",
"hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
)}
>
<UploadIcon className="h-5 w-5 text-muted-foreground" strokeWidth={1.5} />
{file ? (
<div className="flex items-center gap-2 text-sm">
<FileText className="h-3.5 w-3.5 text-muted-foreground" strokeWidth={1.75} />
<span className="font-medium">{file.name}</span>
<span className="text-muted-foreground num">· {formatBytes(file.size)}</span>
</div>
) : (
<>
<div className="text-sm font-medium">Drop a file here, or click to choose</div>
<div className="text-[12px] text-muted-foreground">.txt — the X12 837/835 file as exported from your clearinghouse</div>
</>
)}
<input ref={inputRef} type="file" accept=".txt" className="sr-only"
onChange={(e) => pickFile(e.target.files?.[0] ?? null)} />
</div>
<div className="flex items-center justify-between gap-3">
<div className="text-[12px] text-muted-foreground">
{api.isConfigured ? (
<span className="inline-flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-[hsl(var(--success))]" />
Backend ready · {api.baseUrl}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-[hsl(var(--warning))]">
<AlertTriangle className="h-3 w-3" strokeWidth={1.75} />
No backend configured — set VITE_API_BASE_URL to enable parsing
</span>
)}
</div>
<div className="flex items-center gap-2">
{file ? (
<Button variant="ghost" onClick={() => pickFile(null)} disabled={parseMutation.isPending}>Clear</Button>
) : null}
<Button onClick={onParse} disabled={!file || parseMutation.isPending}>
{parseMutation.isPending ? (
<><Loader2 className="h-3.5 w-3.5 animate-spin" /> Parsing…</>
) : (
<><UploadIcon className="h-3.5 w-3.5" /> Parse</>
)}
</Button>
</div>
</div>
</CardContent>
</Card>
{(parseMutation.isPending || stream.items.length > 0) ? (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-[14px] flex items-center gap-2">
{parseMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" strokeWidth={1.75} />
) : (
<CheckCircle2 className="h-3.5 w-3.5 text-[hsl(var(--success))]" strokeWidth={1.75} />
)}
{kind === "837p" ? "Claims" : "Claim payments"}
</CardTitle>
<p className="text-xs text-muted-foreground mt-1">
{totalSoFar} of {stream.expectedTotal ?? "?"} parsed
{stream.failed > 0 ? ` · ${stream.failed} failed validation` : ""}
</p>
</div>
<div className="text-right">
<div className="display num text-[16px]">{stream.expectedTotal ? `${progressPct}%` : "…"}</div>
<div className="text-[10.5px] uppercase tracking-[0.14em] text-muted-foreground">progress</div>
</div>
</div>
<div className="mt-3 h-1 w-full rounded-full bg-muted overflow-hidden">
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${progressPct}%` }} />
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="max-h-[640px] overflow-y-auto -mx-2 px-2 space-y-2">
{stream.items.length === 0 && parseMutation.isPending ? (
<div className="text-center py-8 text-muted-foreground text-sm">Waiting for first claim…</div>
) : null}
{stream.items.map((item, i) =>
item.kind === "837p" ? (
<ClaimCard837 key={`837-${item.data.claim_id}-${i}`} claim={item.data} />
) : (
<ClaimCard835 key={`835-${item.data.payer_claim_control_number}-${i}`} claim={item.data} />
)
)}
</div>
</CardContent>
</Card>
) : null}
{parsedBatches.length > 0 ? (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-[14px]">Recent batches</CardTitle>
<p className="text-xs text-muted-foreground mt-1">Parsed files, newest first.</p>
</CardHeader>
<CardContent className="pt-0">
<ul className="divide-y divide-border/40">
{parsedBatches.map((b) => (
<li key={b.id} className="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
<Badge variant={b.kind === "837p" ? "default" : "muted"}>{b.kind === "837p" ? "837P" : "835"}</Badge>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{b.inputFilename}</div>
<div className="text-[11px] text-muted-foreground num">{fmt.dateShort(b.parsedAt)} · {b.claimCount} {b.kind === "837p" ? "claims" : "payments"}</div>
</div>
<div className="text-right shrink-0">
<div className="display num text-[13px]">{b.passed} / {b.claimCount}</div>
<div className="text-[11px] text-muted-foreground">passed</div>
</div>
</li>
))}
</ul>
</CardContent>
</Card>
) : null}
</div>
);
}
- Step 2: Verify build + commit
npm run build
git add src/pages/Upload.tsx
git commit -m "feat(frontend): refactor Upload page to use useParse mutation"
Task 27: Run frontend smoke (build + dev server check)
- Step 1: Build
Run: npm run build
Expected: passes (no TS errors, no unused-import warnings).
- Step 2: Type-check
Run: npm run typecheck
Expected: passes.
- Step 3: Commit any fixups (likely none)
git status # should be clean
Phase 3 — Docs
Task 28: docs/reference/837p.md
Files:
-
Create:
docs/reference/837p.md -
Step 1: Create the file
# 837P — Professional Claims (005010X222A1)
The 837P transaction set carries professional (outpatient) healthcare claims
from a billing provider to a payer. Cyclone parses the segments it needs to
produce a structured `ClaimOutput` and validates against CO Medicaid rules.
## File format
- Extension: `.txt`
- Encoding: ASCII (UTF-8 also accepted)
- Delimiters (declared in `ISA`): `*` element, `:` component, `~` segment, `^` repetition
## Envelope
| Segment | Purpose |
|---|---|
| `ISA` / `IEA` | Interchange envelope (sender ↔ receiver) |
| `GS` / `GE` | Functional group envelope |
| `ST` / `SE` | Transaction set envelope (837) |
## Loops
| Loop | Contents |
|---|---|
| 2000A | Billing provider hierarchy (NM1*85) |
| 2000B | Subscriber hierarchy (NM1*IL) |
| 2300 | Claim (CLM, HI, NM1, DTP, REF…) |
| 2400 | Service line (LX, SV1, DTP) |
## Segments Cyclone parses
- `NM1`, `N3`, `N4` — names and addresses
- `REF` — prior auth (`REF*G1`), provider taxonomy, etc.
- `CLM` — claim header; `CLM05` carries total charge + place-of-service + frequency
- `HI` — diagnoses (qualifier `ABK` = ICD-10 principal)
- `LX`, `SV1` — service line + procedure code
- `DTP` — service date
- `BHT` — beginning of hierarchical transaction
## Segments preserved but not modeled
All other segments are kept in `raw_segments` for audit but are not extracted
into the structured `ClaimOutput`. See `cyclone/parsers/parse_837.py` for the
full walker.
## CO Medicaid specifics
- Trading partner: `SKCO0` (sender) ↔ `COHCPF` (receiver) on `NM1*PR` / `NM1*40`
- `CLM05` shape: `<total_charge>:<place_of_service>:<frequency_code>`
- Place-of-service: any valid CMS POS code
- Frequency code: must be one of `{1, 7, 8}` (1 = original, 7 = replacement, 8 = void)
- `REF*G1` carries prior-authorization number when applicable
- No 2010BA/2010CA patient loop — subscriber is the patient
- `CLM02` (provider signature on file) and `CLM07` (assignment of benefits) are typically `Y`
## Validation rules Cyclone enforces
| Rule | Severity | Description |
|---|---|---|
| `R001_frequency_allowed` | error | `CLM05-3` ∈ `{1, 7, 8}` |
| `R050_diagnosis_present` | error | At least one ICD-10 `HI*ABK` |
| `R100_provider_npi` | error | Billing provider NPI present and 10 digits |
| `R200_service_line_charge` | error | At least one `SV1` with non-zero charge |
| `R201_service_date` | warning | `DTP*472` present on every service line |
| `R300_subscriber_present` | error | `NM1*IL` (subscriber) present |
- Step 2: Commit
git add docs/reference/837p.md
git commit -m "docs: add 837p reference note"
Task 29: docs/reference/835.md
Files:
-
Create:
docs/reference/835.md -
Step 1: Create the file
# 835 — Electronic Remittance Advice (005010X221A1)
The 835 transaction set carries payment and remittance information from a
payer (or its clearinghouse) to a provider, in response to previously
submitted 837P claims. Cyclone parses it into a `ParseResult835` and surfaces
the per-claim payment breakdown.
## File format
Same as 837P — ASCII text with the same four delimiters declared in `ISA`.
## Envelope
Same as 837P (`ISA/IEA/GS/GE/ST/SE`).
## Loops
| Loop | Contents |
|---|---|
| 1000A | Payer (N1*PR) |
| 1000B | Payee (N1*PE) |
| 2100 | Claim payment (CLP) |
| 2110 | Service payment (SVC) |
## Header segments
- `BPR` — financial information (credit/debit flag, total paid amount, account details)
- `TRN` — reassociation trace number
- `DTM` — production date
## CLP — claim payment
`CLP01` = claim submitter's ID (patient control number); `CLP02` = status code.
| CLP02 | Meaning |
|---|---|
| 1 | Processed as Primary |
| 2 | Processed as Secondary |
| 3 | Processed as Tertiary |
| 4 | Denied |
| 19 | Processed as Primary, Forwarded |
| 20 | Processed as Secondary, Forwarded |
| 21 | Reversal of Previous Payment |
| 22 | Reversal of Previous Payment (Secondary) |
| 23 | Not Our Claim, Forwarded |
| 25 | Predetermination Pricing Only - No Payment |
## Critical balancing rules
Cyclone enforces (or flags) the following:
- `BPR02 == sum(CLP04)` — total paid equals sum of all claim paid amounts
- `CLP04 == sum(SVC03)` — per-claim paid equals sum of per-service paid
## CAS — claim/service adjustment
`CAS` segments carry reason codes (`CO` contractual obligation, `PR` patient
responsibility, `OA` other adjustments, `PI` payer-initiated reduction).
Cyclone preserves the raw `CAS` text in `raw_segments`; deep-parsing of
reason codes is sub-project 3.
## CO Medicaid specifics
- `BPR10` = `"81-1725341"` (TXIX / Medicaid) or `"84-0644739"` (BHA / behavioral health)
- `N1*PR` `N104` = `"7912900843"` — payer ID
- `N1*PR` `N102` = `"CO_TXIX"` or `"CO_BHA"` — payer name
- Allowed `CLP02` codes: `{1, 2, 3, 4, 19, 20, 21, 22, 23, 25}`
## Reconciliation (sub-project 2)
Matching 835 payouts back to 837P submissions by patient control number +
date of service is deferred to sub-project 2. Cyclone currently stores the
835 output as-is.
- Step 2: Commit
git add docs/reference/835.md
git commit -m "docs: add 835 reference note"
Task 30: docs/reference/x12naming.md
Files:
-
Create:
docs/reference/x12naming.md -
Step 1: Create the file
# X12 naming conventions
A short glossary of the names and indices used throughout Cyclone's parsers.
## Segments
Two- or three-letter codes that identify a row type. The codes are mnemonic:
`CLM` = claim, `NM1` = name, `BPR` = beginning segment for payment
order/remittance advice, `SVC` = service, `DTM`/`DTP` = date/time/period.
## Elements
Inside a segment, elements are separated by `*` and are 1-indexed. So
`CLM01` is the first element of the `CLM` segment — typically the patient
control number on 837P.
## Composite elements
A single element can carry sub-fields, separated by `:`. The sub-fields are
also 1-indexed, suffixed to the element index:
- `CLM05-1` = total charge
- `CLM05-2` = place of service
- `CLM05-3` = frequency code
In the Python code, this is `clm.claim.total_charge` (alias) and
`clm.claim.place_of_service` / `clm.claim.frequency_code`.
## Loops
Four-digit numeric IDs, hierarchical:
- 2000 (Billing/Subscriber hierarchy) contains
- 2300 (Claim) which contains
- 2400 (Service line)
In `cyclone.parsers`, the `parse_837` walker descends through these loops
explicitly.
## Common qualifiers Cyclone cares about
| Qualifier | Meaning | Where |
|---|---|---|
| `ABK` | ICD-10 principal diagnosis | `HI01-1` |
| `ABF` | ICD-10 diagnosis | `HI01-1` |
| `B` | Place of service | `CLM05-2` |
| `MC` | Medicaid (claim filing indicator) | 835 `CLP06` |
| `PR` | Payer | 835 `N1*PR` |
| `PE` | Payee | 835 `N1*PE` |
| `G1` | Prior authorization | 837P `REF01` |
| `TJ` | Federal taxpayer ID | 837P `REF01` (rendering provider TIN) |
| `1` | Original claim | 837P `CLM05-3` |
| `7` | Replacement claim | 837P `CLM05-3` |
| `8` | Void/cancel claim | 837P `CLM05-3` |
## The four delimiters
All four are declared in the `ISA` segment (positions 103–106 in the ISA
fixed-width header) and reused throughout the file. Cyclone's tokenizer
in `cyclone/parsers/segments.py` reads the ISA first, then splits the
rest of the file by those characters.
- `*` — element separator
- `:` — component (sub-element) separator
- `~` — segment terminator
- `^` — repetition separator
- Step 2: Commit
git add docs/reference/x12naming.md
git commit -m "docs: add x12 naming reference note"
Task 31: docs/reference/co-medicaid.md
Files:
-
Create:
docs/reference/co-medicaid.md -
Step 1: Create the file
# Colorado Medicaid — payer specifics
Cyclone's CO Medicaid (`PayerConfig.co_medicaid()` and
`PayerConfig835.co_medicaid_835()`) factories encode the trading-partner
and code-set rules that the Colorado Department of Health Care Policy &
Financing (HCPF) requires.
## Trading Partner IDs (TPID)
| Role | 837P | 835 |
|---|---|---|
| Sender | `SKCO0` | (varies; usually the clearinghouse) |
| Receiver | `COHCPF` | (varies) |
These appear in the `NM1*PR` (payer) and `NM1*40` (receiver) segments of
the 837P file.
## Payer IDs
### 837P (claims)
- `NM1*PR N104 = "SKCO0"` (COHCPF)
### 835 (remittance)
- `BPR10 = "81-1725341"` — TXIX (Medicaid) account
- `BPR10 = "84-0644739"` — BHA (behavioral health) account
- `N1*PR N104 = "7912900843"` — payer ID
- `N1*PR N102` is one of `"CO_TXIX"` or `"CO_BHA"`
## Allowed codes
### 837P `CLM05-3` (frequency code)
| Code | Meaning |
|---|---|
| 1 | Original claim |
| 7 | Replacement claim |
| 8 | Void / cancel |
Cyclone raises `R001_frequency_allowed` if any other value appears.
### 835 `CLP02` (claim status code)
Allowed: `{1, 2, 3, 4, 19, 20, 21, 22, 23, 25}`. See the 835 reference
note for the meaning of each.
### Place of service (POS)
All 89 CMS POS codes are accepted. The canonical list lives in
`cyclone/parsers/payer.py` as `CMS_PLACE_OF_SERVICE_CODES` and is the
source of truth for validation and any UI dropdowns.
## Validation rules Cyclone enforces
See [837p.md](./837p.md#validation-rules-cyclone-enforces) and the
`cyclone.parsers.validator` / `cyclone.parsers.validator_835` modules.
## Adding a new payer
The pattern is:
1. Add a `PayerConfig` (837P) and `PayerConfig835` (835) factory in
`cyclone/parsers/payer.py` — usually thin wrappers that override only
the differing fields (allowed codes, payer IDs).
2. Register the factory in the `PAYER_FACTORIES` / `PAYER_FACTORIES_835`
dicts in `cyclone/api.py` and `cyclone/cli.py`.
3. Add a smoke fixture under `backend/tests/fixtures/` (a real or
synthetic file matching the new payer's rules) and a parser test that
loads it.
4. Update this note + the 837p/835 notes with the new payer's specifics.
- Step 2: Commit
git add docs/reference/co-medicaid.md
git commit -m "docs: add CO Medicaid reference note"
Task 32: Root README.md rewrite
Files:
-
Modify:
README.md -
Step 1: Replace the file
Replace README.md (root) with:
# 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:
# 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
# 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)
- Step 1: Start the backend
Terminal 1: cd backend && .venv/bin/python -m cyclone serve
Expected: log line "Uvicorn running on http://127.0.0.1:8000".
- Step 2: Confirm loopback-only
Terminal 2: curl -s http://127.0.0.1:8000/api/health | jq . → {"status": "ok", "version": "0.1.0"}.
Then: curl -s http://0.0.0.0:8000/api/health → connection refused.
- Step 3: Start the frontend
Terminal 3: npm run dev
Expected: "Local: http://localhost:5173".
- Step 4: Drop a fixture file and verify live data flow
- Open
http://localhost:5173/uploadin a browser. - Drop
backend/tests/fixtures/co_medicaid_837p.txton the upload zone. - Click Parse. Expect: 2 cards stream in; success toast appears.
- Navigate to
/claims. Expect: 2 rows, bothsubmitted. - Navigate to
/remittances. Expect: empty state "Remittances · awaiting first 835". - Navigate to
/providers. Expect: 1 provider card, 2 claims. - Navigate to
/activity. Expect: 2 events, bothclaim_submitted. - Apply
Status: submittedfilter on/claims. Expect: the same 2 rows; theFilterChipsrow shows "Status: submitted ×". - Watch the top of
<main>while navigating back to/uploadand re-parsing — the 1px scan-line indicator should appear briefly.
- Step 5: Verify NDJSON streaming
Terminal 2: curl -s -N -H 'Accept: application/x-ndjson' http://127.0.0.1:8000/api/claims | head -5
Expected: 2 lines of {"type":"item",…} + 1 line of {"type":"summary",…}.
- Step 6: Stop the backend and verify the UI reverts to the fallback
Kill terminal 1 (Ctrl+C). Refresh the browser. Expect: the pages still
render (using the zustand fallback via the existing data adapter), but
the upload page shows "No backend configured".
- Step 7: Final commit (likely none)
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-reviewon 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 buildas a gate before starting Phase 3. - After Phase 3, run the T33 end-to-end smoke as the final acceptance.