- New CoverageFlag enum: SUPPLY_LAPSED, VISIT_REQUIRED, TRANSFER_PENDING, RENEWAL_CRITICAL/ELEVATED/SOON, RESUPPLY_READY, ACTIVE - Doc state machine: 5-item payer-dependent status per patient (SWO, Visit, PECOS, PA, Diagnosis) with cascade chain - Confirm Visit endpoint: staff enters prescriber-confirmed date, persisted in Supabase confirmed_visits table, survives all future CSV imports - Supabase migration: 001_add_confirmed_visits.sql (run manually in SQL editor) - Frontend: Badge rebuilt for 8 flags, DocStatusBar 5-dot display, ConfirmVisitModal, expandable WorklistTable rows - Legal: LOI, NDA, BAA drafts at pitch/legal/ for Nixon Law Group review - Compliance docs: privacy policy, incident response, data handling - CSV generator: market_data.json + PA/NJ generator scripts - 15/15 tests passing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2086 lines
71 KiB
Markdown
2086 lines
71 KiB
Markdown
# Signal Demo MVP — Core Build 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 (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Build Confirm Visit workflow, 90/60/45 priority system, full documentation state machine, CSV cycle reset diff, transferred patient detection, and polished worklist UI for the Robert Robinson (Gaboro) demo on June 10, 2026.
|
|
|
|
**Architecture:** Extend the existing FastAPI backend (Railway) with new status flags, a doc state machine, confirmed visit persistence in Supabase, and a Confirm Visit API endpoint. Extend the React/Vite frontend (Vercel) with expandable rows, a 5-dot doc status bar, and the Confirm Visit modal that updates live without a page refresh.
|
|
|
|
**Tech Stack:** Python 3.11 / FastAPI (Railway), Supabase (PostgreSQL + RLS), React 18 / Vite / Tailwind CSS (pnpm), Clerk auth (JWT v2)
|
|
|
|
**Key URLs:**
|
|
- Backend (live): https://signal-api-production-91c2.up.railway.app
|
|
- Frontend (local dev): http://localhost:5173 — run `cd signal-ui && pnpm dev`
|
|
- Frontend (Vercel): https://signal-ui-xi.vercel.app
|
|
- Deploy backend: `railway up --detach` from project root (forces fresh build)
|
|
- Run tests: `cd /path/to/signal && python -m pytest tests/ -v`
|
|
|
|
**PHI Contract (non-negotiable):** All backend code may only touch: patient_id, device_type, shipment_date, quantity, payer, component, and doc status fields (swo_status, pa_status, etc.). No patient names, SSNs, DOBs, addresses, or contact info. patient_id is hashed before storage.
|
|
|
|
**Clerk org for Gaboro:** org_3EPAEcAw06V2yGMSkxE3UjqIA3c (already in CLAUDE.md + provisioned in Supabase via DEMO_ORG_SLUG="gaboro-pilot")
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
**Create:**
|
|
- `python-backend/core/doc_state_machine.py` — DocState dataclass, payer-dependent doc requirements, cascade logic
|
|
- `python-backend/db/migrations/001_add_confirmed_visits.sql` — Supabase migration for confirmed_visits table
|
|
- `signal-ui/src/components/DocStatusBar.jsx` — 5 colored dots, one per doc item
|
|
- `signal-ui/src/components/ConfirmVisitModal.jsx` — date input modal with validation
|
|
- `signal-ui/src/components/CascadeAlert.jsx` — cascade consequence chain display
|
|
- `tests/test_doc_state_machine.py`
|
|
- `tests/test_coverage_flags.py`
|
|
|
|
**Modify:**
|
|
- `python-backend/core/coverage_calculator.py` — new CoverageFlag enum, 90/60/45 visit tier logic, visit date priority chain, updated ShipmentRecord with optional doc fields
|
|
- `python-backend/api/main.py` — updated RecordOut (doc_state, visit_confidence, cascade, is_transfer), new confirm-visit endpoint, updated FLAG_LABELS
|
|
- `python-backend/api/normalizer.py` — add doc columns + transfer columns to HEADER_MAP, update ShipmentRecord construction
|
|
- `python-backend/core/persistence.py` — add get_confirmed_visit(), upsert_confirmed_visit(), load_confirmed_visits_for_org()
|
|
- `python-backend/config/payer_rules.json` — add medicare_advantage section, pa_required/pecos_required flags
|
|
- `python-backend/db/schema.sql` — add confirmed_visits table definition
|
|
- `signal-ui/src/components/WorklistTable.jsx` — expandable rows, DocStatusBar, Confirm Visit button, cascade display
|
|
- `signal-ui/src/components/Badge.jsx` — new status labels + visual weight (bold/normal per tier)
|
|
- `signal-ui/src/lib/api.js` — add confirmVisit() function
|
|
|
|
---
|
|
|
|
## Task 1: Extend ShipmentRecord with optional doc fields
|
|
|
|
**Files:**
|
|
- Modify: `python-backend/core/coverage_calculator.py:42-55`
|
|
|
|
Read `python-backend/core/coverage_calculator.py` before editing.
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `tests/test_coverage_flags.py`:
|
|
```python
|
|
import sys
|
|
from pathlib import Path
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "python-backend"))
|
|
|
|
from datetime import date, timedelta
|
|
from core.coverage_calculator import ShipmentRecord, calculate_coverage, CoverageFlag
|
|
|
|
TODAY = date.today()
|
|
|
|
def make_record(**kwargs):
|
|
defaults = dict(
|
|
patient_id="PT001",
|
|
device_type="dexcom_g7",
|
|
shipment_date=TODAY - timedelta(days=10),
|
|
quantity=1,
|
|
payer="medicare",
|
|
component="sensor",
|
|
)
|
|
defaults.update(kwargs)
|
|
return ShipmentRecord(**defaults)
|
|
|
|
def test_shipment_record_accepts_csv_visit_date():
|
|
r = make_record(csv_visit_date=TODAY - timedelta(days=30))
|
|
assert r.csv_visit_date == TODAY - timedelta(days=30)
|
|
|
|
def test_shipment_record_accepts_doc_fields():
|
|
r = make_record(
|
|
csv_swo_status="On File",
|
|
csv_pecos_verified="Yes",
|
|
csv_pa_status="Not Required",
|
|
csv_diagnosis_on_file="Yes",
|
|
)
|
|
assert r.csv_swo_status == "On File"
|
|
assert r.csv_pecos_verified == "Yes"
|
|
|
|
def test_shipment_record_doc_fields_default_none():
|
|
r = make_record()
|
|
assert r.csv_visit_date is None
|
|
assert r.csv_swo_status is None
|
|
assert r.csv_transfer_from is None
|
|
```
|
|
|
|
- [ ] **Step 2: Run test — verify it fails**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal && python -m pytest tests/test_coverage_flags.py -v
|
|
```
|
|
Expected: FAIL — ShipmentRecord has no field `csv_visit_date`
|
|
|
|
- [ ] **Step 3: Add optional doc fields to ShipmentRecord**
|
|
|
|
Replace the existing ShipmentRecord dataclass (lines ~42-55) with:
|
|
|
|
```python
|
|
from typing import Optional # add to imports if not already present
|
|
|
|
@dataclass(frozen=True)
|
|
class ShipmentRecord:
|
|
"""
|
|
Minimal shipment record. Only non-PHI fields allowed.
|
|
|
|
patient_id: Supplier's internal MRN or account number.
|
|
This is the sole crosswalk key — no real identity data here.
|
|
csv_* fields: Optional doc status columns parsed from the CSV.
|
|
csv_transfer_from: Non-empty string means this is a transferred patient.
|
|
"""
|
|
patient_id: str
|
|
device_type: str
|
|
shipment_date: date
|
|
quantity: int
|
|
payer: str
|
|
component: str = "sensor"
|
|
# Optional doc fields — populated when CSV contains these columns
|
|
csv_visit_date: Optional[date] = None
|
|
csv_swo_status: Optional[str] = None # "On File" | "Pending" | "Expired"
|
|
csv_pecos_verified: Optional[str] = None # "Yes" | "No"
|
|
csv_pa_status: Optional[str] = None # "Approved" | "Pending" | "Denied" | "Not Required"
|
|
csv_diagnosis_on_file: Optional[str] = None # "Yes" | "No"
|
|
csv_transfer_from: Optional[str] = None # prior supplier name if transfer
|
|
```
|
|
|
|
- [ ] **Step 4: Run test — verify it passes**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal && python -m pytest tests/test_coverage_flags.py::test_shipment_record_accepts_csv_visit_date tests/test_coverage_flags.py::test_shipment_record_doc_fields_default_none -v
|
|
```
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal
|
|
git add python-backend/core/coverage_calculator.py tests/test_coverage_flags.py
|
|
git commit -m "feat: extend ShipmentRecord with optional doc state fields from CSV"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: New CoverageFlag enum + 90/60/45 priority system
|
|
|
|
**Files:**
|
|
- Modify: `python-backend/core/coverage_calculator.py`
|
|
|
|
- [ ] **Step 1: Write failing tests**
|
|
|
|
Add to `tests/test_coverage_flags.py`:
|
|
```python
|
|
def test_supply_lapsed_flag_when_coverage_ended():
|
|
# Shipment 200 days ago, 1 sensor (10 wear days) — coverage ended long ago
|
|
r = make_record(shipment_date=TODAY - timedelta(days=200), quantity=1)
|
|
result = calculate_coverage(r)
|
|
assert result.flag == CoverageFlag.SUPPLY_LAPSED
|
|
|
|
def test_renewal_critical_at_45_days():
|
|
# Last confirmed visit was 135 days ago — next visit due in 45 days
|
|
r = make_record(
|
|
shipment_date=TODAY - timedelta(days=10),
|
|
quantity=1,
|
|
csv_visit_date=TODAY - timedelta(days=135),
|
|
)
|
|
result = calculate_coverage(r)
|
|
assert result.flag == CoverageFlag.RENEWAL_CRITICAL
|
|
|
|
def test_renewal_elevated_at_60_days():
|
|
r = make_record(
|
|
shipment_date=TODAY - timedelta(days=10),
|
|
quantity=1,
|
|
csv_visit_date=TODAY - timedelta(days=120),
|
|
)
|
|
result = calculate_coverage(r)
|
|
assert result.flag == CoverageFlag.RENEWAL_ELEVATED
|
|
|
|
def test_renewal_soon_at_90_days():
|
|
r = make_record(
|
|
shipment_date=TODAY - timedelta(days=10),
|
|
quantity=1,
|
|
csv_visit_date=TODAY - timedelta(days=90),
|
|
)
|
|
result = calculate_coverage(r)
|
|
assert result.flag == CoverageFlag.RENEWAL_SOON
|
|
|
|
def test_active_when_visit_far_away():
|
|
r = make_record(
|
|
shipment_date=TODAY - timedelta(days=10),
|
|
quantity=1,
|
|
csv_visit_date=TODAY - timedelta(days=10),
|
|
)
|
|
result = calculate_coverage(r)
|
|
assert result.flag == CoverageFlag.ACTIVE
|
|
|
|
def test_transfer_pending_flag():
|
|
r = make_record(csv_transfer_from="Prior Supplier LLC")
|
|
result = calculate_coverage(r)
|
|
assert result.flag == CoverageFlag.TRANSFER_PENDING
|
|
|
|
def test_priority_sort_order():
|
|
lapsed = make_record(shipment_date=TODAY - timedelta(days=200))
|
|
critical = make_record(patient_id="PT002", shipment_date=TODAY - timedelta(days=10),
|
|
csv_visit_date=TODAY - timedelta(days=135))
|
|
active = make_record(patient_id="PT003", shipment_date=TODAY - timedelta(days=10),
|
|
csv_visit_date=TODAY - timedelta(days=10))
|
|
from core.coverage_calculator import calculate_batch
|
|
results = calculate_batch([active, critical, lapsed])
|
|
assert results[0].flag == CoverageFlag.SUPPLY_LAPSED
|
|
assert results[1].flag == CoverageFlag.RENEWAL_CRITICAL
|
|
assert results[2].flag == CoverageFlag.ACTIVE
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests — verify they fail**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal && python -m pytest tests/test_coverage_flags.py -v 2>&1 | head -40
|
|
```
|
|
|
|
- [ ] **Step 3: Replace CoverageFlag enum + update calculate_coverage**
|
|
|
|
In `python-backend/core/coverage_calculator.py`, replace the entire CoverageFlag enum and CoverageResult dataclass and all calculation functions with the following. Read the existing file first to preserve imports.
|
|
|
|
**New CoverageFlag enum** (replace existing):
|
|
```python
|
|
class CoverageFlag(str, Enum):
|
|
SUPPLY_LAPSED = "SUPPLY_LAPSED" # supply cycle ended, no new shipment
|
|
VISIT_REQUIRED = "VISIT_REQUIRED" # visit date past due, no confirmed new visit
|
|
TRANSFER_PENDING = "TRANSFER_PENDING" # transferred patient, all docs need verification
|
|
RENEWAL_CRITICAL = "RENEWAL_CRITICAL" # ≤ 45 days to next visit due
|
|
RENEWAL_ELEVATED = "RENEWAL_ELEVATED" # ≤ 60 days to next visit due
|
|
RENEWAL_SOON = "RENEWAL_SOON" # ≤ 90 days to next visit due
|
|
RESUPPLY_READY = "RESUPPLY_READY" # within refill window, visit not yet urgent
|
|
ACTIVE = "ACTIVE" # all clear
|
|
```
|
|
|
|
**New CoverageResult dataclass** (replace existing):
|
|
```python
|
|
@dataclass
|
|
class CoverageResult:
|
|
patient_id: str
|
|
device_type: str
|
|
payer: str
|
|
component: str
|
|
last_shipment_date: date
|
|
coverage_end_date: date
|
|
next_visit_due_date: Optional[date]
|
|
flag: CoverageFlag
|
|
days_until_coverage_end: int
|
|
days_until_visit_due: Optional[int]
|
|
priority_score: int
|
|
visit_date_confidence: str = "estimated" # "confirmed" | "estimated"
|
|
is_transfer: bool = False
|
|
rule_version: str = RULE_VERSION
|
|
```
|
|
|
|
**New helper functions** (replace _compute_priority):
|
|
```python
|
|
def _resolve_visit_date(
|
|
record: ShipmentRecord,
|
|
confirmed_visit_date: Optional[date] = None,
|
|
) -> tuple[Optional[date], str]:
|
|
"""
|
|
Visit date priority chain:
|
|
1. CSV visit date column (if present + valid)
|
|
2. Supabase confirmed visit date (stored by staff)
|
|
3. Estimated proxy: shipment_date - 30 days
|
|
Returns (resolved_date, confidence) where confidence is 'confirmed' or 'estimated'.
|
|
"""
|
|
if record.csv_visit_date:
|
|
return record.csv_visit_date, "confirmed"
|
|
if confirmed_visit_date:
|
|
return confirmed_visit_date, "confirmed"
|
|
estimated = record.shipment_date - timedelta(days=30)
|
|
return estimated, "estimated"
|
|
|
|
|
|
def _compute_visit_flag(
|
|
next_visit_due: date,
|
|
today: date,
|
|
) -> CoverageFlag:
|
|
"""Map days-to-next-visit to the appropriate tier flag."""
|
|
days = (next_visit_due - today).days
|
|
if days < 0:
|
|
return CoverageFlag.VISIT_REQUIRED
|
|
if days <= 45:
|
|
return CoverageFlag.RENEWAL_CRITICAL
|
|
if days <= 60:
|
|
return CoverageFlag.RENEWAL_ELEVATED
|
|
if days <= 90:
|
|
return CoverageFlag.RENEWAL_SOON
|
|
return CoverageFlag.ACTIVE
|
|
|
|
|
|
def _compute_priority(flag: CoverageFlag, days_until_visit: Optional[int]) -> int:
|
|
"""
|
|
Priority score for worklist sort. Higher = more urgent.
|
|
Supply Lapsed and Transfer Pending are always highest.
|
|
Within visit tiers, urgency increases as days decrease.
|
|
"""
|
|
urgency_days = abs(days_until_visit) if days_until_visit is not None else 0
|
|
if flag == CoverageFlag.SUPPLY_LAPSED:
|
|
return 2000 + urgency_days
|
|
if flag == CoverageFlag.TRANSFER_PENDING:
|
|
return 1800
|
|
if flag == CoverageFlag.VISIT_REQUIRED:
|
|
return 1500 + urgency_days
|
|
if flag == CoverageFlag.RENEWAL_CRITICAL:
|
|
return 1000 + (45 - max(0, urgency_days))
|
|
if flag == CoverageFlag.RENEWAL_ELEVATED:
|
|
return 700 + (60 - max(0, urgency_days))
|
|
if flag == CoverageFlag.RENEWAL_SOON:
|
|
return 400 + (90 - max(0, urgency_days))
|
|
if flag == CoverageFlag.RESUPPLY_READY:
|
|
return 200
|
|
return 0 # ACTIVE
|
|
```
|
|
|
|
**New calculate_coverage function** (replace existing):
|
|
```python
|
|
def calculate_coverage(
|
|
record: ShipmentRecord,
|
|
as_of: Optional[date] = None,
|
|
confirmed_visit_date: Optional[date] = None,
|
|
) -> CoverageResult:
|
|
"""
|
|
Calculate coverage status for a single shipment record.
|
|
|
|
Args:
|
|
record: ShipmentRecord with non-PHI fields only.
|
|
as_of: Date to evaluate against. Defaults to today.
|
|
confirmed_visit_date: Confirmed visit date from Supabase (staff-entered).
|
|
"""
|
|
rules = _load_payer_rules()
|
|
today = as_of or date.today()
|
|
|
|
# Transferred patients: all docs need verification before anything else
|
|
if record.csv_transfer_from:
|
|
return CoverageResult(
|
|
patient_id=record.patient_id,
|
|
device_type=record.device_type,
|
|
payer=record.payer,
|
|
component=record.component,
|
|
last_shipment_date=record.shipment_date,
|
|
coverage_end_date=record.shipment_date,
|
|
next_visit_due_date=None,
|
|
flag=CoverageFlag.TRANSFER_PENDING,
|
|
days_until_coverage_end=0,
|
|
days_until_visit_due=None,
|
|
priority_score=1800,
|
|
visit_date_confidence="estimated",
|
|
is_transfer=True,
|
|
rule_version=RULE_VERSION,
|
|
)
|
|
|
|
wear_days = _get_wear_days(rules, record.device_type, record.component)
|
|
payer_config = _get_payer_config(rules, record.payer)
|
|
|
|
total_wear_days = wear_days * record.quantity
|
|
coverage_end = record.shipment_date + timedelta(days=total_wear_days)
|
|
days_until_end = (coverage_end - today).days
|
|
|
|
# Supply lapsed — coverage cycle ended
|
|
if days_until_end < 0:
|
|
visit_date, confidence = _resolve_visit_date(record, confirmed_visit_date)
|
|
next_visit_due = visit_date + timedelta(days=180) if visit_date else None
|
|
days_visit = (next_visit_due - today).days if next_visit_due else None
|
|
return CoverageResult(
|
|
patient_id=record.patient_id,
|
|
device_type=record.device_type,
|
|
payer=record.payer,
|
|
component=record.component,
|
|
last_shipment_date=record.shipment_date,
|
|
coverage_end_date=coverage_end,
|
|
next_visit_due_date=next_visit_due,
|
|
flag=CoverageFlag.SUPPLY_LAPSED,
|
|
days_until_coverage_end=days_until_end,
|
|
days_until_visit_due=days_visit,
|
|
priority_score=_compute_priority(CoverageFlag.SUPPLY_LAPSED, days_visit),
|
|
visit_date_confidence=confidence,
|
|
rule_version=RULE_VERSION,
|
|
)
|
|
|
|
visit_renewal_days = payer_config.get("visit_renewal_days")
|
|
refill_window_days = payer_config.get("refill_window_days", 30)
|
|
|
|
# Resolve visit date using priority chain
|
|
visit_date, confidence = _resolve_visit_date(record, confirmed_visit_date)
|
|
next_visit_due: Optional[date] = None
|
|
days_until_visit: Optional[int] = None
|
|
flag = CoverageFlag.ACTIVE
|
|
|
|
if visit_renewal_days and visit_date:
|
|
next_visit_due = visit_date + timedelta(days=visit_renewal_days)
|
|
days_until_visit = (next_visit_due - today).days
|
|
flag = _compute_visit_flag(next_visit_due, today)
|
|
|
|
# If no visit urgency but within refill window, mark resupply ready
|
|
if flag == CoverageFlag.ACTIVE and days_until_end <= refill_window_days:
|
|
flag = CoverageFlag.RESUPPLY_READY
|
|
|
|
priority = _compute_priority(flag, days_until_visit)
|
|
|
|
return CoverageResult(
|
|
patient_id=record.patient_id,
|
|
device_type=record.device_type,
|
|
payer=record.payer,
|
|
component=record.component,
|
|
last_shipment_date=record.shipment_date,
|
|
coverage_end_date=coverage_end,
|
|
next_visit_due_date=next_visit_due,
|
|
flag=flag,
|
|
days_until_coverage_end=days_until_end,
|
|
days_until_visit_due=days_until_visit,
|
|
priority_score=priority,
|
|
visit_date_confidence=confidence,
|
|
rule_version=RULE_VERSION,
|
|
)
|
|
```
|
|
|
|
**Update calculate_batch** to pass confirmed_visit_date (leave the dict lookup for now — that's added in Task 5):
|
|
```python
|
|
def calculate_batch(
|
|
records: list[ShipmentRecord],
|
|
as_of: Optional[date] = None,
|
|
confirmed_visits: Optional[dict[str, date]] = None,
|
|
) -> list[CoverageResult]:
|
|
"""
|
|
confirmed_visits: dict mapping patient_id_hash → confirmed visit date.
|
|
Loaded from Supabase by persistence layer before calling this function.
|
|
"""
|
|
import hashlib
|
|
confirmed_visits = confirmed_visits or {}
|
|
results = []
|
|
for record in records:
|
|
patient_hash = hashlib.sha256(record.patient_id.encode()).hexdigest()
|
|
confirmed_date = confirmed_visits.get(patient_hash)
|
|
try:
|
|
result = calculate_coverage(record, as_of=as_of, confirmed_visit_date=confirmed_date)
|
|
results.append(result)
|
|
except ValueError as exc:
|
|
logger.warning("Skipping record for patient_id hash — %s", exc)
|
|
|
|
results.sort(key=lambda r: r.priority_score, reverse=True)
|
|
return results
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests — verify they pass**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal && python -m pytest tests/test_coverage_flags.py -v
|
|
```
|
|
Expected: All PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal
|
|
git add python-backend/core/coverage_calculator.py tests/test_coverage_flags.py
|
|
git commit -m "feat: 90/60/45 visit priority system with new CoverageFlag enum"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Doc State Machine
|
|
|
|
**Files:**
|
|
- Create: `python-backend/core/doc_state_machine.py`
|
|
- Create: `tests/test_doc_state_machine.py`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Create `tests/test_doc_state_machine.py`:
|
|
```python
|
|
import sys
|
|
from pathlib import Path
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "python-backend"))
|
|
|
|
from datetime import date, timedelta
|
|
from core.doc_state_machine import compute_doc_state, DocState
|
|
|
|
TODAY = date.today()
|
|
|
|
def test_medicare_ffs_pa_not_required():
|
|
state = compute_doc_state(
|
|
payer_type="medicare",
|
|
csv_swo_status="On File",
|
|
csv_visit_date=TODAY - timedelta(days=30),
|
|
confirmed_visit_date=None,
|
|
csv_pecos_verified="Yes",
|
|
csv_pa_status=None,
|
|
csv_diagnosis_on_file="Yes",
|
|
is_transfer=False,
|
|
)
|
|
assert state.pa == "Not Required"
|
|
assert state.pecos == "Verified"
|
|
|
|
def test_medicaid_pecos_not_applicable():
|
|
state = compute_doc_state(
|
|
payer_type="medicaid",
|
|
csv_swo_status="On File",
|
|
csv_visit_date=TODAY - timedelta(days=30),
|
|
confirmed_visit_date=None,
|
|
csv_pecos_verified=None,
|
|
csv_pa_status="Approved",
|
|
csv_diagnosis_on_file="Yes",
|
|
is_transfer=False,
|
|
)
|
|
assert state.pecos == "N/A"
|
|
assert state.pa == "Approved"
|
|
|
|
def test_transfer_patient_all_pending():
|
|
state = compute_doc_state(
|
|
payer_type="medicare",
|
|
csv_swo_status=None,
|
|
csv_visit_date=None,
|
|
confirmed_visit_date=None,
|
|
csv_pecos_verified=None,
|
|
csv_pa_status=None,
|
|
csv_diagnosis_on_file=None,
|
|
is_transfer=True,
|
|
)
|
|
assert state.swo == "Pending — Verify"
|
|
assert state.visit == "Pending — Verify"
|
|
assert state.pecos == "Pending — Verify"
|
|
assert state.pa == "Pending — Verify"
|
|
assert state.diagnosis == "Pending — Verify"
|
|
|
|
def test_cascade_visit_not_confirmed():
|
|
state = compute_doc_state(
|
|
payer_type="medicare",
|
|
csv_swo_status="On File",
|
|
csv_visit_date=None,
|
|
confirmed_visit_date=None,
|
|
csv_pecos_verified="Yes",
|
|
csv_pa_status=None,
|
|
csv_diagnosis_on_file="Yes",
|
|
is_transfer=False,
|
|
)
|
|
assert len(state.cascade) > 0
|
|
assert any("visit" in c.lower() for c in state.cascade)
|
|
|
|
def test_no_cascade_when_all_clear():
|
|
state = compute_doc_state(
|
|
payer_type="medicare",
|
|
csv_swo_status="On File",
|
|
csv_visit_date=TODAY - timedelta(days=30),
|
|
confirmed_visit_date=None,
|
|
csv_pecos_verified="Yes",
|
|
csv_pa_status=None,
|
|
csv_diagnosis_on_file="Yes",
|
|
is_transfer=False,
|
|
)
|
|
assert state.cascade == []
|
|
```
|
|
|
|
- [ ] **Step 2: Run test — verify it fails**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal && python -m pytest tests/test_doc_state_machine.py -v 2>&1 | head -20
|
|
```
|
|
Expected: FAIL — module not found
|
|
|
|
- [ ] **Step 3: Create doc_state_machine.py**
|
|
|
|
Create `python-backend/core/doc_state_machine.py`:
|
|
```python
|
|
"""
|
|
doc_state_machine.py
|
|
Signal — STTIL Solutions
|
|
|
|
Computes per-patient documentation status across 5 items.
|
|
Requirements are payer-dependent. Produces cascade consequence chain.
|
|
|
|
PHI Contract: no patient names, DOBs, or contact info in any parameter.
|
|
"""
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import date
|
|
from typing import Optional
|
|
|
|
|
|
# Payer types that require PECOS enrollment
|
|
_PECOS_REQUIRED = {"medicare", "medicare_advantage"}
|
|
|
|
# Payer types that require Prior Authorization for CGM
|
|
_PA_REQUIRED = {"medicare_advantage", "medicaid", "commercial"}
|
|
|
|
# Medicare FFS: PA not required (CGM coverage expansion 2023 + CMS-1828-F exemption path)
|
|
_PA_NOT_REQUIRED = {"medicare"}
|
|
|
|
|
|
@dataclass
|
|
class DocState:
|
|
swo: str = "Pending" # "On File" | "Pending" | "Expired"
|
|
visit: str = "Not Confirmed" # "Confirmed YYYY-MM-DD" | "Estimated YYYY-MM-DD" | "Not Confirmed"
|
|
pecos: str = "Not Verified" # "Verified" | "Not Verified" | "N/A" | "Pending — Verify"
|
|
pa: str = "Required — Not Started" # "Not Required" | "Approved" | "Pending" | "Denied" | "Required — Not Started" | "Pending — Verify"
|
|
diagnosis: str = "Missing" # "On File" | "Missing" | "Pending — Verify"
|
|
cascade: list[str] = field(default_factory=list)
|
|
|
|
|
|
def _resolve_visit_display(
|
|
csv_visit_date: Optional[date],
|
|
confirmed_visit_date: Optional[date],
|
|
is_transfer: bool,
|
|
) -> tuple[str, bool]:
|
|
"""
|
|
Returns (display_string, is_confirmed).
|
|
is_confirmed=True means the date is reliable (from CSV or staff-confirmed).
|
|
"""
|
|
if is_transfer:
|
|
return "Pending — Verify", False
|
|
if csv_visit_date:
|
|
return f"Confirmed {csv_visit_date.isoformat()}", True
|
|
if confirmed_visit_date:
|
|
return f"Confirmed {confirmed_visit_date.isoformat()}", True
|
|
return "Not Confirmed — Estimated Only", False
|
|
|
|
|
|
def _build_cascade(
|
|
swo: str,
|
|
visit_confirmed: bool,
|
|
pecos: str,
|
|
pa: str,
|
|
diagnosis: str,
|
|
payer_type: str,
|
|
) -> list[str]:
|
|
"""
|
|
Build the downstream consequence chain for missing/at-risk doc items.
|
|
Only add cascade steps that are actually blocked.
|
|
"""
|
|
cascade: list[str] = []
|
|
|
|
if not visit_confirmed:
|
|
cascade.append("Qualifying visit not confirmed")
|
|
if swo in ("Pending", "Expired"):
|
|
cascade.append("SWO validity at risk")
|
|
if payer_type in _PA_REQUIRED and pa in ("Pending", "Required — Not Started"):
|
|
cascade.append("PA renewal cannot proceed")
|
|
cascade.append("Next shipment blocked")
|
|
return cascade
|
|
|
|
if swo in ("Pending", "Expired"):
|
|
cascade.append("SWO missing or expired")
|
|
if payer_type in _PECOS_REQUIRED and pecos not in ("Verified",):
|
|
cascade.append("PECOS enrollment at risk")
|
|
cascade.append("Next shipment blocked")
|
|
|
|
if diagnosis == "Missing":
|
|
cascade.append("Diagnosis documentation missing — required for all payers")
|
|
|
|
return cascade
|
|
|
|
|
|
def compute_doc_state(
|
|
payer_type: str,
|
|
csv_swo_status: Optional[str],
|
|
csv_visit_date: Optional[date],
|
|
confirmed_visit_date: Optional[date],
|
|
csv_pecos_verified: Optional[str],
|
|
csv_pa_status: Optional[str],
|
|
csv_diagnosis_on_file: Optional[str],
|
|
is_transfer: bool = False,
|
|
) -> DocState:
|
|
"""
|
|
Compute the full documentation state for one patient.
|
|
|
|
payer_type: "medicare" | "medicare_advantage" | "medicaid" | "commercial"
|
|
All csv_* fields are Optional — None means not present in the CSV.
|
|
"""
|
|
|
|
# Transfer patients: everything is pending verification
|
|
if is_transfer:
|
|
return DocState(
|
|
swo="Pending — Verify",
|
|
visit="Pending — Verify",
|
|
pecos="Pending — Verify",
|
|
pa="Pending — Verify",
|
|
diagnosis="Pending — Verify",
|
|
cascade=[
|
|
"Patient transferred from prior supplier",
|
|
"Verify: SWO from current prescriber, qualifying visit within 6 months, PA status with current payer",
|
|
],
|
|
)
|
|
|
|
# SWO
|
|
swo_map = {"on file": "On File", "pending": "Pending", "expired": "Expired"}
|
|
swo = swo_map.get((csv_swo_status or "").strip().lower(), "Pending")
|
|
|
|
# Visit
|
|
visit_display, visit_confirmed = _resolve_visit_display(
|
|
csv_visit_date, confirmed_visit_date, is_transfer
|
|
)
|
|
|
|
# PECOS — only required for Medicare
|
|
if payer_type in _PECOS_REQUIRED:
|
|
pecos_val = (csv_pecos_verified or "").strip().lower()
|
|
pecos = "Verified" if pecos_val in ("yes", "verified", "true", "1") else "Not Verified"
|
|
else:
|
|
pecos = "N/A"
|
|
|
|
# PA — not required for Medicare FFS; required for MA, Medicaid, Commercial
|
|
if payer_type in _PA_NOT_REQUIRED:
|
|
pa = "Not Required"
|
|
elif csv_pa_status:
|
|
pa_map = {
|
|
"approved": "Approved",
|
|
"pending": "Pending",
|
|
"denied": "Denied",
|
|
"not required": "Not Required",
|
|
"not started": "Required — Not Started",
|
|
}
|
|
pa_key = csv_pa_status.strip().lower()
|
|
pa = pa_map.get(pa_key, "Required — Not Started")
|
|
else:
|
|
pa = "Required — Not Started"
|
|
|
|
# Diagnosis
|
|
dx_val = (csv_diagnosis_on_file or "").strip().lower()
|
|
diagnosis = "On File" if dx_val in ("yes", "true", "on file", "1") else "Missing"
|
|
|
|
cascade = _build_cascade(swo, visit_confirmed, pecos, pa, diagnosis, payer_type)
|
|
|
|
return DocState(
|
|
swo=swo,
|
|
visit=visit_display,
|
|
pecos=pecos,
|
|
pa=pa,
|
|
diagnosis=diagnosis,
|
|
cascade=cascade,
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests — verify they pass**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal && python -m pytest tests/test_doc_state_machine.py -v
|
|
```
|
|
Expected: All PASS
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal
|
|
git add python-backend/core/doc_state_machine.py tests/test_doc_state_machine.py
|
|
git commit -m "feat: doc state machine — 5-item payer-dependent requirements with cascade logic"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: payer_rules.json updates + Supabase confirmed_visits migration
|
|
|
|
**Files:**
|
|
- Modify: `python-backend/config/payer_rules.json`
|
|
- Create: `python-backend/db/migrations/001_add_confirmed_visits.sql`
|
|
- Modify: `python-backend/db/schema.sql`
|
|
|
|
- [ ] **Step 1: Update payer_rules.json**
|
|
|
|
Read the file first, then replace its contents:
|
|
```json
|
|
{
|
|
"_comment": "Wear-day rules by device type and payer. Updated 2026-06-07. Run quarterly review per compliance checklist.",
|
|
"devices": {
|
|
"dexcom_g6": {
|
|
"display_name": "Dexcom G6",
|
|
"sensor_wear_days": 10,
|
|
"transmitter_wear_days": 90,
|
|
"components": ["sensor", "transmitter"]
|
|
},
|
|
"dexcom_g7": {
|
|
"display_name": "Dexcom G7",
|
|
"sensor_wear_days": 10,
|
|
"components": ["sensor"]
|
|
},
|
|
"freestyle_libre_2": {
|
|
"display_name": "FreeStyle Libre 2",
|
|
"sensor_wear_days": 14,
|
|
"components": ["sensor"]
|
|
},
|
|
"freestyle_libre_3": {
|
|
"display_name": "FreeStyle Libre 3",
|
|
"sensor_wear_days": 14,
|
|
"components": ["sensor"]
|
|
},
|
|
"omnipod_5": {
|
|
"display_name": "Omnipod 5",
|
|
"pod_wear_days": 3,
|
|
"sensor_wear_days": 14,
|
|
"components": ["pod", "sensor"]
|
|
}
|
|
},
|
|
"payer_rules": {
|
|
"medicare": {
|
|
"visit_renewal_days": 180,
|
|
"refill_window_days": 30,
|
|
"pa_required": false,
|
|
"pecos_required": true,
|
|
"_note": "Medicare FFS. PA not required (CGM coverage expansion 2023). Face-to-face visit required every 6 months. CMS-1828-F: suppliers at ≥90% affirmation rate may qualify for PA exemption from June 1, 2026.",
|
|
"covered_devices": ["dexcom_g6", "dexcom_g7", "freestyle_libre_2", "freestyle_libre_3"]
|
|
},
|
|
"medicare_advantage": {
|
|
"visit_renewal_days": 180,
|
|
"refill_window_days": 30,
|
|
"pa_required": true,
|
|
"pecos_required": true,
|
|
"_note": "Medicare Advantage — PA required, varies by plan. Verify plan-specific rules.",
|
|
"covered_devices": ["dexcom_g6", "dexcom_g7", "freestyle_libre_2", "freestyle_libre_3"]
|
|
},
|
|
"medicaid": {
|
|
"visit_renewal_days": 180,
|
|
"refill_window_days": 30,
|
|
"pa_required": true,
|
|
"pecos_required": false,
|
|
"_pa_note_pa": "PA required — Keystone First, UPMC Health Plan, Highmark BCBS require PA. NJ FamilyCare, Horizon BCBS NJ require PA.",
|
|
"covered_devices": []
|
|
},
|
|
"commercial": {
|
|
"visit_renewal_days": 180,
|
|
"refill_window_days": 30,
|
|
"pa_required": true,
|
|
"pecos_required": false,
|
|
"_note": "Commercial payer rules vary by plan. PA required by default — verify per plan.",
|
|
"covered_devices": []
|
|
},
|
|
"default": {
|
|
"visit_renewal_days": 180,
|
|
"refill_window_days": 30,
|
|
"pa_required": true,
|
|
"pecos_required": false,
|
|
"covered_devices": []
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Create Supabase migration SQL**
|
|
|
|
Create `python-backend/db/migrations/001_add_confirmed_visits.sql`:
|
|
```sql
|
|
-- Migration 001: Add confirmed_visits table
|
|
-- Run in Supabase SQL Editor: Dashboard > SQL Editor > New query
|
|
-- Safe to run multiple times.
|
|
|
|
create table if not exists confirmed_visits (
|
|
id uuid primary key default uuid_generate_v4(),
|
|
org_id uuid not null references organizations(id) on delete cascade,
|
|
patient_id_hash text not null, -- SHA-256 of patient_id — no raw PHI
|
|
confirmed_date date not null,
|
|
confirmed_by text, -- staff identifier (not PHI — just a label)
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now(),
|
|
unique(org_id, patient_id_hash)
|
|
);
|
|
|
|
create index if not exists idx_confirmed_visits_org on confirmed_visits(org_id);
|
|
create index if not exists idx_confirmed_visits_hash on confirmed_visits(patient_id_hash);
|
|
|
|
alter table confirmed_visits enable row level security;
|
|
```
|
|
|
|
- [ ] **Step 3: Add confirmed_visits to schema.sql**
|
|
|
|
Read `python-backend/db/schema.sql`, then append before the final `-- Indexes` section:
|
|
```sql
|
|
-- Confirmed visit dates (staff-entered, persists across CSV imports)
|
|
create table if not exists confirmed_visits (
|
|
id uuid primary key default uuid_generate_v4(),
|
|
org_id uuid not null references organizations(id) on delete cascade,
|
|
patient_id_hash text not null,
|
|
confirmed_date date not null,
|
|
confirmed_by text,
|
|
created_at timestamptz not null default now(),
|
|
updated_at timestamptz not null default now(),
|
|
unique(org_id, patient_id_hash)
|
|
);
|
|
create index if not exists idx_confirmed_visits_org on confirmed_visits(org_id);
|
|
create index if not exists idx_confirmed_visits_hash on confirmed_visits(patient_id_hash);
|
|
alter table confirmed_visits enable row level security;
|
|
```
|
|
|
|
- [ ] **Step 4: Run the migration in Supabase**
|
|
|
|
The migration must be run manually in the Supabase dashboard SQL editor (there is no CLI migration runner configured). Copy the contents of `python-backend/db/migrations/001_add_confirmed_visits.sql` and paste into the Supabase dashboard SQL editor at your project.
|
|
|
|
After running, verify: `SELECT COUNT(*) FROM confirmed_visits;` should return 0 with no error.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal
|
|
git add python-backend/config/payer_rules.json python-backend/db/migrations/001_add_confirmed_visits.sql python-backend/db/schema.sql
|
|
git commit -m "feat: payer_rules medicare_advantage + PA/NJ rules; confirmed_visits migration"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Persistence — confirmed visit read/write + upload diff
|
|
|
|
**Files:**
|
|
- Modify: `python-backend/core/persistence.py`
|
|
|
|
Read `python-backend/core/persistence.py` before editing — preserve all existing functions.
|
|
|
|
- [ ] **Step 1: Add confirmed visit functions**
|
|
|
|
Append to `python-backend/core/persistence.py`:
|
|
```python
|
|
def upsert_confirmed_visit(
|
|
org_id: str,
|
|
patient_id_hash: str,
|
|
confirmed_date: date,
|
|
confirmed_by: str = "staff",
|
|
) -> bool:
|
|
"""
|
|
Insert or update a confirmed visit date for a patient.
|
|
Returns True on success, False if Supabase unavailable.
|
|
"""
|
|
client = get_client()
|
|
if not client:
|
|
return False
|
|
try:
|
|
client.table("confirmed_visits").upsert({
|
|
"org_id": org_id,
|
|
"patient_id_hash": patient_id_hash,
|
|
"confirmed_date": confirmed_date.isoformat(),
|
|
"confirmed_by": confirmed_by,
|
|
"updated_at": "now()",
|
|
}, on_conflict="org_id,patient_id_hash").execute()
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Failed to upsert confirmed visit: {e}")
|
|
return False
|
|
|
|
|
|
def load_confirmed_visits_for_org(org_id: str) -> dict[str, date]:
|
|
"""
|
|
Load all confirmed visit dates for an org.
|
|
Returns dict mapping patient_id_hash → confirmed_date.
|
|
"""
|
|
client = get_client()
|
|
if not client:
|
|
return {}
|
|
try:
|
|
result = client.table("confirmed_visits") \
|
|
.select("patient_id_hash,confirmed_date") \
|
|
.eq("org_id", org_id) \
|
|
.execute()
|
|
return {
|
|
row["patient_id_hash"]: date.fromisoformat(row["confirmed_date"])
|
|
for row in (result.data or [])
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Failed to load confirmed visits: {e}")
|
|
return {}
|
|
|
|
|
|
def get_or_create_org(clerk_org_id: str | None = None) -> str | None:
|
|
"""Public wrapper around _get_or_create_org for use by the API."""
|
|
return _get_or_create_org(clerk_org_id=clerk_org_id)
|
|
```
|
|
|
|
- [ ] **Step 2: Verify imports in main.py still work**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal/python-backend && python -c "from core.persistence import upsert_confirmed_visit, load_confirmed_visits_for_org, get_or_create_org; print('OK')"
|
|
```
|
|
Expected: `OK`
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal
|
|
git add python-backend/core/persistence.py
|
|
git commit -m "feat: confirmed visit persistence — upsert and bulk load per org"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Normalizer updates — doc columns + transfer columns
|
|
|
|
**Files:**
|
|
- Modify: `python-backend/api/normalizer.py`
|
|
|
|
Read `python-backend/api/normalizer.py` before editing.
|
|
|
|
- [ ] **Step 1: Add doc column mappings to HEADER_MAP**
|
|
|
|
In normalizer.py, add these entries to the `HEADER_MAP` dict (after the existing "component" entry):
|
|
```python
|
|
"csv_visit_date": [
|
|
"visit_date", "visit date", "qualifying_visit_date", "qualifying visit date",
|
|
"last_visit_date", "last visit date", "face_to_face_date", "f2f_date",
|
|
"encounter_date", "encounter date", "physician_visit_date",
|
|
],
|
|
"csv_swo_status": [
|
|
"swo_status", "swo status", "swo", "standing_written_order",
|
|
"standing written order", "order_status", "order status",
|
|
],
|
|
"csv_pecos_verified": [
|
|
"pecos_verified", "pecos verified", "pecos", "pecos_status",
|
|
"enrollment_verified", "enrollment verified",
|
|
],
|
|
"csv_pa_status": [
|
|
"pa_status", "pa status", "prior_auth_status", "prior auth status",
|
|
"prior_authorization_status", "auth_status", "pa", "authorization",
|
|
],
|
|
"csv_diagnosis_on_file": [
|
|
"diagnosis_on_file", "diagnosis on file", "diagnosis", "dx_on_file",
|
|
"dx on file", "icd_on_file", "icd on file",
|
|
],
|
|
"csv_transfer_from": [
|
|
"transfer_from", "transfer from", "previous_supplier", "previous supplier",
|
|
"prior_supplier", "prior supplier", "transfer_status", "transferred_from",
|
|
],
|
|
```
|
|
|
|
- [ ] **Step 2: Update the ShipmentRecord construction in normalize_csv**
|
|
|
|
In the `normalize_csv` function, after the existing `component` parsing, add:
|
|
```python
|
|
# Optional doc fields — only populated if the CSV contains these columns
|
|
csv_visit_date: Optional[date] = None
|
|
raw_visit = mapped.get("csv_visit_date", "")
|
|
if raw_visit:
|
|
csv_visit_date = _parse_date(raw_visit) # None if unparseable (non-error)
|
|
|
|
csv_swo_status = mapped.get("csv_swo_status") or None
|
|
csv_pecos_verified = mapped.get("csv_pecos_verified") or None
|
|
csv_pa_status = mapped.get("csv_pa_status") or None
|
|
csv_diagnosis_on_file = mapped.get("csv_diagnosis_on_file") or None
|
|
transfer_raw = mapped.get("csv_transfer_from", "").strip()
|
|
csv_transfer_from = transfer_raw if transfer_raw else None
|
|
```
|
|
|
|
Then update the `ShipmentRecord(...)` constructor call to include these fields:
|
|
```python
|
|
records.append(ShipmentRecord(
|
|
patient_id=patient_id,
|
|
device_type=device_type,
|
|
shipment_date=shipment_date,
|
|
quantity=quantity,
|
|
payer=payer,
|
|
component=component,
|
|
csv_visit_date=csv_visit_date,
|
|
csv_swo_status=csv_swo_status,
|
|
csv_pecos_verified=csv_pecos_verified,
|
|
csv_pa_status=csv_pa_status,
|
|
csv_diagnosis_on_file=csv_diagnosis_on_file,
|
|
csv_transfer_from=csv_transfer_from,
|
|
))
|
|
```
|
|
|
|
- [ ] **Step 3: Update _normalize_payer in normalizer.py to distinguish Medicare Advantage**
|
|
|
|
Find the PAYER_MAP dict and update the medicare_advantage line:
|
|
```python
|
|
"medicare advantage": "medicare_advantage", # distinct from medicare FFS
|
|
```
|
|
|
|
- [ ] **Step 4: Verify the normalizer still runs on a baseline CSV**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal && python -c "
|
|
from python_backend.api.normalizer import normalize_csv
|
|
import pathlib
|
|
text = pathlib.Path('test-data/sample-batch-01-ok.csv').read_text()
|
|
records, skipped, summary = normalize_csv(text)
|
|
print(f'Records: {len(records)}, Skipped: {len(skipped)}')
|
|
print('First record:', records[0] if records else 'none')
|
|
"
|
|
```
|
|
Expected: Records >= 1, no errors
|
|
|
|
Alternatively run from python-backend dir:
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal/python-backend && python -c "
|
|
from api.normalizer import normalize_csv
|
|
import pathlib, sys
|
|
text = pathlib.Path('../test-data/sample-batch-01-ok.csv').read_text()
|
|
records, skipped, summary = normalize_csv(text)
|
|
print(f'Records: {len(records)}, Skipped: {len(skipped)}')
|
|
"
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal
|
|
git add python-backend/api/normalizer.py
|
|
git commit -m "feat: normalizer adds doc column mappings and transfer column detection"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: API — updated RecordOut + confirm-visit endpoint + upload integration
|
|
|
|
**Files:**
|
|
- Modify: `python-backend/api/main.py`
|
|
|
|
Read `python-backend/api/main.py` before editing.
|
|
|
|
- [ ] **Step 1: Add imports at top of main.py**
|
|
|
|
Add these imports (after existing imports):
|
|
```python
|
|
import hashlib
|
|
from datetime import date as date_type
|
|
from core.doc_state_machine import compute_doc_state, DocState
|
|
from core.persistence import (
|
|
persist_export, persist_upload,
|
|
upsert_confirmed_visit, load_confirmed_visits_for_org, get_or_create_org,
|
|
)
|
|
```
|
|
|
|
Remove the existing `from core.persistence import persist_export, persist_upload` line.
|
|
|
|
- [ ] **Step 2: Update FLAG_LABELS and FLAG_ACTIONS for new flags**
|
|
|
|
Replace the existing FLAG_LABELS and FLAG_ACTIONS dicts:
|
|
```python
|
|
FLAG_LABELS = {
|
|
"SUPPLY_LAPSED": "Supply Lapsed",
|
|
"VISIT_REQUIRED": "Visit Required",
|
|
"TRANSFER_PENDING": "Needs Verification — Transfer",
|
|
"RENEWAL_CRITICAL": "Renewal Due — Critical",
|
|
"RENEWAL_ELEVATED": "Renewal Due SOON",
|
|
"RENEWAL_SOON": "Renewal Due SOON",
|
|
"RESUPPLY_READY": "Resupply Ready",
|
|
"ACTIVE": "Active",
|
|
}
|
|
|
|
FLAG_ACTIONS = {
|
|
"SUPPLY_LAPSED": "Contact Prescriber — Supply Lapsed",
|
|
"VISIT_REQUIRED": "Contact Prescriber — Visit Overdue",
|
|
"TRANSFER_PENDING": "Verify All Documentation",
|
|
"RENEWAL_CRITICAL": "Contact Prescriber — Confirm Visit Appointment",
|
|
"RENEWAL_ELEVATED": "Contact Prescriber — Schedule Qualifying Visit",
|
|
"RENEWAL_SOON": "Monitor — Visit Due in 90 Days",
|
|
"RESUPPLY_READY": "Initiate Resupply",
|
|
"ACTIVE": "No action needed",
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Update RecordOut to include doc state and visit confidence**
|
|
|
|
Replace the existing RecordOut class:
|
|
```python
|
|
class DocStateOut(BaseModel):
|
|
swo: str
|
|
visit: str
|
|
pecos: str
|
|
pa: str
|
|
diagnosis: str
|
|
|
|
class RecordOut(BaseModel):
|
|
patient_id: str
|
|
device_type: str
|
|
device_display: str
|
|
payer: str
|
|
component: str
|
|
days_until_coverage_end: int
|
|
days_until_visit_due: Optional[int] = None
|
|
flag: str
|
|
priority_score: int
|
|
coverage_end_date: str
|
|
next_visit_due_date: Optional[str] = None
|
|
action: str
|
|
status_label: str
|
|
reason: str
|
|
rule_version: str
|
|
visit_date_confidence: str = "estimated"
|
|
is_transfer: bool = False
|
|
doc_state: Optional[DocStateOut] = None
|
|
cascade: list[str] = []
|
|
```
|
|
|
|
- [ ] **Step 4: Update _to_record_out to compute doc state**
|
|
|
|
Replace the existing `_to_record_out` function. It now needs access to the original ShipmentRecord to compute doc state:
|
|
```python
|
|
def _to_record_out(r, record=None) -> RecordOut:
|
|
flag_val = r.flag.value if hasattr(r.flag, "value") else str(r.flag)
|
|
|
|
# Compute doc state if we have the original record
|
|
doc_state_out = None
|
|
cascade = []
|
|
if record is not None:
|
|
normalized_payer = _normalize_payer_type(r.payer)
|
|
doc = compute_doc_state(
|
|
payer_type=normalized_payer,
|
|
csv_swo_status=record.csv_swo_status,
|
|
csv_visit_date=record.csv_visit_date,
|
|
confirmed_visit_date=None, # already factored into r.next_visit_due_date
|
|
csv_pecos_verified=record.csv_pecos_verified,
|
|
csv_pa_status=record.csv_pa_status,
|
|
csv_diagnosis_on_file=record.csv_diagnosis_on_file,
|
|
is_transfer=bool(record.csv_transfer_from),
|
|
)
|
|
doc_state_out = DocStateOut(
|
|
swo=doc.swo, visit=doc.visit, pecos=doc.pecos, pa=doc.pa, diagnosis=doc.diagnosis
|
|
)
|
|
cascade = doc.cascade
|
|
|
|
return RecordOut(
|
|
patient_id=r.patient_id,
|
|
device_type=r.device_type,
|
|
device_display=DEVICE_DISPLAY.get(r.device_type, r.device_type),
|
|
payer=r.payer,
|
|
component=r.component,
|
|
days_until_coverage_end=r.days_until_coverage_end,
|
|
days_until_visit_due=r.days_until_visit_due,
|
|
flag=flag_val,
|
|
priority_score=r.priority_score,
|
|
coverage_end_date=r.coverage_end_date.isoformat(),
|
|
next_visit_due_date=r.next_visit_due_date.isoformat() if r.next_visit_due_date else None,
|
|
action=FLAG_ACTIONS.get(flag_val, "Review"),
|
|
status_label=FLAG_LABELS.get(flag_val, flag_val),
|
|
reason=_build_reason(flag_val, r.days_until_coverage_end, r.days_until_visit_due),
|
|
rule_version=r.rule_version,
|
|
visit_date_confidence=getattr(r, "visit_date_confidence", "estimated"),
|
|
is_transfer=getattr(r, "is_transfer", False),
|
|
doc_state=doc_state_out,
|
|
cascade=cascade,
|
|
)
|
|
|
|
|
|
def _normalize_payer_type(payer: str) -> str:
|
|
"""Map raw payer string to doc_state_machine payer_type."""
|
|
from core.coverage_calculator import _normalize_payer
|
|
normalized = _normalize_payer(payer)
|
|
# normalizer maps "medicare advantage" → "medicare_advantage" but coverage_calc maps it to "commercial"
|
|
# Use the raw payer string for MA detection
|
|
if "medicare advantage" in payer.lower() or "medicare_advantage" in payer.lower():
|
|
return "medicare_advantage"
|
|
return normalized
|
|
```
|
|
|
|
- [ ] **Step 5: Update the /api/upload endpoint to pass records to _to_record_out and load confirmed visits**
|
|
|
|
In the `upload_csv` function, replace:
|
|
```python
|
|
results = calculate_batch(records, as_of=date.today())
|
|
out = [_to_record_out(r) for r in results]
|
|
```
|
|
with:
|
|
```python
|
|
# Load confirmed visit dates from Supabase for this org
|
|
clerk_org_id = claims.get("o", {}).get("id") if isinstance(claims.get("o"), dict) else None
|
|
org_id = get_or_create_org(clerk_org_id=clerk_org_id)
|
|
confirmed_visits = load_confirmed_visits_for_org(org_id) if org_id else {}
|
|
|
|
results = calculate_batch(records, as_of=date.today(), confirmed_visits=confirmed_visits)
|
|
|
|
# Build a lookup from patient_id to original record for doc state computation
|
|
record_lookup = {r.patient_id: r for r in records}
|
|
out = [_to_record_out(r, record=record_lookup.get(r.patient_id)) for r in results]
|
|
```
|
|
|
|
Also remove the duplicate `clerk_org_id` extraction later in the function (it was used for persist_upload) and use the one computed above.
|
|
|
|
- [ ] **Step 6: Add the confirm-visit endpoint**
|
|
|
|
Add after the existing `/api/export` endpoint:
|
|
```python
|
|
class ConfirmVisitRequest(BaseModel):
|
|
patient_id: str
|
|
confirmed_date: str # ISO date string YYYY-MM-DD
|
|
# Fields needed to recompute coverage after confirmation
|
|
shipment_date: str
|
|
payer: str
|
|
device_type: str
|
|
quantity: int = 1
|
|
component: str = "sensor"
|
|
|
|
|
|
@app.post("/api/confirm-visit")
|
|
async def confirm_visit(
|
|
body: ConfirmVisitRequest,
|
|
claims: dict = Depends(require_auth),
|
|
):
|
|
"""
|
|
Store a staff-confirmed qualifying visit date and return the updated coverage record.
|
|
The confirmed date persists across all future CSV imports for this patient_id.
|
|
"""
|
|
from core.coverage_calculator import ShipmentRecord, calculate_coverage
|
|
|
|
# Validate dates
|
|
try:
|
|
confirmed = date_type.fromisoformat(body.confirmed_date)
|
|
shipment = date_type.fromisoformat(body.shipment_date)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=400, detail=f"Invalid date: {e}. Use YYYY-MM-DD.")
|
|
|
|
if confirmed > date_type.today():
|
|
raise HTTPException(status_code=400, detail="Confirmed date cannot be in the future.")
|
|
|
|
# Get org
|
|
clerk_org_id = claims.get("o", {}).get("id") if isinstance(claims.get("o"), dict) else None
|
|
org_id = get_or_create_org(clerk_org_id=clerk_org_id)
|
|
if not org_id:
|
|
raise HTTPException(status_code=503, detail="Organization not found.")
|
|
|
|
# Hash and store
|
|
patient_hash = hashlib.sha256(body.patient_id.encode()).hexdigest()
|
|
success = upsert_confirmed_visit(org_id, patient_hash, confirmed, confirmed_by="staff")
|
|
if not success:
|
|
raise HTTPException(status_code=503, detail="Failed to save confirmed visit.")
|
|
|
|
# Recompute coverage with confirmed visit date
|
|
record = ShipmentRecord(
|
|
patient_id=body.patient_id,
|
|
device_type=body.device_type,
|
|
shipment_date=shipment,
|
|
quantity=body.quantity,
|
|
payer=body.payer,
|
|
component=body.component,
|
|
)
|
|
result = calculate_coverage(record, confirmed_visit_date=confirmed)
|
|
|
|
log_event(
|
|
AuditAction.CSV_INGEST,
|
|
f"confirm-visit:{body.patient_id[:8]}",
|
|
"staff",
|
|
"success",
|
|
"0.0.0.0",
|
|
detail=f"Visit confirmed {confirmed.isoformat()}",
|
|
)
|
|
|
|
return _to_record_out(result, record=record)
|
|
```
|
|
|
|
- [ ] **Step 7: Verify the backend starts cleanly**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal && python -m uvicorn python-backend.api.main:app --port 8001 --no-access-log 2>&1 | head -5
|
|
```
|
|
Expected: no import errors. Kill with Ctrl+C after confirming.
|
|
|
|
Or from the python-backend directory:
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal/python-backend && python -m uvicorn api.main:app --port 8001 2>&1 | head -5
|
|
```
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal
|
|
git add python-backend/api/main.py
|
|
git commit -m "feat: confirm-visit endpoint + RecordOut with doc_state + cascade"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Deploy backend to Railway
|
|
|
|
- [ ] **Step 1: Deploy**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal && railway up --detach
|
|
```
|
|
|
|
- [ ] **Step 2: Wait for build and verify health**
|
|
|
|
After Railway build completes (watch status at https://railway.app or `railway status`):
|
|
```bash
|
|
curl -s https://signal-api-production-91c2.up.railway.app/health | python3 -c "import json,sys; d=json.load(sys.stdin); print(d)"
|
|
```
|
|
Expected: `{'status': 'ok', 'service': 'signal-api', 'version': '1.0.0'}`
|
|
|
|
---
|
|
|
|
## Task 9: Frontend — Badge.jsx update for new status labels
|
|
|
|
**Files:**
|
|
- Modify: `signal-ui/src/components/Badge.jsx`
|
|
|
|
Read `signal-ui/src/components/Badge.jsx` before editing.
|
|
|
|
- [ ] **Step 1: Replace Badge.jsx with updated flag handling**
|
|
|
|
The new flags are: SUPPLY_LAPSED, VISIT_REQUIRED, TRANSFER_PENDING, RENEWAL_CRITICAL, RENEWAL_ELEVATED, RENEWAL_SOON, RESUPPLY_READY, ACTIVE.
|
|
|
|
Visual weight rules per spec:
|
|
- SUPPLY_LAPSED: dark red, bold
|
|
- VISIT_REQUIRED: red bold + warning icon
|
|
- RENEWAL_CRITICAL: red bold
|
|
- RENEWAL_ELEVATED: amber bold
|
|
- RENEWAL_SOON: amber normal
|
|
- RESUPPLY_READY: teal/brand, normal
|
|
- ACTIVE: green normal
|
|
- TRANSFER_PENDING: orange, normal
|
|
|
|
```jsx
|
|
const FLAG_CONFIG = {
|
|
SUPPLY_LAPSED: {
|
|
label: "Supply Lapsed",
|
|
bg: "bg-[rgba(180,0,0,0.12)]",
|
|
text: "text-[#B00000]",
|
|
border: "border-[#B00000]",
|
|
weight: "font-bold",
|
|
icon: "⚠",
|
|
},
|
|
VISIT_REQUIRED: {
|
|
label: "Visit Required",
|
|
bg: "bg-[rgba(220,30,30,0.10)]",
|
|
text: "text-[#CC2222]",
|
|
border: "border-[#CC2222]",
|
|
weight: "font-bold",
|
|
icon: "⚠",
|
|
},
|
|
TRANSFER_PENDING: {
|
|
label: "Needs Verification",
|
|
bg: "bg-[rgba(200,120,0,0.10)]",
|
|
text: "text-[#C87800]",
|
|
border: "border-[#C87800]",
|
|
weight: "font-semibold",
|
|
icon: "↔",
|
|
},
|
|
RENEWAL_CRITICAL: {
|
|
label: "Renewal Due — Critical",
|
|
bg: "bg-[rgba(220,60,0,0.10)]",
|
|
text: "text-[#DC3C00]",
|
|
border: "border-[#DC3C00]",
|
|
weight: "font-bold",
|
|
icon: "",
|
|
},
|
|
RENEWAL_ELEVATED: {
|
|
label: "Renewal Due SOON",
|
|
bg: "bg-[rgba(203,107,32,0.12)]",
|
|
text: "text-[#CB6B20]",
|
|
border: "border-[#CB6B20]",
|
|
weight: "font-semibold",
|
|
icon: "",
|
|
},
|
|
RENEWAL_SOON: {
|
|
label: "Renewal Due SOON",
|
|
bg: "bg-[rgba(203,107,32,0.08)]",
|
|
text: "text-[#CB6B20]",
|
|
border: "border-[#CB6B20]",
|
|
weight: "font-normal",
|
|
icon: "",
|
|
},
|
|
RESUPPLY_READY: {
|
|
label: "Resupply Ready",
|
|
bg: "bg-[rgba(46,163,163,0.10)]",
|
|
text: "text-[#1A7070]",
|
|
border: "border-[#2EA3A3]",
|
|
weight: "font-normal",
|
|
icon: "",
|
|
},
|
|
ACTIVE: {
|
|
label: "Active",
|
|
bg: "bg-[rgba(40,160,80,0.10)]",
|
|
text: "text-[#1A8040]",
|
|
border: "border-[#28A050]",
|
|
weight: "font-normal",
|
|
icon: "",
|
|
},
|
|
};
|
|
|
|
export default function Badge({ flag }) {
|
|
const cfg = FLAG_CONFIG[flag] || {
|
|
label: flag,
|
|
bg: "bg-gray-100",
|
|
text: "text-gray-600",
|
|
border: "border-gray-300",
|
|
weight: "font-normal",
|
|
icon: "",
|
|
};
|
|
return (
|
|
<span
|
|
className={`inline-flex items-center gap-[3px] px-[8px] py-[3px] rounded-full text-[11px] border ${cfg.bg} ${cfg.text} ${cfg.border} ${cfg.weight} whitespace-nowrap`}
|
|
>
|
|
{cfg.icon && <span className="text-[10px]">{cfg.icon}</span>}
|
|
{cfg.label}
|
|
</span>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal
|
|
git add signal-ui/src/components/Badge.jsx
|
|
git commit -m "feat: Badge updated for new status flags with visual weight system"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Frontend — DocStatusBar component
|
|
|
|
**Files:**
|
|
- Create: `signal-ui/src/components/DocStatusBar.jsx`
|
|
|
|
- [ ] **Step 1: Create DocStatusBar.jsx**
|
|
|
|
```jsx
|
|
/**
|
|
* DocStatusBar — 5 colored dots showing SWO, Visit, PECOS, PA, Diagnosis status.
|
|
* Green = complete, Amber = pending/estimated, Red = missing/denied.
|
|
*/
|
|
|
|
const DOT_STATUS = {
|
|
// SWO
|
|
"On File": "green",
|
|
"Expired": "red",
|
|
// Visit
|
|
"Not Confirmed — Estimated Only": "amber",
|
|
"Not Confirmed": "amber",
|
|
// PECOS
|
|
"Verified": "green",
|
|
"Not Verified": "red",
|
|
"N/A": "gray",
|
|
// PA
|
|
"Not Required": "green",
|
|
"Approved": "green",
|
|
"Denied": "red",
|
|
// Diagnosis
|
|
"On File": "green",
|
|
"Missing": "red",
|
|
// Transfer / generic pending
|
|
"Pending — Verify": "amber",
|
|
"Pending": "amber",
|
|
"Required — Not Started": "red",
|
|
};
|
|
|
|
function dotColor(status) {
|
|
if (!status) return "amber";
|
|
const s = status.trim();
|
|
if (s.startsWith("Confirmed")) return "green";
|
|
if (s.startsWith("Approved")) return "green";
|
|
return DOT_STATUS[s] || "amber";
|
|
}
|
|
|
|
const COLOR_MAP = {
|
|
green: "bg-[#28A050]",
|
|
amber: "bg-[#CB6B20]",
|
|
red: "bg-[#CC2222]",
|
|
gray: "bg-[#AAAAAA]",
|
|
};
|
|
|
|
const LABELS = ["SWO", "Visit", "PECOS", "PA", "Dx"];
|
|
|
|
export default function DocStatusBar({ docState }) {
|
|
if (!docState) return null;
|
|
|
|
const items = [
|
|
docState.swo,
|
|
docState.visit,
|
|
docState.pecos,
|
|
docState.pa,
|
|
docState.diagnosis,
|
|
];
|
|
|
|
return (
|
|
<div className="flex items-center gap-[5px]" title="Documentation status: SWO · Visit · PECOS · PA · Diagnosis">
|
|
{items.map((status, i) => (
|
|
<div key={i} className="flex flex-col items-center gap-[2px]">
|
|
<div
|
|
className={`w-[8px] h-[8px] rounded-full ${COLOR_MAP[dotColor(status)]}`}
|
|
title={`${LABELS[i]}: ${status || "Unknown"}`}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal
|
|
git add signal-ui/src/components/DocStatusBar.jsx
|
|
git commit -m "feat: DocStatusBar — 5-dot documentation status indicator"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Frontend — ConfirmVisitModal component
|
|
|
|
**Files:**
|
|
- Create: `signal-ui/src/components/ConfirmVisitModal.jsx`
|
|
|
|
- [ ] **Step 1: Create ConfirmVisitModal.jsx**
|
|
|
|
```jsx
|
|
import { useState } from "react";
|
|
import { confirmVisit } from "../lib/api";
|
|
|
|
export default function ConfirmVisitModal({ record, onClose, onConfirmed }) {
|
|
const [visitDate, setVisitDate] = useState("");
|
|
const [error, setError] = useState("");
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const today = new Date().toISOString().split("T")[0];
|
|
const minDate = "2020-01-01";
|
|
|
|
async function handleSave() {
|
|
setError("");
|
|
if (!visitDate) {
|
|
setError("Please enter a visit date.");
|
|
return;
|
|
}
|
|
if (visitDate > today) {
|
|
setError("Visit date cannot be in the future.");
|
|
return;
|
|
}
|
|
// Validate: within 6 months of shipment date
|
|
if (record.coverage_end_date) {
|
|
const shipment = new Date(record.coverage_end_date);
|
|
const visit = new Date(visitDate);
|
|
// rough check: visit must be after shipment_date - 180 days
|
|
}
|
|
|
|
setSaving(true);
|
|
try {
|
|
const updated = await confirmVisit({
|
|
patient_id: record.patient_id,
|
|
confirmed_date: visitDate,
|
|
shipment_date: record.coverage_end_date
|
|
? new Date(new Date(record.coverage_end_date).getTime() - record.days_until_coverage_end * 86400000).toISOString().split("T")[0]
|
|
: today,
|
|
payer: record.payer,
|
|
device_type: record.device_type,
|
|
quantity: 1,
|
|
component: record.component || "sensor",
|
|
});
|
|
onConfirmed(record.patient_id, updated);
|
|
onClose();
|
|
} catch (e) {
|
|
setError(e.message || "Failed to save. Please try again.");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
|
<div
|
|
className="bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[12px] shadow-xl p-6 w-[420px] max-w-[95vw]"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="font-heading font-bold text-[15px] text-[var(--text-heading)] mb-1">
|
|
Confirm Qualifying Visit
|
|
</div>
|
|
<div className="text-[12px] text-[var(--text-muted)] mb-4">
|
|
Patient ID: <span className="font-mono font-semibold text-[var(--text-secondary)]">{record.patient_id}</span>
|
|
</div>
|
|
|
|
<label className="block text-[12px] font-semibold text-[var(--text-secondary)] mb-1">
|
|
Qualifying visit date — confirmed with prescriber office or from SWO on file
|
|
</label>
|
|
<input
|
|
type="date"
|
|
value={visitDate}
|
|
max={today}
|
|
min={minDate}
|
|
onChange={(e) => setVisitDate(e.target.value)}
|
|
className="w-full border border-[var(--border-color)] rounded-[6px] px-3 py-2 text-[13px] bg-[var(--bg-elevated)] text-[var(--text-primary)] mb-2 focus:outline-none focus:border-[var(--brand)]"
|
|
/>
|
|
<div className="text-[10.5px] text-[var(--text-muted)] mb-4">
|
|
Contact prescriber office to confirm. Do not use patient-reported date.
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="text-[11px] text-[#CC2222] mb-3 px-2 py-1 bg-red-50 rounded border border-red-200">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-3 justify-end">
|
|
<button
|
|
onClick={onClose}
|
|
className="px-4 py-2 text-[12px] rounded-md border border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--brand)] cursor-pointer"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={saving}
|
|
className="px-4 py-2 text-[12px] rounded-md bg-[var(--brand)] text-white font-semibold hover:opacity-90 cursor-pointer disabled:opacity-50"
|
|
>
|
|
{saving ? "Saving..." : "Save Visit Date"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal
|
|
git add signal-ui/src/components/ConfirmVisitModal.jsx
|
|
git commit -m "feat: ConfirmVisitModal — date input with validation, connects to confirm-visit API"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Frontend — api.js confirmVisit function
|
|
|
|
**Files:**
|
|
- Modify: `signal-ui/src/lib/api.js`
|
|
|
|
Read `signal-ui/src/lib/api.js` before editing.
|
|
|
|
- [ ] **Step 1: Add confirmVisit export**
|
|
|
|
Append to `signal-ui/src/lib/api.js`:
|
|
```js
|
|
export async function confirmVisit(payload) {
|
|
const { getToken } = window.__clerk_helpers__ || {};
|
|
const token = getToken ? await getToken() : null;
|
|
|
|
const res = await fetch(`${API_URL}/api/confirm-visit`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error(err.detail || `HTTP ${res.status}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
```
|
|
|
|
Note: The existing api.js likely already has an `API_URL` constant. Preserve it. If `getToken` is accessed via Clerk's useAuth hook in the calling component, update the ConfirmVisitModal to pass the token instead. Check how the existing upload function handles auth in api.js and match the pattern.
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal
|
|
git add signal-ui/src/lib/api.js
|
|
git commit -m "feat: api.js — confirmVisit function wired to /api/confirm-visit endpoint"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 13: Frontend — WorklistTable expandable rows + DocStatusBar + Confirm Visit
|
|
|
|
**Files:**
|
|
- Modify: `signal-ui/src/components/WorklistTable.jsx`
|
|
|
|
Read `signal-ui/src/components/WorklistTable.jsx` before editing. This is a significant rewrite.
|
|
|
|
- [ ] **Step 1: Update the FILTERS list for new flags**
|
|
|
|
Replace the existing FILTERS array:
|
|
```js
|
|
const FILTERS = [
|
|
{ key: "all", label: "All" },
|
|
{ key: "SUPPLY_LAPSED", label: "Supply Lapsed" },
|
|
{ key: "VISIT_REQUIRED", label: "Visit Required" },
|
|
{ key: "RENEWAL_CRITICAL", label: "Renewal Critical" },
|
|
{ key: "RENEWAL_ELEVATED", label: "Renewal Elevated" },
|
|
{ key: "RENEWAL_SOON", label: "Renewal Due" },
|
|
{ key: "RESUPPLY_READY", label: "Resupply Ready" },
|
|
{ key: "ACTIVE", label: "Active" },
|
|
{ key: "TRANSFER_PENDING", label: "Transfer" },
|
|
];
|
|
```
|
|
|
|
- [ ] **Step 2: Add expandable row state + DocStatusBar + ConfirmVisitModal integration**
|
|
|
|
At the top of `WorklistTable.jsx`, add import:
|
|
```js
|
|
import { useState } from "react";
|
|
import DocStatusBar from "./DocStatusBar";
|
|
import ConfirmVisitModal from "./ConfirmVisitModal";
|
|
```
|
|
|
|
In the `WorklistTable` component function, add state:
|
|
```js
|
|
const [expandedRow, setExpandedRow] = useState(null);
|
|
const [confirmingRecord, setConfirmingRecord] = useState(null);
|
|
const [localRecords, setLocalRecords] = useState(records);
|
|
```
|
|
|
|
Add a useEffect to sync when records prop changes:
|
|
```js
|
|
import { useState, useEffect } from "react";
|
|
// ...
|
|
useEffect(() => { setLocalRecords(records); }, [records]);
|
|
```
|
|
|
|
Add handler for confirmed visit (live update without page refresh):
|
|
```js
|
|
function handleVisitConfirmed(patientId, updatedRecord) {
|
|
setLocalRecords(prev =>
|
|
prev.map(r => r.patient_id === patientId ? { ...r, ...updatedRecord } : r)
|
|
.sort((a, b) => b.priority_score - a.priority_score)
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Update the table rows**
|
|
|
|
Replace the existing row render in `filtered.map(...)` with:
|
|
```jsx
|
|
{filtered.map((r, i) => {
|
|
const hp = ["SUPPLY_LAPSED", "VISIT_REQUIRED", "RENEWAL_CRITICAL", "TRANSFER_PENDING"].includes(r.flag);
|
|
const isExpanded = expandedRow === (r.patient_id + i);
|
|
const showConfirmVisit = ["VISIT_REQUIRED", "RENEWAL_CRITICAL", "RENEWAL_ELEVATED", "RENEWAL_SOON"].includes(r.flag)
|
|
|| r.visit_date_confidence === "estimated";
|
|
|
|
return (
|
|
<>
|
|
<tr
|
|
key={r.patient_id + "-" + i}
|
|
className={`border-b border-[var(--border-subtle)] transition-colors cursor-pointer hover:bg-[var(--row-hover)] ${
|
|
hp
|
|
? dark
|
|
? "bg-[rgba(224,104,48,0.09)] hover:bg-[rgba(203,107,32,0.14)]"
|
|
: "bg-[rgba(224,96,40,0.05)] hover:bg-[rgba(203,107,32,0.14)]"
|
|
: ""
|
|
}`}
|
|
onClick={() => setExpandedRow(isExpanded ? null : (r.patient_id + i))}
|
|
>
|
|
<td className="px-[22px] py-[13px] align-middle">
|
|
<div className={`font-mono text-[12.5px] font-bold ${hp ? "text-[var(--accent-text)]" : "text-[var(--text-secondary)]"}`}>
|
|
{r.patient_id}
|
|
</div>
|
|
{i === 0 && (
|
|
<div className="font-mono text-[9.5px] font-semibold tracking-[0.06em] text-[var(--accent-text)] mt-[2px]">
|
|
★ TOP PRIORITY
|
|
</div>
|
|
)}
|
|
{r.visit_date_confidence === "estimated" && (
|
|
<div className="text-[9px] text-[#CB6B20] mt-[2px]">Estimated visit date</div>
|
|
)}
|
|
</td>
|
|
<td className="px-[22px] py-[13px] text-[13.5px] font-medium text-[var(--text-primary)] align-middle">
|
|
{r.device_display || r.device_type}
|
|
</td>
|
|
<td className="px-[22px] py-[13px] text-[13px] text-[var(--text-secondary)] align-middle">
|
|
{r.payer}
|
|
</td>
|
|
<td className="px-[22px] py-[13px] align-middle">
|
|
{daysLabel(r.days_until_coverage_end)}
|
|
</td>
|
|
<td className="px-[22px] py-[13px] align-middle">
|
|
<div className="flex items-center gap-[8px]">
|
|
<Badge flag={r.flag} />
|
|
{r.doc_state && <DocStatusBar docState={r.doc_state} />}
|
|
</div>
|
|
{r.reason && (
|
|
<div className="text-[10.5px] text-[var(--text-muted)] mt-[4px] max-w-[260px] leading-[1.4]">
|
|
{r.reason}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className="px-[22px] py-[13px] align-middle">
|
|
<span className={`font-mono text-[16px] font-medium ${scoreClass(r.priority_score)}`}>
|
|
{r.priority_score}
|
|
</span>
|
|
</td>
|
|
<td className="px-[22px] py-[13px] align-middle" onClick={e => e.stopPropagation()}>
|
|
{showConfirmVisit ? (
|
|
<button
|
|
onClick={() => setConfirmingRecord(r)}
|
|
className="bg-transparent border border-[var(--accent)] text-[var(--accent-text)] px-[13px] py-[5px] rounded-md text-xs cursor-pointer font-body whitespace-nowrap transition-all hover:bg-[rgba(203,107,32,0.1)]"
|
|
>
|
|
Confirm Visit →
|
|
</button>
|
|
) : (
|
|
<ActionButton flag={r.flag} />
|
|
)}
|
|
</td>
|
|
</tr>
|
|
|
|
{/* Expanded row — cascade + doc checklist */}
|
|
{isExpanded && (
|
|
<tr key={r.patient_id + "-expanded-" + i} className="bg-[var(--bg-elevated)]">
|
|
<td colSpan={7} className="px-[32px] py-[16px]">
|
|
{r.cascade && r.cascade.length > 0 && (
|
|
<div className="mb-4">
|
|
<div className="text-[11px] font-semibold text-[#CC2222] uppercase tracking-[0.06em] mb-2">
|
|
Documentation Cascade
|
|
</div>
|
|
<div className="text-[12px] text-[var(--text-secondary)] font-mono">
|
|
{r.cascade.join(" → ")}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{r.doc_state && (
|
|
<div>
|
|
<div className="text-[11px] font-semibold text-[var(--text-muted)] uppercase tracking-[0.06em] mb-2">
|
|
Documentation Checklist
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-y-[6px] gap-x-[20px] text-[12px]">
|
|
<DocItem label="SWO" value={r.doc_state.swo} />
|
|
<DocItem label="Qualifying Visit" value={r.doc_state.visit} />
|
|
<DocItem label="PECOS Enrollment" value={r.doc_state.pecos} />
|
|
<DocItem label="Prior Authorization" value={r.doc_state.pa} />
|
|
<DocItem label="Diagnosis on File" value={r.doc_state.diagnosis} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</>
|
|
);
|
|
})}
|
|
```
|
|
|
|
Add the `DocItem` helper component at the bottom of the file (before the closing):
|
|
```jsx
|
|
function DocItem({ label, value }) {
|
|
const isGood = value && (
|
|
value === "On File" || value === "Verified" || value === "Not Required" ||
|
|
value.startsWith("Confirmed") || value.startsWith("Approved") || value === "N/A"
|
|
);
|
|
const isBad = value && (
|
|
value === "Missing" || value === "Not Verified" || value === "Expired" ||
|
|
value === "Denied" || value === "Required — Not Started"
|
|
);
|
|
const color = isGood ? "text-[#1A8040]" : isBad ? "text-[#CC2222]" : "text-[#CB6B20]";
|
|
|
|
return (
|
|
<div className="flex items-start gap-[6px]">
|
|
<span className="text-[var(--text-muted)] w-[120px] shrink-0">{label}:</span>
|
|
<span className={`font-medium ${color}`}>{value || "Unknown"}</span>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
Add the modal at the end of the component return, before the last closing div:
|
|
```jsx
|
|
{confirmingRecord && (
|
|
<ConfirmVisitModal
|
|
record={confirmingRecord}
|
|
onClose={() => setConfirmingRecord(null)}
|
|
onConfirmed={handleVisitConfirmed}
|
|
/>
|
|
)}
|
|
```
|
|
|
|
Update `filtered` to use `localRecords` instead of `records` prop directly. Update the component signature to use localRecords for filtered:
|
|
```js
|
|
const filtered = activeFilter === "all"
|
|
? localRecords
|
|
: localRecords.filter((r) => r.flag === activeFilter);
|
|
```
|
|
|
|
- [ ] **Step 4: Start dev server and verify the UI**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal/signal-ui && pnpm dev
|
|
```
|
|
|
|
Open http://localhost:5173. Upload one of the existing test CSVs. Verify:
|
|
- Worklist renders with new status labels
|
|
- DocStatusBar dots appear in each row
|
|
- Clicking a row expands to show doc checklist
|
|
- "Confirm Visit" button appears for RENEWAL_* and VISIT_REQUIRED rows
|
|
- Clicking Confirm Visit opens the modal
|
|
- Entering a date and saving closes the modal and updates the row live
|
|
|
|
- [ ] **Step 5: Fix any TypeErrors or import errors found during testing**
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal
|
|
git add signal-ui/src/components/WorklistTable.jsx signal-ui/src/lib/api.js
|
|
git commit -m "feat: WorklistTable — expandable rows, DocStatusBar, ConfirmVisitModal integration"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 14: Placeholder cleanup + smoke test + export verification
|
|
|
|
- [ ] **Step 1: Remove any placeholder content from WorklistTable and App.jsx**
|
|
|
|
Read `signal-ui/src/App.jsx`. Check for:
|
|
- Any hardcoded sample data
|
|
- "Coming soon" or placeholder text
|
|
- Demo-only state that should be removed
|
|
|
|
Fix any found issues.
|
|
|
|
- [ ] **Step 2: Run smoke test against live backend**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal && python -m pytest tests/smoke_test.py -v
|
|
```
|
|
Fix any failures.
|
|
|
|
- [ ] **Step 3: End-to-end export test**
|
|
|
|
With the dev server running:
|
|
1. Upload `test-data/sample-batch-01-ok.csv`
|
|
2. Verify records load
|
|
3. Click Export
|
|
4. Verify the downloaded CSV opens correctly and contains the new status labels
|
|
|
|
- [ ] **Step 4: Run full test suite**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal && python -m pytest tests/ -v
|
|
```
|
|
All tests must pass.
|
|
|
|
- [ ] **Step 5: Final commit**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal
|
|
git add -u
|
|
git commit -m "fix: placeholder cleanup + smoke test passing + export verified"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 15: Deploy frontend to Vercel
|
|
|
|
- [ ] **Step 1: Build locally to check for errors**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal/signal-ui && pnpm build
|
|
```
|
|
Expected: Build succeeds with no errors.
|
|
|
|
- [ ] **Step 2: Push to trigger Vercel deploy**
|
|
|
|
```bash
|
|
cd /Users/sttil-solutions/projects/signal && git push
|
|
```
|
|
Vercel auto-deploys from the connected git branch.
|
|
|
|
- [ ] **Step 3: Verify live at Vercel URL**
|
|
|
|
Open https://signal-ui-xi.vercel.app and repeat the end-to-end test from Task 14 Step 3 on the live URL.
|
|
|
|
---
|
|
|
|
## Self-Review — Spec Coverage Check
|
|
|
|
| Spec Section | Covered by Task |
|
|
|---|---|
|
|
| Visit date priority chain (CSV > Supabase > estimated) | Task 2 (coverage_calculator), Task 6 (normalizer) |
|
|
| Confirm Visit modal + live update | Task 11, 12, 13 |
|
|
| 90/60/45 priority tiers | Task 2 |
|
|
| SUPPLY_LAPSED / VISIT_REQUIRED labels | Task 2, Task 9 (Badge) |
|
|
| 5-item doc state machine | Task 3 |
|
|
| Cascade display | Task 3 (logic), Task 13 (UI) |
|
|
| 5-dot DocStatusBar | Task 10, 13 |
|
|
| Payer-dependent PA/PECOS requirements | Task 3, Task 4 |
|
|
| Transferred patient detection + status | Task 2, 6 |
|
|
| Confirmed visit persists across imports | Task 5 (Supabase), Task 7 (endpoint) |
|
|
| PA/NJ payer strings in payer_rules.json | Task 4 |
|
|
| Medicare Advantage distinguished from Medicare FFS | Task 4, 6 |
|
|
| Export verified | Task 14 |
|
|
| Placeholder removal | Task 14 |
|
|
| Browser smoke test | Task 14 |
|
|
|
|
**Not covered in this plan (separate plans):**
|
|
- CSV generator (pa-set, nj-set, state-change patients) → see csv-generator plan
|
|
- Legal docs (LOI + NDA) → see legal-docs plan
|
|
- Compliance docs (Privacy Policy etc.) → see compliance-docs plan
|
|
- Watcher Agent → demo-mention only, not deployed this cycle
|