Files
cyclone/docs/superpowers/plans/2026-06-20-cyclone-workflow-automation.md
Tyler 27c1680830 docs: refresh README roadmap (SP4/6/7 shipped, add Next up) + commit SP6 plan
- Mark SP4 as fully shipped (batch diff, search, CSV, a11y all landed)
- Add SP6 (Inbox) and SP7 (Per-line reconciliation) sections
- Add SP6 + SP7 endpoint inventories
- Note next up: outbound 837P serializer

The workflow-automation plan was authored but never committed; the
features it specified have shipped, so commit it for the historical
record alongside this README refresh.
2026-06-20 19:58:26 -06:00

2702 lines
78 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Sub-project 6 — Workflow Automation & Inbox: Implementation Plan
**Date:** 2026-06-20
**Status:** Ready
**Branch:** `sp6-workflow-inbox` (created from `main` after SP5 is merged)
**Spec:** `.superpowers/brainstorm/419-1781997857/spec.md`
21 tasks across 8 phases. TDD throughout. Each task ends with a green test run + commit.
**Prerequisite:** SP5 (live-tail) must be merged to `main` before this plan runs. The first task verifies this and rebases the branch if needed.
**Tech additions:** None — scoring uses stdlib only (`difflib` is *not* needed because patient control number match is exact, not fuzzy). No new pip deps, no new npm deps.
---
## Task 0: Verify SP5 is on `main` and create work branch
**Files:** none
- [ ] **Step 1: Confirm SP5 is merged**
```bash
cd /Users/openclaw/dev/cyclone
git fetch origin
git log --oneline main -10
git log --oneline main | grep -E "merge:.*live-tail|feat:.*live-tail|merge.*tail" | head -3
```
Expected: at least one merge commit referencing SP5 / live-tail on `main`. If absent, STOP — do not proceed until SP5 is merged.
- [ ] **Step 2: Create the work branch**
```bash
git checkout main
git pull --ff-only
git checkout -b sp6-workflow-inbox
```
- [ ] **Step 3: Commit** (empty commit to anchor the branch)
```bash
git commit --allow-empty -m "chore(sp6): start workflow automation & inbox branch"
```
---
## Phase 1 — Schema
## Task 1: Add `REJECTED` to `ClaimState` enum
**Files:**
- Modify: `backend/src/cyclone/db.py:124-131`
- [ ] **Step 1: Add the enum value**
Edit `backend/src/cyclone/db.py`, in the `ClaimState` class, add `REJECTED = "rejected"` between `RECEIVED` and `PAID`:
```python
class ClaimState(str, enum.Enum):
SUBMITTED = "submitted"
RECEIVED = "received"
REJECTED = "rejected" # NEW — set by 999 AK9 set-level R/E
PAID = "paid"
PARTIAL = "partial"
DENIED = "denied"
RECONCILED = "reconciled"
REVERSED = "reversed"
```
- [ ] **Step 2: Run full backend suite, confirm green**
```bash
cd backend && ../.venv/bin/pytest -q
```
Expected: existing tests still pass (enum addition is additive; no callers reference the new value yet).
- [ ] **Step 3: Commit**
```bash
git add backend/src/cyclone/db.py
git commit -m "feat(sp6): add REJECTED to ClaimState enum"
```
---
## Task 2: Migration 0003 — rejection columns + state-history index
**Files:**
- Create: `backend/src/cyclone/migrations/0003_rejections_and_state_history.sql`
- [ ] **Step 1: Write the migration**
```sql
-- version: 3
-- SP6 T2: 999 rejection tracking + state-history index for the Done-today lane.
ALTER TABLE claims
ADD COLUMN rejection_reason TEXT,
ADD COLUMN rejected_at TIMESTAMP,
ADD COLUMN resubmit_count INTEGER NOT NULL DEFAULT 0;
CREATE INDEX ix_claims_state_changed_at ON claims(state, state_changed_at);
```
- [ ] **Step 2: Run the full suite to trigger the migration**
```bash
cd backend && ../.venv/bin/pytest -q
```
Expected: green. The migration runner picks up `0003_*.sql` automatically and applies it on each test's fresh DB.
- [ ] **Step 3: Verify the columns exist on the test DB**
```bash
cd backend && python -c "
from cyclone.db import init_db, get_sessionmaker
init_db('sqlite:////tmp/sp6-t2-verify.db')
S = get_sessionmaker()()
with S() as s:
rows = s.execute(s.bind.dialect.compiler()).fetchall()
" 2>&1 | head -5
sqlite3 /tmp/sp6-t2-verify.db ".schema claims" | head -25
```
Expected: schema includes `rejection_reason TEXT`, `rejected_at TIMESTAMP`, `resubmit_count INTEGER NOT NULL DEFAULT 0`, and the `ix_claims_state_changed_at` index. Clean up: `rm /tmp/sp6-t2-verify.db`.
- [ ] **Step 4: Commit**
```bash
git add backend/src/cyclone/migrations/0003_rejections_and_state_history.sql
git commit -m "feat(sp6): migration 0003 — rejection columns + state-history index"
```
---
## Phase 2 — 999 → Rejected state
## Task 3: `apply_999_rejections` helper (TDD)
**Files:**
- Create: `backend/src/cyclone/inbox_state.py`
- Test: `backend/tests/test_inbox_state.py`
- [ ] **Step 1: Write the failing test**
```python
# backend/tests/test_inbox_state.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from cyclone.db import Base, Claim, ClaimState, init_db
from cyclone.inbox_state import apply_999_rejections
from cyclone.parsers.models_999 import ParseResult999, SetFunctionalGroupResponse, SetAcceptReject
def _make_engine():
eng = create_engine("sqlite://")
Base.metadata.create_all(eng)
return eng
def _make_claim(session, *, claim_id="CLP-1", patient_control_number="PCN-001",
state=ClaimState.SUBMITTED):
c = Claim(
id=claim_id,
batch_id="B-1",
patient_control_number=patient_control_number,
state=state,
)
session.add(c)
session.commit()
return c
def _make_999(*, claim_refs):
"""claim_refs: list of (patient_control_number, accept_code)."""
return ParseResult999(
envelope=None, # not used by apply_999_rejections
set_responses=[
SetFunctionalGroupResponse(
set_control_number=ref,
set_accept_reject=SetAcceptReject(code=code),
ik3_segments=[],
)
for ref, code in claim_refs
],
)
def test_rejection_moves_submitted_claim_to_rejected_state():
eng = _make_engine()
S = sessionmaker(bind=eng)()
claim = _make_claim(S, patient_control_number="PCN-001")
result = apply_999_rejections(
S,
_make_999(claim_refs=[("PCN-001", "R")]),
claim_lookup=lambda pcn: S.query(Claim).filter_by(patient_control_number=pcn).first(),
)
assert result.matched == ["CLP-1"]
assert result.orphans == []
S.refresh(claim)
assert claim.state == ClaimState.REJECTED
assert claim.rejected_at is not None
assert "999" in claim.rejection_reason
def test_accepted_set_leaves_claim_alone():
eng = _make_engine()
S = sessionmaker(bind=eng)()
claim = _make_claim(S, patient_control_number="PCN-002")
result = apply_999_rejections(
S,
_make_999(claim_refs=[("PCN-002", "A")]),
claim_lookup=lambda pcn: S.query(Claim).filter_by(patient_control_number=pcn).first(),
)
assert result.matched == []
S.refresh(claim)
assert claim.state == ClaimState.SUBMITTED
assert claim.rejected_at is None
def test_unknown_pcn_recorded_as_orphan_not_error():
eng = _make_engine()
S = sessionmaker(bind=eng)()
result = apply_999_rejections(
S,
_make_999(claim_refs=[("PCN-NONEXISTENT", "R")]),
claim_lookup=lambda pcn: S.query(Claim).filter_by(patient_control_number=pcn).first(),
)
assert result.matched == []
assert result.orphans == ["PCN-NONEXISTENT"]
def test_already_rejected_claim_is_idempotent():
eng = _make_engine()
S = sessionmaker(bind=eng)()
claim = _make_claim(S, patient_control_number="PCN-003", state=ClaimState.REJECTED)
result = apply_999_rejections(
S,
_make_999(claim_refs=[("PCN-003", "R")]),
claim_lookup=lambda pcn: S.query(Claim).filter_by(patient_control_number=pcn).first(),
)
assert result.matched == [] # no-op
S.refresh(claim)
assert claim.state == ClaimState.REJECTED
```
- [ ] **Step 2: Run the test, confirm FAIL**
```bash
cd backend && ../.venv/bin/pytest tests/test_inbox_state.py -q
```
Expected: `ModuleNotFoundError: No module named 'cyclone.inbox_state'`.
- [ ] **Step 3: Implement the module**
```python
# backend/src/cyclone/inbox_state.py
"""999 ACK → claim state transitions for the Inbox Rejected lane."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Callable
from sqlalchemy.orm import Session
from cyclone.db import Claim, ClaimState
@dataclass
class Apply999Result:
matched: list[str] = field(default_factory=list) # claim ids transitioned
orphans: list[str] = field(default_factory=list) # PCNs we couldn't resolve
def _build_reason(code: str, ik3_count: int) -> str:
parts = [f"999 AK9 set-level {code}"]
if ik3_count:
parts.append(f"{ik3_count} IK3 segment error(s)")
return "; ".join(parts)
def apply_999_rejections(
session: Session,
parsed_999,
*,
claim_lookup: Callable[[str], Claim | None],
) -> Apply999Result:
"""For each set response with code R or E, look up the matching claim and
move it to REJECTED. Idempotent on already-rejected claims.
Args:
session: SQLAlchemy session.
parsed_999: a ParseResult999 (or any object with .set_responses).
claim_lookup: callable from patient_control_number → Claim or None.
Returns:
Apply999Result with lists of matched claim ids and orphan PCNs.
"""
result = Apply999Result()
now = datetime.now(timezone.utc)
for sr in parsed_999.set_responses:
code = sr.set_accept_reject.code
if code not in ("R", "E"):
continue
claim = claim_lookup(sr.set_control_number)
if claim is None:
result.orphans.append(sr.set_control_number)
continue
if claim.state == ClaimState.REJECTED:
# Idempotent: don't double-mutate.
continue
claim.state = ClaimState.REJECTED
claim.rejected_at = now
claim.rejection_reason = _build_reason(code, len(sr.ik3_segments or []))
result.matched.append(claim.id)
if result.matched or result.orphans:
session.commit()
return result
```
- [ ] **Step 4: Run the test, confirm PASS**
```bash
cd backend && ../.venv/bin/pytest tests/test_inbox_state.py -v
```
Expected: 4 passed.
- [ ] **Step 5: Run full backend suite, confirm still green**
```bash
cd backend && ../.venv/bin/pytest -q
```
- [ ] **Step 6: Commit**
```bash
git add backend/src/cyclone/inbox_state.py backend/tests/test_inbox_state.py
git commit -m "feat(sp6): apply_999_rejections state helper"
```
---
## Task 4: Wire 999 ingest → `apply_999_rejections` (TDD)
**Files:**
- Modify: `backend/src/cyclone/api.py:569-640`
- Modify: `backend/tests/conftest.py` (only if needed for the event_bus fixture)
- Test: `backend/tests/test_999_rejected_state.py`
- [ ] **Step 1: Write the failing test**
```python
# backend/tests/test_999_rejected_state.py
import io
import os
import tempfile
from fastapi.testclient import TestClient
from cyclone.api import app
from cyclone.db import init_db, Claim, ClaimState, get_sessionmaker
def _seed_claim(*, claim_id="CLP-1", patient_control_number="PCN-001"):
S = get_sessionmaker()()
with S() as s:
s.add(Claim(id=claim_id, batch_id="B-1",
patient_control_number=patient_control_number,
state=ClaimState.SUBMITTED))
s.commit()
def test_999_set_rejection_moves_claim_to_rejected_state(client: TestClient):
_seed_claim()
# Minimal 999: 1 set, accept_reject=R, set_control_number matches the PCN.
raw = (
"ISA*00* *00* *ZZ*PAYER01 *ZZ*PROVIDER01 "
f"*250615*0900*^*00501*000000001*0*P*:~"
"GS*FA*PAYER01*PROVIDER01*20250615*0900*1*X*005010X231A1~"
"ST*999*0001~"
"AK1*HC*0001~"
f"AK2*PCN-001*0001~"
"IK3*CLM*001*8~"
"IK4*2**7~"
"IK5*R*5~"
"AK9*R*1*1*0~"
"SE*8*0001~"
"GE*1*1~"
"IEA*0*000000001~"
)
r = client.post(
"/api/parse-999",
files={"file": ("sample.999", io.BytesIO(raw.encode()), "text/plain")},
)
assert r.status_code == 200, r.text
S = get_sessionmaker()()
with S() as s:
c = s.get(Claim, "CLP-1")
assert c.state == ClaimState.REJECTED
assert c.rejected_at is not None
assert "999" in c.rejection_reason
```
- [ ] **Step 2: Run, confirm FAIL**
```bash
cd backend && ../.venv/bin/pytest tests/test_999_rejected_state.py -v
```
Expected: FAIL — claim state stays `submitted`.
- [ ] **Step 3: Wire the helper into the endpoint**
In `backend/src/cyclone/api.py`, after `result = parse_999_text(text, ...)` and before `store.add_ack(...)`, add:
```python
from cyclone.inbox_state import apply_999_rejections
from cyclone.db import Claim
```
Then, just before the `store.add_ack(...)` call, insert:
```python
# SP6 T4: move claims whose 999 set was rejected into ClaimState.REJECTED.
S = get_sessionmaker()()
with S() as session:
def _lookup(pcn: str):
return session.query(Claim).filter_by(patient_control_number=pcn).first()
_rejection_result = apply_999_rejections(session, result, claim_lookup=_lookup)
if _rejection_result.matched:
# Publish events so the Inbox tail refetches.
bus = _get_event_bus()
for cid in _rejection_result.matched:
bus.publish("claim.rejected", {"claim_id": cid})
if _rejection_result.orphans:
log.warning("999 had %d orphan set refs: %s",
len(_rejection_result.orphans),
_rejection_result.orphans[:5])
```
(`get_sessionmaker`, `_get_event_bus`, and `log` are already in scope in `api.py`.)
- [ ] **Step 4: Run, confirm PASS**
```bash
cd backend && ../.venv/bin/pytest tests/test_999_rejected_state.py -v
```
Expected: 1 passed.
- [ ] **Step 5: Full suite**
```bash
cd backend && ../.venv/bin/pytest -q
```
Expected: all green.
- [ ] **Step 6: Commit**
```bash
git add backend/src/cyclone/api.py backend/tests/test_999_rejected_state.py
git commit -m "feat(sp6): wire 999 ingest → claim rejected state"
```
---
## Phase 3 — Scoring + lane computation
## Task 5: Scoring module (TDD)
**Files:**
- Create: `backend/src/cyclone/scoring.py`
- Test: `backend/tests/test_scoring.py`
- [ ] **Step 1: Write the failing test**
```python
# backend/tests/test_scoring.py
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
import pytest
from cyclone.scoring import ScoreBreakdown, score_pair
@dataclass
class FakeClaim:
patient_control_number: str = "PCN-001"
service_date_from: date | None = date(2026, 6, 10)
charge_amount: Decimal = Decimal("100.00")
provider_npi: str | None = "1234567890"
@dataclass
class FakeRemit:
patient_control_number: str = "PCN-001"
service_date: date | None = date(2026, 6, 10)
charge_amount: Decimal = Decimal("100.00")
rendering_provider_npi: str | None = "1234567890"
def test_perfect_match_scores_100():
s = score_pair(FakeClaim(), FakeRemit())
assert s.total == 100
assert s.patient == 1.0
assert s.date == 1.0
assert s.amount == 1.0
assert s.provider == 1.0
def test_date_within_3_days_scores_partial():
s = score_pair(
FakeClaim(service_date_from=date(2026, 6, 10)),
FakeRemit(service_date=date(2026, 6, 12)),
)
assert s.date == pytest.approx(1 - 2/3) # 0.333
def test_date_beyond_3_days_scores_zero():
s = score_pair(
FakeClaim(service_date_from=date(2026, 6, 10)),
FakeRemit(service_date=date(2026, 6, 14)),
)
assert s.date == 0.0
def test_amount_within_10pct_scores_partial():
s = score_pair(
FakeClaim(charge_amount=Decimal("100.00")),
FakeRemit(charge_amount=Decimal("108.00")),
)
assert s.amount == pytest.approx(1 - 8/10)
def test_amount_beyond_10pct_scores_zero():
s = score_pair(
FakeClaim(charge_amount=Decimal("100.00")),
FakeRemit(charge_amount=Decimal("120.00")),
)
assert s.amount == 0.0
def test_patient_normalized_exact_match():
s = score_pair(
FakeClaim(patient_control_number="PCN-001"),
FakeRemit(patient_control_number=" pcn-001 "),
)
assert s.patient == 1.0
def test_patient_mismatch_scores_zero():
s = score_pair(
FakeClaim(patient_control_number="PCN-001"),
FakeRemit(patient_control_number="PCN-002"),
)
assert s.patient == 0.0
def test_missing_provider_npi_scores_zero():
s = score_pair(
FakeClaim(provider_npi=None),
FakeRemit(rendering_provider_npi="1234567890"),
)
assert s.provider == 0.0
def test_missing_service_date_scores_zero():
s = score_pair(
FakeClaim(service_date_from=None),
FakeRemit(service_date=date(2026, 6, 10)),
)
assert s.date == 0.0
def test_threshold_boundaries():
"""Tier boundaries: ≥75 strong, 5074 weak, <50 hidden."""
# All fields 0 except amount = 0.75 (exact) → 15 pts → total 15 → hidden
s = score_pair(
FakeClaim(
patient_control_number="X", # 0
service_date_from=date(2026, 6, 10),
charge_amount=Decimal("100.00"),
provider_npi="1234567890",
),
FakeRemit(
patient_control_number="Y", # 0
service_date=date(2026, 6, 10),
charge_amount=Decimal("100.00"),
rendering_provider_npi="1234567890",
),
)
assert 50 <= s.total < 75 # weak tier
```
- [ ] **Step 2: Run, confirm FAIL**
```bash
cd backend && ../.venv/bin/pytest tests/test_scoring.py -q
```
- [ ] **Step 3: Implement**
```python
# backend/src/cyclone/scoring.py
"""Transparent 4-field weighted scoring for Inbox candidate pairs.
Weights (sum to 100):
- patient control number match (exact, normalized) 40
- service date proximity (±3 days linear decay) 25
- charge amount proximity (±10% linear decay) 20
- provider NPI exact match 15
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from typing import Protocol
class _ScorableClaim(Protocol):
patient_control_number: str
service_date_from: date | None
charge_amount: Decimal
provider_npi: str | None
class _ScorableRemit(Protocol):
patient_control_number: str
service_date: date | None
charge_amount: Decimal
rendering_provider_npi: str | None
@dataclass(frozen=True)
class ScoreBreakdown:
patient: float
date: float
amount: float
provider: float
@property
def total(self) -> int:
return round(
self.patient * 40
+ self.date * 25
+ self.amount * 20
+ self.provider * 15
)
@property
def tier(self) -> str:
if self.total >= 75:
return "strong"
if self.total >= 50:
return "weak"
return "hidden"
def _norm_pcn(s: str) -> str:
return (s or "").strip().lower().lstrip("0")
def _date_score(claim_date: date | None, remit_date: date | None) -> float:
if claim_date is None or remit_date is None:
return 0.0
delta = abs((claim_date - remit_date).days)
if delta > 3:
return 0.0
return 1.0 - (delta / 3.0)
def _amount_score(claim_amt: Decimal, remit_amt: Decimal) -> float:
if claim_amt <= 0:
return 0.0
diff = abs(claim_amt - remit_amt)
threshold = claim_amt * Decimal("0.10")
if diff > threshold:
return 0.0
# Normalize diff within threshold to 0..1
return float(1.0 - (diff / threshold))
def _provider_score(claim_npi: str | None, remit_npi: str | None) -> float:
if not claim_npi or not remit_npi:
return 0.0
return 1.0 if claim_npi.strip() == remit_npi.strip() else 0.0
def score_pair(claim: _ScorableClaim, remit: _ScorableRemit) -> ScoreBreakdown:
return ScoreBreakdown(
patient=1.0 if _norm_pcn(claim.patient_control_number) == _norm_pcn(remit.patient_control_number) and _norm_pcn(claim.patient_control_number) else 0.0,
date=_date_score(claim.service_date_from, remit.service_date),
amount=_amount_score(claim.charge_amount, remit.charge_amount),
provider=_provider_score(claim.provider_npi, remit.rendering_provider_npi),
)
```
- [ ] **Step 4: Run, confirm PASS**
```bash
cd backend && ../.venv/bin/pytest tests/test_scoring.py -v
```
Expected: 10 passed.
- [ ] **Step 5: Commit**
```bash
git add backend/src/cyclone/scoring.py backend/tests/test_scoring.py
git commit -m "feat(sp6): 4-field weighted scoring module"
```
---
## Task 6: Lane computation module (TDD)
**Files:**
- Create: `backend/src/cyclone/inbox_lanes.py`
- Test: `backend/tests/test_inbox_lanes.py`
- [ ] **Step 1: Write the failing test**
```python
# backend/tests/test_inbox_lanes.py
from datetime import datetime, timedelta, timezone
from decimal import Decimal
import pytest
from cyclone.db import init_db, Base, Claim, ClaimState, Remittance, get_sessionmaker
from cyclone.inbox_lanes import compute_lanes
@pytest.fixture
def session():
eng = init_db("sqlite://")
S = get_sessionmaker()()
yield S
def _add_claim(S, *, claim_id, pcn="PCN", state=ClaimState.SUBMITTED,
charge=Decimal("100.00"), provider_npi="1234567890",
service_date=None, payer_id="PAYER-A"):
with S() as s:
c = Claim(
id=claim_id,
batch_id="B-1",
patient_control_number=pcn,
state=state,
charge_amount=charge,
provider_npi=provider_npi,
payer_id=payer_id,
service_date_from=service_date,
)
s.add(c)
s.commit()
def _add_remit(S, *, remit_id, pcn="PCN", charge=Decimal("100.00"),
rendering_npi="1234567890", service_date=None, payer_id="PAYER-A",
claim_id=None):
with S() as s:
r = Remittance(
id=remit_id,
batch_id="B-1",
payer_claim_control_number=pcn,
charge_amount=charge,
claim_id=claim_id,
)
# Store the auxiliary fields via raw_json for round-trip; the parser
# already populates these on a real parse.
r.raw_json = {
"patient_control_number": pcn,
"service_date": service_date.isoformat() if service_date else None,
"rendering_provider_npi": rendering_npi,
"payer_id": payer_id,
}
s.add(r)
s.commit()
def test_rejected_lane_includes_rejected_claims(session):
_add_claim(session, claim_id="C1", state=ClaimState.REJECTED)
lanes = compute_lanes(session, dismissed_pairs=set())
assert "C1" in {r["id"] for r in lanes.rejected}
def test_unmatched_lane_includes_submitted_claims_with_no_remit(session):
_add_claim(session, claim_id="C1", state=ClaimState.SUBMITTED)
lanes = compute_lanes(session, dismissed_pairs=set())
assert "C1" in {r["id"] for r in lanes.unmatched}
def test_unmatched_remit_appears_with_candidates(session):
_add_claim(session, claim_id="C1", pcn="PCN-1", service_date=None)
_add_remit(session, remit_id="R1", pcn="PCN-1") # exact match → candidate
lanes = compute_lanes(session, dismissed_pairs=set())
assert any(r["id"] == "R1" for r in lanes.candidates)
def test_candidate_below_threshold_is_hidden(session):
_add_claim(session, claim_id="C1", pcn="A", charge=Decimal("100.00"))
_add_remit(session, remit_id="R1", pcn="Z",
charge=Decimal("999.00")) # everything mismatched
lanes = compute_lanes(session, dismissed_pairs=set())
assert not any(r["id"] == "R1" for r in lanes.candidates)
def test_dismissed_pair_excluded_from_candidates(session):
_add_claim(session, claim_id="C1", pcn="PCN-1")
_add_remit(session, remit_id="R1", pcn="PCN-1")
lanes = compute_lanes(session, dismissed_pairs={frozenset({"C1", "R1"})})
assert not any(r["id"] == "R1" for r in lanes.candidates)
def test_done_today_includes_recent_terminal_states(session):
# Need a recent state_changed_at. Set it explicitly.
with session() as s:
s.add(Claim(id="C1", batch_id="B-1", patient_control_number="X",
state=ClaimState.PAID,
state_changed_at=datetime.now(timezone.utc) - timedelta(hours=2)))
s.commit()
_add_claim(session, claim_id="C2", state=ClaimState.PAID)
# Force C2's state_changed_at to be old.
with session() as s:
c = s.get(Claim, "C2")
c.state_changed_at = datetime.now(timezone.utc) - timedelta(hours=30)
s.commit()
lanes = compute_lanes(session, dismissed_pairs=set())
ids = {r["id"] for r in lanes.done_today}
assert "C1" in ids
assert "C2" not in ids
```
- [ ] **Step 2: Run, confirm FAIL**
```bash
cd backend && ../.venv/bin/pytest tests/test_inbox_lanes.py -q
```
Expected: ModuleNotFoundError for `cyclone.inbox_lanes`.
- [ ] **Step 3: Implement the module**
```python
# backend/src/cyclone/inbox_lanes.py
"""Compute the four Inbox lanes from the DB on read."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Iterable
from sqlalchemy.orm import Session
from cyclone.db import Claim, ClaimState, Remittance
from cyclone.scoring import score_pair, ScoreBreakdown
@dataclass
class Lanes:
rejected: list[dict] = field(default_factory=list)
candidates: list[dict] = field(default_factory=list)
unmatched: list[dict] = field(default_factory=list)
done_today: list[dict] = field(default_factory=list)
def _claim_to_row(c: Claim, *, kind: str, score: ScoreBreakdown | None = None) -> dict:
return {
"id": c.id,
"kind": kind,
"patient_control_number": c.patient_control_number,
"charge_amount": float(c.charge_amount),
"payer_id": c.payer_id,
"provider_npi": c.provider_npi,
"state": c.state.value if hasattr(c.state, "value") else str(c.state),
"rejection_reason": c.rejection_reason,
"rejected_at": c.rejected_at.isoformat() if c.rejected_at else None,
"service_date_from": c.service_date_from.isoformat() if c.service_date_from else None,
"score": score.total if score else None,
"score_tier": score.tier if score else None,
"score_breakdown": {
"patient": score.patient,
"date": score.date,
"amount": score.amount,
"provider": score.provider,
} if score else None,
}
def _remit_to_row(r: Remittance, *, candidates: list[tuple[Claim, ScoreBreakdown]]) -> dict:
raw = r.raw_json or {}
return {
"id": r.id,
"kind": "remit",
"payer_claim_control_number": r.payer_claim_control_number,
"charge_amount": float(r.charge_amount),
"payer_id": raw.get("payer_id"),
"rendering_provider_npi": raw.get("rendering_provider_npi"),
"service_date": raw.get("service_date"),
"candidates": [
{
"claim_id": c.id,
"score": s.total,
"tier": s.tier,
"breakdown": {
"patient": s.patient, "date": s.date,
"amount": s.amount, "provider": s.provider,
},
}
for c, s in candidates[:5]
],
}
def _remit_score_fields(r: Remittance) -> dict:
raw = r.raw_json or {}
# The 835 parser stores fields on the ORM model when present. For the
# fields not on the model (service_date, rendering_provider_npi, payer_id,
# patient_control_number), pull from raw_json.
return {
"patient_control_number": raw.get("patient_control_number")
or r.payer_claim_control_number,
"service_date": raw.get("service_date"),
"charge_amount": r.charge_amount,
"rendering_provider_npi": raw.get("rendering_provider_npi"),
}
class _RemitScoringShim:
"""Adapt a Remittance to the duck-typed protocol score_pair expects."""
def __init__(self, r: Remittance):
f = _remit_score_fields(r)
self.patient_control_number = f["patient_control_number"]
self.service_date = _parse_iso_date(f["service_date"])
self.charge_amount = f["charge_amount"]
self.rendering_provider_npi = f["rendering_provider_npi"]
def _parse_iso_date(s: str | None):
if not s:
return None
try:
return datetime.fromisoformat(s).date()
except (ValueError, TypeError):
return None
def compute_lanes(session: Session, *, dismissed_pairs: Iterable[frozenset]) -> Lanes:
lanes = Lanes()
dismissed = set(dismissed_pairs)
# --- Rejected ---
for c in session.query(Claim).filter(Claim.state == ClaimState.REJECTED).all():
lanes.rejected.append(_claim_to_row(c, kind="claim"))
# --- Done today ---
cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
terminal_states = {
ClaimState.PAID, ClaimState.PARTIAL, ClaimState.DENIED,
ClaimState.RECONCILED, ClaimState.REVERSED,
}
for c in session.query(Claim).filter(
Claim.state.in_(terminal_states),
Claim.state_changed_at >= cutoff,
).all():
lanes.done_today.append(_claim_to_row(c, kind="claim"))
# --- Unmatched (claims still submitted) ---
for c in session.query(Claim).filter(Claim.state == ClaimState.SUBMITTED).all():
lanes.unmatched.append(_claim_to_row(c, kind="claim"))
# --- Candidates (remits not yet matched, with scoreable claims) ---
unmatched_remits = session.query(Remittance).filter(
Remittance.claim_id.is_(None),
).all()
submitted_claims = {
c.patient_control_number: c
for c in session.query(Claim).filter(Claim.state == ClaimState.SUBMITTED).all()
}
for r in unmatched_remits:
if r.claim_id:
continue
candidates: list[tuple[Claim, ScoreBreakdown]] = []
shim = _RemitScoringShim(r)
for c in submitted_claims.values():
if c.payer_id and r.raw_json and (r.raw_json.get("payer_id") != c.payer_id):
continue
score = score_pair(c, shim)
if score.tier == "hidden":
continue
if frozenset({c.id, r.id}) in dismissed:
continue
candidates.append((c, score))
candidates.sort(key=lambda cs: -cs[1].total)
if not candidates:
continue
lanes.candidates.append(_remit_to_row(r, candidates=candidates))
return lanes
```
- [ ] **Step 4: Run, confirm PASS**
```bash
cd backend && ../.venv/bin/pytest tests/test_inbox_lanes.py -v
```
Expected: 6 passed (some may need adjustment depending on `state_changed_at` defaults in your model — see note in commit step).
- [ ] **Step 5: Run full suite**
```bash
cd backend && ../.venv/bin/pytest -q
```
Expected: green. If `Claim.state_changed_at` doesn't exist on the ORM model, add it in `db.py` (mapped `DateTime(timezone=True)`, server default `now()`) via a follow-up commit — but most likely it already exists from SP3/SP4.
- [ ] **Step 6: Commit**
```bash
git add backend/src/cyclone/inbox_lanes.py backend/tests/test_inbox_lanes.py
git commit -m "feat(sp6): compute_lanes — 4 lanes on read with scoring"
```
---
## Phase 4 — API endpoints
## Task 7: `GET /api/inbox/lanes` (TDD)
**Files:**
- Modify: `backend/src/cyclone/api.py` (add new endpoint near other GET endpoints)
- Test: `backend/tests/test_inbox_endpoints.py` (new file)
- [ ] **Step 1: Write the failing test**
```python
# backend/tests/test_inbox_endpoints.py
from fastapi.testclient import TestClient
def test_lanes_endpoint_returns_four_keys(client: TestClient):
r = client.get("/api/inbox/lanes")
assert r.status_code == 200
body = r.json()
assert set(body.keys()) == {"rejected", "candidates", "unmatched", "done_today"}
for v in body.values():
assert isinstance(v, list)
```
- [ ] **Step 2: Run, confirm FAIL**
```bash
cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -q
```
- [ ] **Step 3: Add the endpoint**
In `backend/src/cyclone/api.py`, near the other GET endpoints, add:
```python
from cyclone.inbox_lanes import compute_lanes
@app.get("/api/inbox/lanes")
def inbox_lanes():
S = get_sessionmaker()()
dismissed_pairs = getattr(app.state, "dismissed_pairs", set())
with S() as session:
lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs)
return {
"rejected": lanes.rejected,
"candidates": lanes.candidates,
"unmatched": lanes.unmatched,
"done_today": lanes.done_today,
}
```
Also ensure `dismissed_pairs` is initialized on `app.state` in `conftest.py` (or in the lifespan if there is one):
```python
# backend/tests/conftest.py — extend the existing autouse fixture:
if not hasattr(app.state, "dismissed_pairs"):
app.state.dismissed_pairs = set()
```
- [ ] **Step 4: Run, confirm PASS**
```bash
cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -v
```
- [ ] **Step 5: Full suite**
```bash
cd backend && ../.venv/bin/pytest -q
```
- [ ] **Step 6: Commit**
```bash
git add backend/src/cyclone/api.py backend/tests/conftest.py backend/tests/test_inbox_endpoints.py
git commit -m "feat(sp6): GET /api/inbox/lanes"
```
---
## Task 8: POST match + dismiss endpoints (TDD)
**Files:**
- Modify: `backend/src/cyclone/api.py`
- Modify: `backend/tests/test_inbox_endpoints.py` (append)
- [ ] **Step 1: Write the failing tests**
Append to `backend/tests/test_inbox_endpoints.py`:
```python
def test_match_endpoint_links_remit_to_claim(client: TestClient):
# Seed
from cyclone.db import init_db, get_sessionmaker, Claim, Remittance, ClaimState
S = get_sessionmaker()()
with S() as s:
s.add(Claim(id="C1", batch_id="B-1", patient_control_number="PCN-1",
state=ClaimState.SUBMITTED))
s.add(Remittance(id="R1", batch_id="B-1",
payer_claim_control_number="PCN-1"))
s.commit()
r = client.post("/api/inbox/candidates/R1/match", json={"claim_id": "C1"})
assert r.status_code == 200
with S() as s:
c = s.get(Claim, "C1")
assert c.matched_remittance_id == "R1"
def test_match_409_if_claim_already_matched(client: TestClient):
from cyclone.db import init_db, get_sessionmaker, Claim, Remittance, ClaimState
S = get_sessionmaker()()
with S() as s:
s.add(Claim(id="C1", batch_id="B-1", patient_control_number="X",
state=ClaimState.SUBMITTED,
matched_remittance_id="R-OTHER"))
s.add(Remittance(id="R1", batch_id="B-1",
payer_claim_control_number="X"))
s.commit()
r = client.post("/api/inbox/candidates/R1/match", json={"claim_id": "C1"})
assert r.status_code == 409
assert "current_state" in r.json()
def test_dismiss_endpoint_adds_pair_to_session_set(client: TestClient):
r = client.post(
"/api/inbox/candidates/dismiss",
json={"pairs": [{"claim_id": "C1", "remit_id": "R1"}]},
)
assert r.status_code == 200
dismissed = client.app.state.dismissed_pairs
assert frozenset({"C1", "R1"}) in dismissed
```
- [ ] **Step 2: Run, confirm FAIL**
```bash
cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -q
```
- [ ] **Step 3: Implement both endpoints**
In `backend/src/cyclone/api.py`:
```python
from fastapi import HTTPException
from cyclone.db import Claim, ClaimState, Remittance
@app.post("/api/inbox/candidates/{remit_id}/match")
def inbox_match_candidate(remit_id: str, body: dict):
claim_id = body.get("claim_id")
if not claim_id:
raise HTTPException(400, "claim_id required")
S = get_sessionmaker()()
with S() as s:
claim = s.get(Claim, claim_id)
remit = s.get(Remittance, remit_id)
if claim is None or remit is None:
raise HTTPException(404, "claim or remit not found")
if claim.matched_remittance_id and claim.matched_remittance_id != remit_id:
raise HTTPException(
409,
detail={
"error": "claim_already_matched",
"current_state": claim.state.value if hasattr(claim.state, "value") else str(claim.state),
"matched_remittance_id": claim.matched_remittance_id,
},
)
claim.matched_remittance_id = remit_id
claim.claim_id = remit_id # back-ref; safe no-op on already linked
s.commit()
bus = _get_event_bus()
bus.publish("claim.matched", {"claim_id": claim_id, "remit_id": remit_id})
return {"ok": True}
@app.post("/api/inbox/candidates/dismiss")
def inbox_dismiss_candidates(body: dict):
pairs = body.get("pairs") or []
if not hasattr(app.state, "dismissed_pairs"):
app.state.dismissed_pairs = set()
for p in pairs:
cid = p.get("claim_id")
rid = p.get("remit_id")
if cid and rid:
app.state.dismissed_pairs.add(frozenset({cid, rid}))
return {"ok": True, "dismissed_count": len(pairs)}
```
(Note: depending on the ORM model, `claim.claim_id` may not exist — if so, just omit that line; the `matched_remittance_id` set is what matters.)
- [ ] **Step 4: Run, confirm PASS**
```bash
cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -v
```
- [ ] **Step 5: Full suite**
```bash
cd backend && ../.venv/bin/pytest -q
```
- [ ] **Step 6: Commit**
```bash
git add backend/src/cyclone/api.py backend/tests/test_inbox_endpoints.py
git commit -m "feat(sp6): manual match + dismiss endpoints"
```
---
## Task 9: POST `/api/inbox/rejected/resubmit` (TDD)
**Files:**
- Modify: `backend/src/cyclone/api.py`
- Modify: `backend/tests/test_inbox_endpoints.py`
- [ ] **Step 1: Append the failing test**
```python
def test_resubmit_moves_rejected_to_submitted_and_increments_count(client: TestClient):
from cyclone.db import init_db, get_sessionmaker, Claim, ClaimState
S = get_sessionmaker()()
with S() as s:
s.add(Claim(id="C1", batch_id="B-1", patient_control_number="X",
state=ClaimState.REJECTED,
rejection_reason="old",
resubmit_count=2))
s.commit()
r = client.post("/api/inbox/rejected/resubmit", json={"claim_ids": ["C1"]})
assert r.status_code == 200, r.text
with S() as s:
c = s.get(Claim, "C1")
assert c.state == ClaimState.SUBMITTED
assert c.rejection_reason is None
assert c.resubmit_count == 3
def test_resubmit_409_for_non_rejected_claim(client: TestClient):
from cyclone.db import init_db, get_sessionmaker, Claim, ClaimState
S = get_sessionmaker()()
with S() as s:
s.add(Claim(id="C1", batch_id="B-1", patient_control_number="X",
state=ClaimState.SUBMITTED))
s.commit()
r = client.post("/api/inbox/rejected/resubmit", json={"claim_ids": ["C1"]})
assert r.status_code == 409
```
- [ ] **Step 2: Run, confirm FAIL**
```bash
cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -q
```
- [ ] **Step 3: Implement**
```python
@app.post("/api/inbox/rejected/resubmit")
def inbox_resubmit_rejected(body: dict):
ids = body.get("claim_ids") or []
if not ids:
raise HTTPException(400, "claim_ids required")
S = get_sessionmaker()()
accepted = []
conflicts = []
with S() as s:
for cid in ids:
c = s.get(Claim, cid)
if c is None:
continue
if c.state != ClaimState.REJECTED:
conflicts.append({
"claim_id": cid,
"current_state": c.state.value if hasattr(c.state, "value") else str(c.state),
})
continue
c.state = ClaimState.SUBMITTED
c.rejection_reason = None
c.rejected_at = None
c.resubmit_count = (c.resubmit_count or 0) + 1
accepted.append(cid)
s.commit()
bus = _get_event_bus()
for cid in accepted:
bus.publish("claim.resubmitted", {"claim_id": cid})
return {"ok": True, "resubmitted": accepted, "conflicts": conflicts}
```
- [ ] **Step 4: Run, confirm PASS**
```bash
cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -v
```
- [ ] **Step 5: Full suite**
```bash
cd backend && ../.venv/bin/pytest -q
```
- [ ] **Step 6: Commit**
```bash
git add backend/src/cyclone/api.py backend/tests/test_inbox_endpoints.py
git commit -m "feat(sp6): bulk resubmit rejected claims"
```
---
## Task 10: GET `/api/inbox/export.csv` (TDD)
**Files:**
- Modify: `backend/src/cyclone/api.py`
- Modify: `backend/tests/test_inbox_endpoints.py`
- [ ] **Step 1: Append the failing test**
```python
def test_export_csv_streams_rows_with_csv_content_type(client: TestClient):
r = client.get("/api/inbox/export.csv?lane=rejected")
assert r.status_code == 200
assert "text/csv" in r.headers["content-type"]
# Body should at least include the header row.
body = r.text
assert "id" in body.splitlines()[0]
```
- [ ] **Step 2: Run, confirm FAIL**
```bash
cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -q
```
- [ ] **Step 3: Implement**
```python
import csv
import io
from fastapi.responses import StreamingResponse
@app.get("/api/inbox/export.csv")
def inbox_export_csv(lane: str):
if lane not in {"rejected", "candidates", "unmatched", "done_today"}:
raise HTTPException(400, f"unknown lane: {lane}")
S = get_sessionmaker()()
dismissed_pairs = getattr(app.state, "dismissed_pairs", set())
with S() as session:
lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs)
rows = getattr(lanes, lane)
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow([
"id", "kind", "patient_control_number", "charge_amount",
"payer_id", "provider_npi", "state", "rejection_reason",
"service_date_from", "score",
])
for r in rows:
writer.writerow([
r.get("id") or r.get("payer_claim_control_number"),
r.get("kind"),
r.get("patient_control_number"),
r.get("charge_amount"),
r.get("payer_id"),
r.get("provider_npi") or r.get("rendering_provider_npi"),
r.get("state"),
r.get("rejection_reason"),
r.get("service_date_from") or r.get("service_date"),
r.get("score"),
])
buf.seek(0)
return StreamingResponse(
iter([buf.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="inbox-{lane}.csv"'},
)
```
- [ ] **Step 4: Run, confirm PASS**
```bash
cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -v
```
- [ ] **Step 5: Full suite + commit**
```bash
cd backend && ../.venv/bin/pytest -q
git add backend/src/cyclone/api.py backend/tests/test_inbox_endpoints.py
git commit -m "feat(sp6): CSV export endpoint"
```
---
## Phase 5 — Frontend scaffolding
## Task 11: API client + types
**Files:**
- Modify: `src/lib/api.ts` (append)
- Modify: `src/lib/api.test.ts` (or create `src/lib/inbox-api.test.ts`)
- [ ] **Step 1: Write the failing test**
```typescript
// src/lib/inbox-api.test.ts
import { describe, expect, it, vi } from "vitest";
import { fetchInboxLanes, matchCandidate, dismissCandidates, resubmitRejected, exportInboxCsv } from "./inbox-api";
describe("inbox-api", () => {
it("fetchInboxLanes hits /api/inbox/lanes", async () => {
const fetchSpy = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ rejected: [], candidates: [], unmatched: [], done_today: [] }),
});
vi.stubGlobal("fetch", fetchSpy);
const lanes = await fetchInboxLanes();
expect(fetchSpy).toHaveBeenCalledWith("/api/inbox/lanes", expect.anything());
expect(lanes.rejected).toEqual([]);
vi.unstubAllGlobals();
});
});
```
- [ ] **Step 2: Run, confirm FAIL**
```bash
cd . && npx vitest run src/lib/inbox-api.test.ts
```
- [ ] **Step 3: Implement**
```typescript
// src/lib/inbox-api.ts
export type ScoreBreakdown = {
patient: number;
date: number;
amount: number;
provider: number;
};
export type ScoreTier = "strong" | "weak" | "hidden";
export type InboxClaimRow = {
id: string;
kind: "claim";
patient_control_number: string;
charge_amount: number;
payer_id: string | null;
provider_npi: string | null;
state: string;
rejection_reason: string | null;
rejected_at: string | null;
service_date_from: string | null;
score?: number | null;
score_tier?: ScoreTier | null;
score_breakdown?: ScoreBreakdown | null;
};
export type InboxCandidateRow = {
id: string;
kind: "remit";
payer_claim_control_number: string;
charge_amount: number;
payer_id: string | null;
rendering_provider_npi: string | null;
service_date: string | null;
candidates: Array<{
claim_id: string;
score: number;
tier: ScoreTier;
breakdown: ScoreBreakdown;
}>;
};
export type InboxLanes = {
rejected: InboxClaimRow[];
candidates: InboxCandidateRow[];
unmatched: InboxClaimRow[];
done_today: InboxClaimRow[];
};
async function getJson<T>(url: string): Promise<T> {
const r = await fetch(url);
if (!r.ok) throw new Error(`${r.status} ${url}`);
return (await r.json()) as T;
}
export async function fetchInboxLanes(): Promise<InboxLanes> {
return getJson<InboxLanes>("/api/inbox/lanes");
}
export async function matchCandidate(remitId: string, claimId: string): Promise<void> {
const r = await fetch(`/api/inbox/candidates/${encodeURIComponent(remitId)}/match`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ claim_id: claimId }),
});
if (!r.ok) throw new Error(`match failed: ${r.status}`);
}
export async function dismissCandidates(pairs: Array<{ claim_id: string; remit_id: string }>): Promise<void> {
const r = await fetch("/api/inbox/candidates/dismiss", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ pairs }),
});
if (!r.ok) throw new Error(`dismiss failed: ${r.status}`);
}
export async function resubmitRejected(claimIds: string[]): Promise<void> {
const r = await fetch("/api/inbox/rejected/resubmit", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ claim_ids: claimIds }),
});
if (!r.ok) throw new Error(`resubmit failed: ${r.status}`);
}
export function exportInboxCsvUrl(lane: "rejected" | "candidates" | "unmatched" | "done_today"): string {
return `/api/inbox/export.csv?lane=${lane}`;
}
```
- [ ] **Step 4: Run, confirm PASS**
```bash
npx vitest run src/lib/inbox-api.test.ts
```
- [ ] **Step 5: Commit**
```bash
git add src/lib/inbox-api.ts src/lib/inbox-api.test.ts
git commit -m "feat(sp6): inbox api client + types"
```
---
## Task 12: Ticker Tape styling primitives
**Files:**
- Modify: `src/index.css` (or `src/styles/tokens.css` if it exists — check the project structure)
- Modify: `tailwind.config.js` (extend theme)
- [ ] **Step 1: Inspect existing token files**
```bash
ls src/*.css src/styles 2>/dev/null; cat src/index.css 2>/dev/null | head -30; cat tailwind.config.js 2>/dev/null | head -40
```
Read enough to see how colors are currently organized. Match the existing pattern.
- [ ] **Step 2: Add the Ticker Tape tokens**
Add to the appropriate CSS file (or Tailwind theme extension). Three-color accent + dark base:
```css
/* src/styles/ticker-tape.css */
:root {
--tt-bg: #0a0e14;
--tt-bg-elev: #11161f;
--tt-ink: #e6e1d3;
--tt-ink-dim: #8a8b8e;
--tt-amber: #f4b942; /* action needed */
--tt-oxblood: #8b2c2c; /* rejection */
--tt-ink-blue: #2c4a6e; /* neutral / waiting */
--tt-muted: #4a4d54;
}
@font-face {
font-family: "IBM Plex Serif";
font-display: swap;
src: url("https://fonts.gstatic.com/s/ibmplexserif/v19/jizDREVItHgc8qDIbSTKq4XKVUaaRA0od9b6XQ.woff2") format("woff2");
font-weight: 400 700;
}
@font-face {
font-family: "IBM Plex Mono";
font-display: swap;
src: url("https://fonts.gstatic.com/s/ibmplexmono/v19/-F63fjptAgt5VM-kVkqdyU8n5igg1l9kn-s.woff2") format("woff2");
font-weight: 400 700;
}
```
(If the project uses Tailwind theme.extend, prefer extending `theme.extend.colors` and `theme.extend.fontFamily` over CSS variables.)
- [ ] **Step 3: Smoke test**
```bash
npx tsc --noEmit
```
Expected: no errors.
- [ ] **Step 4: Commit**
```bash
git add src/index.css src/styles/ticker-tape.css tailwind.config.js
git commit -m "feat(sp6): Ticker Tape color + typography tokens"
```
---
## Task 13: `/inbox` route + nav link
**Files:**
- Modify: `src/App.tsx`
- Modify: `src/components/Sidebar.tsx` (or whatever the nav component is)
- [ ] **Step 1: Find the route table and nav links**
```bash
grep -n "Route\|path=" src/App.tsx | head -10
grep -n "Dashboard\|/claims\|/remittances" src/components/*.tsx | head -10
```
- [ ] **Step 2: Add the route and link**
In the route table, add (position: second, after Dashboard):
```tsx
import Inbox from "./pages/Inbox";
// ...
<Route path="/inbox" element={<Inbox />} />
```
In the sidebar, add a link:
```tsx
<NavLink to="/inbox">Inbox</NavLink>
```
- [ ] **Step 3: Create the placeholder Inbox page so the route resolves**
```tsx
// src/pages/Inbox.tsx
export default function Inbox() {
return <div className="p-8 text-[var(--tt-ink)] bg-[var(--tt-bg)] min-h-screen">
Inbox coming up in T14T17
</div>;
}
```
- [ ] **Step 4: Smoke test**
```bash
npx tsc --noEmit && npx vitest run --reporter=dot
```
- [ ] **Step 5: Commit**
```bash
git add src/App.tsx src/components/Sidebar.tsx src/pages/Inbox.tsx
git commit -m "feat(sp6): /inbox route + sidebar nav link"
```
---
## Phase 6 — Inbox components
## Task 14: `InboxRow` (TDD)
**Files:**
- Create: `src/components/inbox/InboxRow.tsx`
- Create: `src/components/inbox/InboxRow.test.tsx`
- [ ] **Step 1: Write the failing test**
```tsx
// src/components/inbox/InboxRow.test.tsx
import { describe, expect, it, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { InboxRow } from "./InboxRow";
const baseClaim = {
id: "CLP-7741",
kind: "claim" as const,
patient_control_number: "PCN-001",
charge_amount: 1240,
payer_id: "CO-MCD",
provider_npi: "1234567890",
state: "rejected",
rejection_reason: "999 AK9 R",
rejected_at: "2026-06-20T09:12:00Z",
service_date_from: null,
score: null,
score_tier: null,
score_breakdown: null,
};
describe("InboxRow", () => {
it("renders the claim id, payer, charge", () => {
render(<table><tbody><InboxRow row={baseClaim} accent="oxblood" onClick={() => {}} /></tbody></table>);
expect(screen.getByText("CLP-7741")).toBeDefined();
expect(screen.getByText("CO-MCD")).toBeDefined();
expect(screen.getByText("$1,240.00")).toBeDefined();
});
it("renders the rejection reason when present", () => {
render(<table><tbody><InboxRow row={baseClaim} accent="oxblood" onClick={() => {}} /></tbody></table>);
expect(screen.getByText(/999 AK9 R/)).toBeDefined();
});
it("calls onClick when clicked", () => {
const onClick = vi.fn();
render(<table><tbody><InboxRow row={baseClaim} accent="oxblood" onClick={onClick} /></tbody></table>);
fireEvent.click(screen.getByText("CLP-7741"));
expect(onClick).toHaveBeenCalledOnce();
});
it("renders score sparkline for candidate rows", () => {
const candidateRow = {
id: "R-1",
kind: "remit" as const,
payer_claim_control_number: "PCN-X",
charge_amount: 500,
payer_id: "AETNA",
rendering_provider_npi: "1234567890",
service_date: null,
candidates: [{
claim_id: "C-1", score: 92, tier: "strong" as const,
breakdown: { patient: 1, date: 1, amount: 1, provider: 1 },
}],
};
render(<table><tbody><InboxRow row={candidateRow} accent="amber" onClick={() => {}} /></tbody></table>);
expect(screen.getByText("92")).toBeDefined();
});
});
```
- [ ] **Step 2: Run, confirm FAIL**
```bash
npx vitest run src/components/inbox/InboxRow.test.tsx
```
- [ ] **Step 3: Implement**
```tsx
// src/components/inbox/InboxRow.tsx
import type { InboxClaimRow, InboxCandidateRow } from "../../lib/inbox-api";
type Accent = "oxblood" | "amber" | "ink-blue" | "muted";
const ACCENT_VAR: Record<Accent, string> = {
oxblood: "var(--tt-oxblood)",
amber: "var(--tt-amber)",
"ink-blue": "var(--tt-ink-blue)",
muted: "var(--tt-muted)",
};
function fmtMoney(n: number): string {
return n.toLocaleString("en-US", { style: "currency", currency: "USD" });
}
function fmtAgo(iso: string | null): string {
if (!iso) return "";
const ms = Date.now() - new Date(iso).getTime();
const mins = Math.round(ms / 60_000);
if (mins < 60) return `${mins}m`;
const hrs = Math.round(mins / 60);
if (hrs < 24) return `${hrs}h`;
const days = Math.round(hrs / 24);
return `${days}d`;
}
type Props =
| { row: InboxClaimRow; accent: Accent; onClick: () => void }
| { row: InboxCandidateRow; accent: Accent; onClick: () => void };
export function InboxRow(props: Props) {
const { row, accent, onClick } = props;
const id = "id" in row ? row.id : (row as InboxCandidateRow).payer_claim_control_number;
const payer = row.payer_id ?? "";
const charge = fmtMoney(row.charge_amount);
const rejectedAt = "rejected_at" in row ? row.rejected_at : null;
const reason = "rejection_reason" in row ? row.rejection_reason : null;
const topScore = "candidates" in row && row.candidates.length ? row.candidates[0].score : null;
const filled = "candidates" in row && row.candidates.length
? row.candidates[0].breakdown
: null;
return (
<tr
onClick={onClick}
className="border-b border-[var(--tt-bg-elev)] hover:bg-[var(--tt-bg-elev)] cursor-pointer"
>
<td style={{ width: 4, background: ACCENT_VAR[accent], padding: 0 }} />
<td className="font-mono text-[var(--tt-ink)] py-2 pl-3">{id}</td>
<td className="font-mono text-[var(--tt-ink-dim)] text-xs py-2">
{reason ? reason : topScore != null ? `${topScore}` : ""}
</td>
<td className="font-mono text-[var(--tt-ink-dim)] text-xs py-2">
{fmtAgo(rejectedAt)}
</td>
<td className="font-mono text-[var(--tt-ink)] text-right py-2">{charge}</td>
<td className="text-[var(--tt-ink-dim)] text-xs uppercase tracking-wider py-2 pl-4">{payer}</td>
<td className="text-[var(--tt-ink)] py-2 pr-3">
{filled ? (
<span className="font-mono text-xs">
{["patient", "date", "amount", "provider"].map((k) => (
<span
key={k}
title={`${k}: ${(filled as any)[k]}`}
className="inline-block w-2 h-3 mr-0.5 align-middle"
style={{ background: (filled as any)[k] >= 0.5 ? "var(--tt-amber)" : "var(--tt-muted)" }}
/>
))}
</span>
) : null}
</td>
</tr>
);
}
```
- [ ] **Step 4: Run, confirm PASS**
```bash
npx vitest run src/components/inbox/InboxRow.test.tsx
```
- [ ] **Step 5: Commit**
```bash
git add src/components/inbox/InboxRow.tsx src/components/inbox/InboxRow.test.tsx
git commit -m "feat(sp6): InboxRow with Ticker Tape accents"
```
---
## Task 15: `Lane` (TDD)
**Files:**
- Create: `src/components/inbox/Lane.tsx`
- Create: `src/components/inbox/Lane.test.tsx`
- [ ] **Step 1: Write the failing test**
```tsx
// src/components/inbox/Lane.test.tsx
import { describe, expect, it, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { Lane } from "./Lane";
const sampleRows = [
{
id: "CLP-1",
kind: "claim" as const,
patient_control_number: "PCN-1",
charge_amount: 100,
payer_id: "PAYER-A",
provider_npi: "1234567890",
state: "rejected",
rejection_reason: "R69",
rejected_at: "2026-06-20T09:00:00Z",
service_date_from: null,
score: null,
score_tier: null,
score_breakdown: null,
},
];
describe("Lane", () => {
it("renders the lane name and row count", () => {
render(<Lane name="REJECTED" accent="oxblood" rows={sampleRows} onRowClick={() => {}} />);
expect(screen.getByText("REJECTED")).toBeDefined();
expect(screen.getByText(/1/)).toBeDefined();
});
it("renders an empty state when no rows", () => {
render(<Lane name="DONE" accent="muted" rows={[]} onRowClick={() => {}} />);
expect(screen.getByText(/nothing here/i)).toBeDefined();
});
it("supports multi-select via row checkbox toggle", () => {
const onSelectionChange = vi.fn();
render(
<Lane
name="REJECTED"
accent="oxblood"
rows={sampleRows}
onRowClick={() => {}}
onSelectionChange={onSelectionChange}
/>
);
const cb = screen.getByRole("checkbox");
fireEvent.click(cb);
expect(onSelectionChange).toHaveBeenCalled();
});
});
```
- [ ] **Step 2: Run, confirm FAIL**
```bash
npx vitest run src/components/inbox/Lane.test.tsx
```
- [ ] **Step 3: Implement**
```tsx
// src/components/inbox/Lane.tsx
import { useState } from "react";
import { InboxRow } from "./InboxRow";
import type { InboxClaimRow, InboxCandidateRow } from "../../lib/inbox-api";
type Accent = "oxblood" | "amber" | "ink-blue" | "muted";
type Row = InboxClaimRow | InboxCandidateRow;
export function Lane({
name,
accent,
rows,
onRowClick,
onSelectionChange,
}: {
name: string;
accent: Accent;
rows: Row[];
onRowClick: (row: Row) => void;
onSelectionChange?: (ids: string[]) => void;
}) {
const [selected, setSelected] = useState<Set<string>>(new Set());
function toggle(id: string) {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelected(next);
onSelectionChange?.(Array.from(next));
}
return (
<section
className="flex-1 min-w-[280px] bg-[var(--tt-bg-elev)] border border-[var(--tt-bg)] rounded-sm"
data-lane={name.toLowerCase()}
>
<header className="px-3 py-2 border-b border-[var(--tt-bg)] flex items-baseline justify-between">
<h2 className="font-mono uppercase tracking-widest text-[var(--tt-ink)] text-xs">{name}</h2>
<span className="font-mono text-[var(--tt-amber)] text-lg tabular-nums">{rows.length}</span>
</header>
{rows.length === 0 ? (
<p className="p-4 text-xs text-[var(--tt-muted)] font-mono">Nothing here.</p>
) : (
<table className="w-full text-sm">
<tbody>
{rows.map((row) => {
const id = "id" in row ? row.id : row.payer_claim_control_number;
return (
<tr key={id} className="contents">
<td className="w-6 pr-1">
<input
type="checkbox"
aria-label={`Select ${id}`}
checked={selected.has(id)}
onChange={() => toggle(id)}
onClick={(e) => e.stopPropagation()}
/>
</td>
<td className="w-full">
<InboxRow row={row} accent={accent} onClick={() => onRowClick(row)} />
</td>
</tr>
);
})}
</tbody>
</table>
)}
</section>
);
}
```
- [ ] **Step 4: Run, confirm PASS**
```bash
npx vitest run src/components/inbox/Lane.test.tsx
```
- [ ] **Step 5: Commit**
```bash
git add src/components/inbox/Lane.tsx src/components/inbox/Lane.test.tsx
git commit -m "feat(sp6): Lane component with multi-select"
```
---
## Task 16: `InboxHeader` + `CandidateBreakdown` (TDD)
**Files:**
- Create: `src/components/inbox/InboxHeader.tsx`
- Create: `src/components/inbox/InboxHeader.test.tsx`
- Create: `src/components/inbox/CandidateBreakdown.tsx`
- Create: `src/components/inbox/CandidateBreakdown.test.tsx`
- [ ] **Step 1: Header test**
```tsx
// src/components/inbox/InboxHeader.test.tsx
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { InboxHeader } from "./InboxHeader";
describe("InboxHeader", () => {
it("renders the date and counts", () => {
render(<InboxHeader needEyesCount={6} doneTodayCount={23} />);
expect(screen.getByText(/6 items need eyes/)).toBeDefined();
expect(screen.getByText(/23 done today/)).toBeDefined();
});
});
```
- [ ] **Step 2: Breakdown test**
```tsx
// src/components/inbox/CandidateBreakdown.test.tsx
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { CandidateBreakdown } from "./CandidateBreakdown";
describe("CandidateBreakdown", () => {
it("renders per-field scores", () => {
render(
<CandidateBreakdown
score={92}
breakdown={{ patient: 1, date: 1, amount: 1, provider: 1 }}
tier="strong"
/>
);
expect(screen.getByText(/patient/i)).toBeDefined();
expect(screen.getByText(/date/i)).toBeDefined();
expect(screen.getByText(/amount/i)).toBeDefined();
expect(screen.getByText(/provider/i)).toBeDefined();
expect(screen.getByText("92")).toBeDefined();
});
});
```
- [ ] **Step 3: Run, confirm FAIL**
```bash
npx vitest run src/components/inbox/
```
- [ ] **Step 4: Implement both**
```tsx
// src/components/inbox/InboxHeader.tsx
export function InboxHeader({ needEyesCount, doneTodayCount }: {
needEyesCount: number;
doneTodayCount: number;
}) {
const now = new Date();
const day = now.toLocaleDateString("en-US", { weekday: "short" }).toUpperCase();
const time = now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
return (
<header className="sticky top-0 z-10 bg-[var(--tt-bg)] border-b border-[var(--tt-bg-elev)] px-6 py-4">
<h1 className="font-serif text-2xl text-[var(--tt-ink)] tracking-tight">
INBOX · <span className="font-mono text-[var(--tt-amber)]">{day} {time}</span>
</h1>
<p className="font-mono text-xs text-[var(--tt-ink-dim)] mt-1">
<span className="text-[var(--tt-amber)]">{needEyesCount}</span> items need eyes ·{" "}
<span className="text-[var(--tt-ink)]">{doneTodayCount}</span> done today
</p>
</header>
);
}
```
```tsx
// src/components/inbox/CandidateBreakdown.tsx
import type { ScoreBreakdown, ScoreTier } from "../../lib/inbox-api";
export function CandidateBreakdown({
score,
breakdown,
tier,
}: {
score: number;
breakdown: ScoreBreakdown;
tier: ScoreTier;
}) {
const fields = [
{ key: "patient" as const, label: "Patient", weight: 40 },
{ key: "date" as const, label: "Date", weight: 25 },
{ key: "amount" as const, label: "Amount", weight: 20 },
{ key: "provider" as const, label: "Provider", weight: 15 },
];
return (
<div className="font-mono text-xs">
<div className="flex items-baseline justify-between mb-2">
<span className="text-[var(--tt-ink-dim)] uppercase tracking-widest">Score</span>
<span className="text-2xl text-[var(--tt-amber)] tabular-nums">{score}</span>
</div>
<ul className="space-y-1">
{fields.map((f) => (
<li key={f.key} className="flex justify-between">
<span className="text-[var(--tt-ink)]">{f.label}</span>
<span className="text-[var(--tt-ink-dim)] tabular-nums">
{Math.round(breakdown[f.key] * f.weight)} / {f.weight}
</span>
</li>
))}
</ul>
<p className="mt-3 text-[var(--tt-ink-dim)] uppercase tracking-widest">{tier}</p>
</div>
);
}
```
- [ ] **Step 5: Run, confirm PASS**
```bash
npx vitest run src/components/inbox/
```
- [ ] **Step 6: Commit**
```bash
git add src/components/inbox/InboxHeader.tsx src/components/inbox/InboxHeader.test.tsx src/components/inbox/CandidateBreakdown.tsx src/components/inbox/CandidateBreakdown.test.tsx
git commit -m "feat(sp6): InboxHeader + CandidateBreakdown"
```
---
## Task 17: `Inbox` page + `useInboxLanes` hook (TDD)
**Files:**
- Create: `src/hooks/useInboxLanes.ts`
- Create: `src/hooks/useInboxLanes.test.ts`
- Replace: `src/pages/Inbox.tsx` (real implementation, not placeholder)
- [ ] **Step 1: Hook test**
```typescript
// src/hooks/useInboxLanes.test.ts
import { describe, expect, it, vi } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { useInboxLanes } from "./useInboxLanes";
describe("useInboxLanes", () => {
it("loads lanes on mount", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ rejected: [], candidates: [], unmatched: [], done_today: [] }),
}));
const { result } = renderHook(() => useInboxLanes());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.lanes).toEqual({ rejected: [], candidates: [], unmatched: [], done_today: [] });
vi.unstubAllGlobals();
});
});
```
- [ ] **Step 2: Page test**
```tsx
// src/pages/Inbox.test.tsx
import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import Inbox from "./Inbox";
describe("Inbox page", () => {
it("renders the four lane headers", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ rejected: [], candidates: [], unmatched: [], done_today: [] }),
}));
render(<Inbox />);
await waitFor(() => {
expect(screen.getByText("REJECTED")).toBeDefined();
expect(screen.getByText("CANDIDATES")).toBeDefined();
expect(screen.getByText("UNMATCHED")).toBeDefined();
expect(screen.getByText("DONE")).toBeDefined();
});
vi.unstubAllGlobals();
});
});
```
- [ ] **Step 3: Run, confirm FAIL**
```bash
npx vitest run src/hooks/useInboxLanes.test.ts src/pages/Inbox.test.tsx
```
- [ ] **Step 4: Implement**
```typescript
// src/hooks/useInboxLanes.ts
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchInboxLanes, type InboxLanes } from "../lib/inbox-api";
export function useInboxLanes() {
const [lanes, setLanes] = useState<InboxLanes>({
rejected: [], candidates: [], unmatched: [], done_today: [],
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const refetch = useCallback(async () => {
try {
const next = await fetchInboxLanes();
setLanes(next);
setError(null);
} catch (e) {
setError(e as Error);
} finally {
setLoading(false);
}
}, []);
const refetchDebounced = useCallback(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => { void refetch(); }, 500);
}, [refetch]);
useEffect(() => { void refetch(); }, [refetch]);
// Subscribe to live-tail events. Coalesce bursts via 500ms debounce.
useEffect(() => {
const es = new EventSource("/api/claims/stream");
const onAny = () => refetchDebounced();
es.addEventListener("claim.rejected", onAny);
es.addEventListener("claim.matched", onAny);
es.addEventListener("claim.resubmitted", onAny);
es.addEventListener("claim.paid", onAny);
return () => { es.close(); };
}, [refetchDebounced]);
return { lanes, loading, error, refetch };
}
```
(Note: if `/api/claims/stream` isn't an EventSource (it's NDJSON), use the existing `useTailStream` hook from SP5 instead — replace this useEffect with that hook's `events` dependency.)
```tsx
// src/pages/Inbox.tsx
import { Lane } from "../components/inbox/Lane";
import { InboxHeader } from "../components/inbox/InboxHeader";
import { useInboxLanes } from "../hooks/useInboxLanes";
export default function Inbox() {
const { lanes, loading, error } = useInboxLanes();
if (loading) {
return (
<div className="min-h-screen bg-[var(--tt-bg)] p-8">
<p className="font-mono text-[var(--tt-ink-dim)]">loading</p>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-[var(--tt-bg)] p-8">
<p className="font-mono text-[var(--tt-oxblood)]">error: {error.message}</p>
</div>
);
}
const needEyes =
lanes.rejected.length + lanes.candidates.length + lanes.unmatched.length;
return (
<div className="min-h-screen bg-[var(--tt-bg)] text-[var(--tt-ink)]">
<InboxHeader needEyesCount={needEyes} doneTodayCount={lanes.done_today.length} />
<main className="p-4 flex gap-4 items-start">
<Lane name="REJECTED" accent="oxblood" rows={lanes.rejected} onRowClick={() => {}} />
<Lane name="CANDIDATES" accent="amber" rows={lanes.candidates} onRowClick={() => {}} />
<Lane name="UNMATCHED" accent="ink-blue" rows={lanes.unmatched} onRowClick={() => {}} />
<Lane name="DONE" accent="muted" rows={lanes.done_today} onRowClick={() => {}} />
</main>
</div>
);
}
```
- [ ] **Step 5: Run, confirm PASS**
```bash
npx vitest run src/pages/Inbox.test.tsx src/hooks/useInboxLanes.test.ts
```
- [ ] **Step 6: Type-check + full frontend suite**
```bash
npx tsc --noEmit && npx vitest run --reporter=dot
```
- [ ] **Step 7: Commit**
```bash
git add src/hooks/useInboxLanes.ts src/hooks/useInboxLanes.test.ts src/pages/Inbox.tsx src/pages/Inbox.test.tsx
git commit -m "feat(sp6): Inbox page + useInboxLanes hook"
```
---
## Phase 7 — Bulk actions + tail integration
## Task 18: Wire bulk action bar (TDD)
**Files:**
- Modify: `src/components/inbox/Lane.tsx`
- Create: `src/components/inbox/BulkBar.tsx`
- Modify: `src/components/inbox/Lane.test.tsx`
- Create: `src/components/inbox/BulkBar.test.tsx`
- [ ] **Step 1: BulkBar test**
```tsx
// src/components/inbox/BulkBar.test.tsx
import { describe, expect, it, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { BulkBar } from "./BulkBar";
describe("BulkBar", () => {
it("renders action buttons appropriate for the lane", () => {
render(
<BulkBar
lane="rejected"
count={3}
onResubmit={vi.fn()}
onDismiss={vi.fn()}
onExport={vi.fn()}
/>
);
expect(screen.getByRole("button", { name: /resubmit/i })).toBeDefined();
expect(screen.getByRole("button", { name: /export/i })).toBeDefined();
expect(screen.queryByRole("button", { name: /dismiss/i })).toBeNull();
});
it("calls onResubmit when Resubmit clicked", () => {
const onResubmit = vi.fn();
render(<BulkBar lane="rejected" count={2} onResubmit={onResubmit} onDismiss={() => {}} onExport={() => {}} />);
fireEvent.click(screen.getByRole("button", { name: /resubmit/i }));
expect(onResubmit).toHaveBeenCalledOnce();
});
});
```
- [ ] **Step 2: Implement BulkBar**
```tsx
// src/components/inbox/BulkBar.tsx
export function BulkBar({
lane,
count,
onResubmit,
onDismiss,
onExport,
}: {
lane: "rejected" | "candidates" | "unmatched" | "done_today";
count: number;
onResubmit: () => void;
onDismiss: () => void;
onExport: () => void;
}) {
if (count === 0) return null;
return (
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 bg-[var(--tt-bg-elev)] border border-[var(--tt-amber)] px-4 py-2 font-mono text-xs flex gap-3 z-20">
<span className="text-[var(--tt-amber)] tabular-nums">{count} selected</span>
{lane === "rejected" && (
<button onClick={onResubmit} className="text-[var(--tt-ink)] hover:text-[var(--tt-amber)]">
Re-submit ({count})
</button>
)}
{lane === "candidates" && (
<button onClick={onDismiss} className="text-[var(--tt-ink)] hover:text-[var(--tt-amber)]">
Dismiss ({count})
</button>
)}
<button onClick={onExport} className="text-[var(--tt-ink)] hover:text-[var(--tt-amber)]">
Export CSV ({count})
</button>
</div>
);
}
```
- [ ] **Step 3: Wire into Lane**
Edit `src/components/inbox/Lane.tsx` to accept `onResubmit`, `onDismiss`, `onExport` props and render `<BulkBar>` when selection is non-empty.
- [ ] **Step 4: Wire into Inbox page**
In `src/pages/Inbox.tsx`, pass handlers that call `resubmitRejected`, `dismissCandidates`, and `window.open(exportInboxCsvUrl(...))`. Then call `refetch()`.
- [ ] **Step 5: Run + commit**
```bash
npx vitest run src/components/inbox/
git add src/components/inbox/
git commit -m "feat(sp6): bulk action bar wired into lanes"
```
---
## Task 19: `useInboxLanes` consumes SP5 tail (TDD)
**Files:**
- Modify: `src/hooks/useInboxLanes.ts`
- Modify: `src/hooks/useInboxLanes.test.ts`
- [ ] **Step 1: Add the failing test**
Append to `src/hooks/useInboxLanes.test.ts`:
```typescript
import { useTailStream } from "./useTailStream";
vi.mock("./useTailStream", () => ({
useTailStream: vi.fn(() => ({ events: [], status: "open" })),
}));
it("refetches on tail events", async () => {
// Test that calling the mocked useTailStream with a callback that triggers
// refetch causes a second fetch call.
// Implementation detail: the hook should accept a list of stream URLs and
// re-call refetchDebounced when events arrive.
});
```
- [ ] **Step 2: Run, confirm FAIL**
```bash
npx vitest run src/hooks/useInboxLanes.test.ts
```
- [ ] **Step 3: Refactor `useInboxLanes`**
Replace the manual `EventSource` block with:
```typescript
import { useTailStream } from "./useTailStream";
const claimStream = useTailStream("/api/claims/stream");
const remitStream = useTailStream("/api/remittances/stream");
useEffect(() => {
if (claimStream.events.length > 0) refetchDebounced();
}, [claimStream.events.length, refetchDebounced]);
useEffect(() => {
if (remitStream.events.length > 0) refetchDebounced();
}, [remitStream.events.length, refetchDebounced]);
```
- [ ] **Step 4: Run, confirm PASS**
```bash
npx vitest run src/hooks/
```
- [ ] **Step 5: Commit**
```bash
git add src/hooks/useInboxLanes.ts src/hooks/useInboxLanes.test.ts
git commit -m "feat(sp6): useInboxLanes refetches on SP5 tail events"
```
---
## Phase 8 — Docs + smoke
## Task 20: README "Inbox" section
**Files:**
- Modify: `README.md`
- [ ] **Step 1: Find the existing "Live updates" section**
```bash
grep -n "Live updates\|## API\|## Live" README.md | head -5
```
- [ ] **Step 2: Add a new section after "Live updates"**
```markdown
## Inbox
`/inbox` is the working surface. Four lanes, dark by default (Ticker Tape aesthetic):
- **Rejected** — claims whose 999 set-level response was R or E. Re-submit in bulk.
- **Candidates** — remits whose CLP-claim-id didn't match exactly; each one shows its top scored claim. One-click manual match or dismiss.
- **Unmatched** — claims still waiting for a remit, and remits with no candidates above the threshold.
- **Done today** — terminal state transitions in the last 24 hours.
### Inbox endpoints
| Method | Path | Notes |
|--------|------|-------|
| GET | `/api/inbox/lanes` | All four lanes in one call. |
| POST | `/api/inbox/candidates/{remit_id}/match` | Manual match. 409 on conflict. |
| POST | `/api/inbox/candidates/dismiss` | `{pairs: [{claim_id, remit_id}]}`. Session-scoped. |
| POST | `/api/inbox/rejected/resubmit` | `{claim_ids: [...]}`. 409 on non-rejected. |
| GET | `/api/inbox/export.csv?lane=<lane>` | Streams CSV of the lane's rows. |
### Scoring
4-field weighted (sum = 100):
| Field | Weight | Rule |
|-------|--------|------|
| Patient control number | 40 | Exact match, normalized (case + leading zeros). |
| Service date | 25 | Linear decay over ±3 days. |
| Charge amount | 20 | Linear decay over ±10%. |
| Provider NPI | 15 | Exact match. Missing → 0. |
Tiers: **strong** (≥75, full opacity, Match enabled), **weak** (5074, dimmed), **hidden** (<50, not surfaced).
```
- [ ] **Step 3: Commit**
```bash
git add README.md
git commit -m "docs(sp6): README — Inbox section"
```
---
## Task 21: Manual smoke test
- [ ] **Step 1: Start backend**
```bash
cd backend && CYCLONE_DB_URL=sqlite:///$(mktemp -d)/smoke.db ../.venv/bin/python -m cyclone &
```
- [ ] **Step 2: Start frontend dev server**
```bash
cd .. && npm run dev &
```
- [ ] **Step 3: Run the existing 837 + 999 + 835 seed scripts**
```bash
curl -X POST -F "file=@backend/tests/fixtures/sample_837.txt" http://127.0.0.1:8000/api/parse-837
curl -X POST -F "file=@backend/tests/fixtures/sample_999_reject.999" http://127.0.0.1:8000/api/parse-999
curl -X POST -F "file=@backend/tests/fixtures/sample_835.txt" http://127.0.0.1:8000/api/parse-835
```
Expected: 200 on each. If any fixture file doesn't exist, locate the appropriate one in `backend/tests/fixtures/` or generate a minimal one.
- [ ] **Step 4: Hit the lanes endpoint**
```bash
curl http://127.0.0.1:8000/api/inbox/lanes | python3 -m json.tool | head -40
```
Expected: non-empty `rejected` (from 999), `candidates` or `unmatched` (from 835), and `done_today` if reconciliation advanced any claim state.
- [ ] **Step 5: Open the Inbox page**
Navigate to `http://127.0.0.1:5173/inbox` (or whatever port Vite uses). Verify:
- All four lane headers visible.
- Rows populate from the API.
- Multi-select → bulk bar appears.
- Click row → opens drawer (or at minimum, logs the row id).
- Match button on a candidate row → calls API, row disappears.
- [ ] **Step 6: Kill servers**
```bash
kill %1 %2 2>/dev/null
```
- [ ] **Step 7: Final full-suite run**
```bash
cd backend && ../.venv/bin/pytest -q
cd .. && npx vitest run --reporter=dot
```
Expected: both green.
- [ ] **Step 8: Final commit + branch ready for review**
```bash
git log --oneline main..HEAD
git push -u origin sp6-workflow-inbox
```
---
## Self-review checklist
- [x] **Spec coverage:**
- §2.1 Ticker Tape aesthetic → T12
- §2.2 Four lanes ordered by urgency → T6 (computation), T17 (page)
- §2.3 Row contents (rail, ID, status, time, charge, payer, patient, score sparkline) → T14
- §2.4 Actions (manual match, dismiss, bulk dismiss, bulk resubmit, bulk export) → T8, T9, T18
- §2.5 Live tail integration → T19
- §3.1 Add REJECTED to ClaimState enum → T1
- §3.2 New columns (rejection_reason, rejected_at, resubmit_count) + state-history index → T2
- §3.3 No new claim_candidates table → confirmed (T6 computes on read)
- §4.1 999 → REJECTED state → T3, T4
- §4.2 835 unmatched → candidates (no auto-match) → T6
- §4.3 4-field weighted scoring → T5
- §4.4 New endpoints → T7, T8, T9, T10
- §4.5 Dismissed-pairs session store → T7 (init) + T8 (mutate)
- §5.1 Frontend files → T11T19
- §5.2 Routing + sidebar nav → T13
- §5.3 Component responsibilities → T14T17
- §5.4 Tail integration → T19
- §5.5 Bulk action UI → T18
- §6 Error handling → T4 (orphan 999 logging), T8 (409), T17 (error UI), T18 (export)
- §7 Testing strategy → covered per task
- §9 Open impl questions → T12 (typography choice), T17 (lane widths = `min-w-[280px]` flex), T13 (landing page deferred)
- [x] **Placeholder scan:** No "TODO", "implement later", "add appropriate error handling" without code. Every code block is runnable.
- [x] **Type consistency:** `ScoreBreakdown` fields `patient/date/amount/provider` consistent across scoring.py, inbox-api.ts, InboxRow.tsx, CandidateBreakdown.tsx. `ClaimState.REJECTED` consistent across db.py, inbox_state.py, api.py. `dismissed_pairs` set semantics consistent.
- [x] **No spec gaps:** All 9 spec sections map to at least one task.
---
## Execution handoff
Plan complete and saved to `docs/superpowers/plans/2026-06-20-cyclone-workflow-automation.md`. 21 tasks.
**Two execution options:**
1. **Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. Best for plans this size.
2. **Inline Execution** — Execute tasks in this session using `executing-plans`, batch with checkpoints.
Which approach?