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

138 KiB
Raw Blame History

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 BatchRecord construction + 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 / ParseResult model fields differ, adjust the _make_claim / _make_result stubs to match the real field names. Run python -c "from cyclone.parsers.models import ClaimOutput; help(ClaimOutput)" to confirm.

  • Step 2: Run the test to verify it fails

Run: cd backend && .venv/bin/pytest tests/test_store.py -v Expected: ModuleNotFoundError: No module named 'cyclone.store' (or similar import error).

  • Step 3: Implement BatchRecord + empty InMemoryStore (no mappers yet)

backend/src/cyclone/store.py:

"""In-memory batch store for parsed X12 files.

Lives at module scope so route handlers and tests can import a single
singleton. Single-process only — no cross-process coordination. Threading:
a re-entrant lock guards every public method so FastAPI's threadpool
request handlers can call them concurrently.
"""

from __future__ import annotations

import threading
from datetime import datetime, timezone
from typing import Iterable, Literal

from pydantic import BaseModel, ConfigDict, Field

from cyclone.parsers.models import ParseResult
from cyclone.parsers.models_835 import ParseResult835


BatchKind = Literal["837p", "835"]


class BatchRecord(BaseModel):
    """One parsed file, with a stable uuid4 id and the full ParseResult."""

    model_config = ConfigDict(extra="ignore")

    id: str
    kind: BatchKind
    input_filename: str
    parsed_at: str  # ISO 8601 UTC, e.g. "2026-06-19T12:00:00Z"
    result: ParseResult | ParseResult835 = Field(discriminator=None)


class InMemoryStore:
    """Thread-safe list of BatchRecord, newest-first on `list`."""

    def __init__(self) -> None:
        self._batches: list[BatchRecord] = []
        self._lock = threading.RLock()

    def add(self, record: BatchRecord) -> None:
        with self._lock:
            self._batches.append(record)

    def list(self, *, limit: int = 100) -> list[BatchRecord]:
        with self._lock:
            # Newest first.
            return list(reversed(self._batches[-limit:]))

    def get(self, batch_id: str) -> BatchRecord | None:
        with self._lock:
            for b in self._batches:
                if b.id == batch_id:
                    return b
            return None

    def all(self) -> list[BatchRecord]:
        with self._lock:
            return list(self._batches)


store = InMemoryStore()


def utcnow_iso() -> str:
    """ISO 8601 UTC timestamp, second precision, suffixed with 'Z'."""
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")

Note: the imports from cyclone.parsers.models import ParseResult and from cyclone.parsers.models_835 import ParseResult835 are correct based on the existing project layout. If the Pydantic v2 union complains about the discriminator, drop the discriminator=None and let Pydantic use the default behavior — the actual class is determined at runtime by the field type.

  • Step 4: Run the tests to verify they pass

Run: cd backend && .venv/bin/pytest tests/test_store.py -v Expected: all 4 tests pass.

  • Step 5: Commit
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 status mapping rules (submitted/pending/denied) and that the mapper output is a dict with the right shape.

  • Step 2: Run the test to verify it fails

Run: cd backend && .venv/bin/pytest tests/test_store.py -v Expected: ImportError: cannot import name 'to_ui_claim' from 'cyclone.store'.

  • Step 3: Implement the mappers

Add to backend/src/cyclone/store.py (after utcnow_iso):

# ---------------------------------------------------------------------------
# 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 ClaimPayment import for to_ui_remittance should 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.api to call store.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__.py to 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 vitest is not already configured, add "test": "vitest run" to package.json scripts and vitest to devDependencies. Run npm install -D vitest if needed.

  • Step 3: Run the tests

Run: npm test -- src/lib/api.test.ts Expected: 3 tests pass.

  • Step 4: Commit
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 103106 in the ISA
fixed-width header) and reused throughout the file. Cyclone's tokenizer
in `cyclone/parsers/segments.py` reads the ISA first, then splits the
rest of the file by those characters.

- `*` — element separator
- `:` — component (sub-element) separator
- `~` — segment terminator
- `^` — repetition separator
  • 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
  1. Open http://localhost:5173/upload in a browser.
  2. Drop backend/tests/fixtures/co_medicaid_837p.txt on the upload zone.
  3. Click Parse. Expect: 2 cards stream in; success toast appears.
  4. Navigate to /claims. Expect: 2 rows, both submitted.
  5. Navigate to /remittances. Expect: empty state "Remittances · awaiting first 835".
  6. Navigate to /providers. Expect: 1 provider card, 2 claims.
  7. Navigate to /activity. Expect: 2 events, both claim_submitted.
  8. Apply Status: submitted filter on /claims. Expect: the same 2 rows; the FilterChips row shows "Status: submitted ×".
  9. Watch the top of <main> while navigating back to /upload and re-parsing — the 1px scan-line indicator should appear briefly.
  • 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.17.7.8 live-data UX T13 (keyframes), T15 (Empty), T16 (Error), T17 (Chips), T18 (Pagination), T19 (refetch indicator), T22T26 (row flash)
§8 docs (4 notes + README) T28T32
§10 testing (~23 backend, 3 frontend) T1T8 (backend), T20 (frontend)
§11 migration/rollout T9 (uvicorn bind) + T4 (store populated on parse)
§13 acceptance checklist T33 (manual)
§13.1 visual / aesthetic checks T13, T14, T15, T16, T17, T18, T19, T22T26

No placeholders. No "TBD", "TODO", "implement later", or vague "add appropriate error handling" — every step has concrete code.

Type consistency. 837P statuses draft|submitted|accepted|denied|paid|pending and 835 statuses received|posted|reconciled appear consistently across T2, T21, T22, T23, T25, T26 and the 7.7.7 spec section. Color tokens (success|accent|warning|destructive|muted) match the existing Tailwind config. Keyframe names shimmer|scan|row-flash are defined once in T13 and referenced consistently in T14, T19, T22, T23, T25.

File path accuracy. All paths verified against the current repo: backend/src/cyclone/store.py (new), backend/src/cyclone/api.py (modify), backend/src/cyclone/__main__.py (modify), src/main.tsx (modify), src/components/Layout.tsx (modify), src/lib/api.ts (modify), src/pages/{Claims,Remittances,Providers,ActivityLog,Upload}.tsx (modify), docs/reference/{837p,835,x12naming,co-medicaid}.md (new), README.md (modify).


Execution

This plan is ready for the subagent-driven-development skill. Each backend task is small enough to fit in a single subagent (≤ 30 minutes); the frontend tasks are similarly scoped. The docs tasks (T28T32) are mechanical markdown and can be dispatched in parallel from a single subagent.

Approximate effort (subagent wall-clock):

  • Phase 1 (backend): 6 subagents, ~90 min total with parallelism
  • Phase 2 (frontend): 8 subagents, ~120 min total
  • Phase 3 (docs): 1 subagent, ~20 min
  • Phase 4 (smoke): manual, ~10 min

Total: ~4 hours of subagent work + review.

Review checkpoints (built into the subagent-driven-development skill):

  • After every 3 tasks, the orchestrator runs requesting-code-review on the cumulative diff.
  • After Phase 1 completes, run the full backend test suite as a gate before starting Phase 2.
  • After Phase 2 completes, run npm run build as a gate before starting Phase 3.
  • After Phase 3, run the T33 end-to-end smoke as the final acceptance.