Compare commits

..

2 commits

Author SHA1 Message Date
Kisa
cc2a5f4b47 coverage calculator and UI refinements; add demo MVP gap analysis and quality review docs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 13:04:23 -04:00
Kisa
3b0de375d9 Update pilot readiness to 12/13 (92%) — Supabase Pro confirmed
Marks Real PHI Blocked as PASS after founder confirmed Supabase Pro
upgrade on 2026-06-11.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 10:21:13 -04:00
11 changed files with 327 additions and 120 deletions

View file

@ -64,7 +64,7 @@ and stays predictable.
Whitepaper: `/Users/sttil-solutions/Documents/Obsidian_Vault/STTIL-Vault/Projects/2026-05-18-pilot-readiness-whitepaper.md` Whitepaper: `/Users/sttil-solutions/Documents/Obsidian_Vault/STTIL-Vault/Projects/2026-05-18-pilot-readiness-whitepaper.md`
### Checklist Status (as of 2026-06-10) — 85% pilot-ready (11/13) ### Checklist Status (as of 2026-06-11) — 92% pilot-ready (12/13)
| Checklist Item | Status | | Checklist Item | Status |
|---|---| |---|---|
@ -79,7 +79,7 @@ Whitepaper: `/Users/sttil-solutions/Documents/Obsidian_Vault/STTIL-Vault/Project
| Placeholder content removed | PARTIAL — StatCard undefined variables fixed 2026-06-09; no empty-state guidance or status legend for first-time users | | Placeholder content removed | PARTIAL — StatCard undefined variables fixed 2026-06-09; no empty-state guidance or status legend for first-time users |
| Browser smoke test passes | PASS — CSV import, all filter tabs, Confirm Visit, Export all verified 2026-06-10; sidebar count sync fixed | | Browser smoke test passes | PASS — CSV import, all filter tabs, Confirm Visit, Export all verified 2026-06-10; sidebar count sync fixed |
| Data handling rules documented | PASS — privacy-policy.md + data-handling.md written 2026-06-07; minimum necessary fields, CSV disposition policy, retention, and payer_rules.json cadence all documented | | Data handling rules documented | PASS — privacy-policy.md + data-handling.md written 2026-06-07; minimum necessary fields, CSV disposition policy, retention, and payer_rules.json cadence all documented |
| Real PHI blocked | PARTIAL — architecture confirmed; Supabase plan tier (Pro vs Team) not yet verified | | Real PHI blocked | PASS — architecture confirmed; Supabase Pro plan verified 2026-06-11 |
| Pilot success metrics written down | PASS | | Pilot success metrics written down | PASS |
Update this table every build session. Re-count PASS items before quoting a % to anyone. Update this table every build session. Re-count PASS items before quoting a % to anyone.

View file

@ -103,7 +103,7 @@ Uploaded CSV files are:
| Upload batch metadata | Duration of subscription + 90 days | | Upload batch metadata | Duration of subscription + 90 days |
| Scored patient records (normalized_records) | Duration of subscription + 90 days | | Scored patient records (normalized_records) | Duration of subscription + 90 days |
| Confirmed visit dates | Duration of subscription + 90 days | | Confirmed visit dates | Duration of subscription + 90 days |
| Audit events | 7 years (HIPAA compliance requirement, per 45 CFR §164.530(j)) | | Audit events | 6 years minimum (HIPAA compliance requirement, per 45 CFR §164.530(j)) |
| Source file metadata (hash only) | Duration of subscription + 90 days | | Source file metadata (hash only) | Duration of subscription + 90 days |
Upon written request, all customer data except audit events (which are retained for legal compliance) will be deleted within 10 business days. STTIL will confirm deletion in writing. Upon written request, all customer data except audit events (which are retained for legal compliance) will be deleted within 10 business days. STTIL will confirm deletion in writing.

View file

@ -0,0 +1,46 @@
# Demo MVP Spec Gap Analysis — 2026-06-11
Spec: docs/superpowers/specs/2026-06-07-signal-demo-mvp-design.md. Read-only analysis by background agent.
## DONE
- Visit date priority chain — _resolve_visit_date() coverage_calculator.py:167-183 (CSV > Supabase confirmed > shipment30d estimate)
- 6-month validation — _compute_visit_flag() + /api/confirm-visit main.py:511-523
- 90/60/45 tier system + urgency sort — _compute_priority() coverage_calculator.py:203-224
- 45-day prescriber outreach flag — FLAG_ACTIONS RENEWAL_CRITICAL
- Confirm Visit workflow end to end — modal, persistence, recompute, live row update, persists across imports
- Full 5-item payer-dependent doc state machine — doc_state_machine.py matches spec exactly
- Cascade on expanded row + 5-dot status bar — WorklistTable.jsx:260-293, DocStatusBar.jsx
- Transfer detection via Transfer_From column — normalizer.py:75-78, TRANSFER_PENDING short-circuit
- CSV test suite — 50 files (PA+NJ), parameterized generators, market_data.json
- Legal drafts — pitch/legal/ (LOI, NDA, BAA)
## PARTIAL
- Status labels deviated from spec — RESOLVED 2026-06-11: locked to At Risk / Action Needed / On Track / Clear to Ship everywhere (see quality review doc)
- Supply Lapsed alert banner — sorts to top, no banner component
- Estimated label wording — spec's "Estimated — verify with prescriber office" not in UI
- Cascade format — all-gaps list vs spec's single chain (deliberate redesign, commit ff657f4)
- Per-item recommended actions in expanded checklist — live in cascade lines only
- Cycle reset via re-import — recompute works, but NO diff vs previous import; CSV visit dates not persisted to confirmed_visits
- Transfer clearing — "Verify Docs" button has no onClick handler
- MFA on admin accounts NOT VERIFIED; attorney review of LOI/NDA pending
## NOT BUILT
- Patient-disappeared-from-import flag (needs import diff: persistence.py previous batch load, main.py compare + new flag, frontend badge)
- Transfer detection via history gap (same import-history dependency)
- Duplicate patient_id detection in one file (normalizer.py has none; collapses silently)
- Watcher Agent (nothing exists; demo needs it only as talking point)
- 6 pilot questions in /pilot-prep skill (zero present)
## Empty-state / status legend
Spec does NOT cover either — no overlap, safe to build separately. EmptyState.jsx exists (no CSV guidance); zero "legend" hits in signal-ui/src. Maps directly to the last PARTIAL pilot-readiness item.
## Recommended build order
1. Status legend + empty-state guidance (clears pilot readiness to 13/13)
2. Pilot-prep skill questions (5-minute edit)
3. Import diff package: previous-batch lookup, disappeared flag, persist CSV visit dates ("Signal gets smarter every import")
4. Wire Verify Docs transfer clearing
5. Duplicate patient_id flagging
6. Watcher Agent (trail)
7. Parallel ops: MFA verification, attorney review
Beyond-spec already built: column mapping review UI (CSVImport.jsx) — spec Sec 12 excluded it; no action.

View file

@ -0,0 +1,57 @@
# Signal Quality Review — 2026-06-11
Read-only review of signal-ui and python-backend by background agent. No fixes applied yet.
Execution plan: surgical pass = 2 CRITICAL + 8 HIGH first, then label unification (see decision note below).
**Label decision (Kisa, 2026-06-11):** All user-facing status labels are exactly four, consistent everywhere without fail: **At Risk, Action Needed, On Track, Clear to Ship.** Mapping: SUPPLY_LAPSED/VISIT_REQUIRED/RENEWAL_CRITICAL → At Risk; RENEWAL_ELEVATED/RENEWAL_SOON/TRANSFER_PENDING/NO_RECENT_SHIPMENT → Action Needed; RESUPPLY_READY → Clear to Ship; ACTIVE → On Track. Define once in backend; Badge.jsx, exports, and coverage.js consume it. Supersedes FLAG_LABELS in api/main.py:117-127 and coverage.js:20-29. This resolves findings 15 and 16.
## CRITICAL
1. **Confirm Visit sends fabricated shipment date (today) and hardcoded quantity 1.** ConfirmVisitModal.jsx:16-23 derives shipment = today; :58 hardcodes quantity 1. Backend recompute (main.py:538-546) then yields wrong Days Left/coverage end/flag; visit validation (main.py:514-523) anchors wrong. Fix: add last_shipment_date and quantity to RecordOut and pass through.
2. **Privacy page claims an executed BAA that does not exist.** Privacy.jsx:104-110; also :97 says 7-year audit retention vs audit_logger.py 6 years. Fix: rewrite HIPAA section to actual de-identified posture; align retention number.
## HIGH
3. **NO_RECENT_SHIPMENT unreachable** — coverage_calculator.py:269-288 returns SUPPLY_LAPSED before the 12-month check at :312-328; same in coverage.js:127-154. Fix: check days_since_shipment >= 365 before supply-lapsed early return.
4. **Tricare → commercial.** normalizer.py:202-208 normalizes first (no Tricare entries, default commercial), making coverage_calculator._normalize_payer TFL logic dead code; raw payer display string also destroyed. Fix: normalize in one place, keep raw string for display, add Tricare to normalizer.
5. **Frontend export drops order_number, hcpcs, recommended_next_step_code.** api.js:62-89 omits them; export rows get blanks + NO_ACTION_NEEDED (main.py:170-172 defaults). Fix: map all three in apiRecordToLocal.
6. **Fallback priority math inverted.** coverage.js:93-102: RENEWAL_CRITICAL 500 < RENEWAL_ELEVATED 700; SUPPLY_LAPSED 1000 vs backend 2000; uses daysUntilEnd vs backend days_until_visit_due. Fix: copy backend _compute_priority exactly.
7. **Confirmed visits never reflected in doc state; confirming regresses checklist.** main.py:262 passes confirmed_visit_date=None; /api/confirm-visit rebuild (main.py:538-545) loses csv_* doc fields, frontend merge (WorklistTable.jsx:71-77) overwrites row with regressed state. Fix: pass confirmed date into compute_doc_state; echo existing doc fields through confirm payload.
8. **_recommended_next_step_code precedence bug.** main.py:245 guard is constantly True (DocStateOut has no cascade attr); diagnosis "Missing" never checked → READY_TO_BILL with open diagnosis gap. Fix: drop broken guard, add diagnosis check.
9. **"refill" in user-facing text.** main.py:136 "30-day refill window" → "resupply window". (Internal refill_window_days config keys non-urgent.)
10. **Frontend FLAG enum missing TRANSFER_PENDING; payer config drift.** coverage.js:9-18 (8 vs 9 flags), no csv_visit_date support; getPayerConfig gives medicaid/commercial visitRenewalDays null vs payer_rules.json 180; visit due = shipment+180 vs backend (shipment30)+180. Fix: mirror backend flag set, payer table, 30-day offset.
## MEDIUM
11. Backend failures silently swallowed; weak local fallback runs (api.js:21-34, App.jsx:61-74); real CSVs can show "Loaded 0 patients" with no error. Surface backend errors; fall back only on network failure.
12. Audit entries never reach WORM audit_log table — audit_logger.py:147-148 needs db_conn, no caller passes one. Add Supabase REST sink in log_event.
13. Audit calls hardcode "demo_user"/"0.0.0.0" (main.py:359, 381, 467, 549). Thread claims + request IP through.
14. Org resolution reads only "o" claim (main.py:371, 526); org_id-shaped JWTs silently land in shared gaboro-pilot org. Read o.id with org_id fallback; fail closed otherwise.
15. Three conflicting status vocabularies — RESOLVED by label decision above.
16. DocStatusBar.jsx:6-24 maps dead status strings; real values fall through to amber. Align keys with doc_state_machine.py values.
17. Duplicate patient_ids collapse in dict lookups (main.py:378, persistence.py:114). Key by (patient_id, shipment_date, component) or index-align.
18. Mapping review Override dropdown is a no-op (CSVImport.jsx:117-123); example_value (:52) never sent by backend. Wire it or remove until wired; checklist counts it PASS — correct the checklist if not wiring now.
19. Blank/unrecognized payer silently becomes commercial (normalizer.py:208). Surface warning in skipped_reasons/mapping_summary.
20. VITE_SIGNAL_API_KEY path would ship API key in public bundle if ever set (api.js:8). Remove frontend API-key path; Clerk token covers production.
21. No explicit PHI-column guard in normalizer; bare "patient"/"id" aliases broad (normalizer.py:20-26). Add rejection list (name/dob/ssn/phone/address) that fails upload with clear message; drop bare "patient" alias. Checklist claims "PHI blocked at normalizer" — currently implicit only.
22. Client/backend disagree on 6-month window: setMonth(-6) (ConfirmVisitModal.jsx:39-41) vs 183 days (main.py:514). Use 183 days both sides.
## LOW
23. Stale flag "OK" in _no_action_flags — persistence.py:153.
24. Unused/redundant imports — main.py:25, 499, 513.
25. catch(e){throw e} — api.js:116-118; misleading warning text — coverage_calculator.py:376.
26. escalateCount unused — App.jsx:34.
27. Two parallel toast systems — Toast.jsx + WorklistTable.jsx:45-53, 330-350.
28. expandedRow localStorage key auto-expands wrong row after re-import — WorklistTable.jsx:41, 139, 199.
29. parseCSV naive comma split breaks quoted fields (fallback only) — coverage.js:169-182.
30. EmptyState hardcodes light-theme colors — EmptyState.jsx:8, 22.
31. /health/db leaks raw exception text — main.py:339.
32. "now()" string literal to timestamptz — persistence.py:193.
33. SUPPLY_LAPSED priority adds abs(days_until_visit) even when visit far future — coverage_calculator.py:209-211.
34. Sidebar hardcodes "Billing Staff" role + dead Settings link — Sidebar.jsx:126-130, 143-145.
## Clean
- Auth: /api/upload, /api/export, /api/confirm-visit all behind require_auth; frontend passes Clerk Bearer tokens. Only /health endpoints open (see 31).
- PHI in audit log: all resource_ids hashed before storage.

View file

@ -133,7 +133,7 @@ FLAG_ACTIONS = {
"RENEWAL_CRITICAL": "Contact prescriber's office and patient directly — ask: 'Was there a visit in the past 6 months to evaluate CGM dosing needs?' Appointment must be on calendar within 45 days", "RENEWAL_CRITICAL": "Contact prescriber's office and patient directly — ask: 'Was there a visit in the past 6 months to evaluate CGM dosing needs?' Appointment must be on calendar within 45 days",
"RENEWAL_ELEVATED": "Follow up with prescriber's office — ask: 'Was there a visit in the past 6 months to evaluate CGM dosing needs?' Confirm appointment is scheduled", "RENEWAL_ELEVATED": "Follow up with prescriber's office — ask: 'Was there a visit in the past 6 months to evaluate CGM dosing needs?' Confirm appointment is scheduled",
"RENEWAL_SOON": "Contact prescriber's office — ask: 'Was there a visit in the past 6 months to evaluate this patient's CGM dosing needs?' Schedule evaluation now if not yet completed", "RENEWAL_SOON": "Contact prescriber's office — ask: 'Was there a visit in the past 6 months to evaluate this patient's CGM dosing needs?' Schedule evaluation now if not yet completed",
"RESUPPLY_READY": "Initiate resupply order — patient is within the 30-day refill window", "RESUPPLY_READY": "Initiate resupply order — patient is within the 30-day resupply window",
"ACTIVE": "No action needed — documentation current, visit more than 90 days out", "ACTIVE": "No action needed — documentation current, visit more than 90 days out",
"NO_RECENT_SHIPMENT": "Verify status with prescriber office — confirm patient is still active", "NO_RECENT_SHIPMENT": "Verify status with prescriber office — confirm patient is still active",
} }
@ -170,6 +170,16 @@ class RecordOut(BaseModel):
order_number: Optional[str] = None order_number: Optional[str] = None
hcpcs: Optional[str] = None hcpcs: Optional[str] = None
recommended_next_step_code: str = "NO_ACTION_NEEDED" recommended_next_step_code: str = "NO_ACTION_NEEDED"
# Real values the frontend must echo back on /api/confirm-visit so the
# recompute uses actual data instead of fabricated defaults.
last_shipment_date: Optional[str] = None
quantity: int = 1
csv_visit_date: Optional[str] = None
csv_swo_status: Optional[str] = None
csv_pecos_verified: Optional[str] = None
csv_pa_status: Optional[str] = None
csv_diagnosis_on_file: Optional[str] = None
csv_transfer_from: Optional[str] = None
class UploadResponse(BaseModel): class UploadResponse(BaseModel):
@ -229,7 +239,9 @@ def _normalize_payer_type(payer: str) -> str:
return normalized return normalized
def _recommended_next_step_code(flag_val: str, doc: Optional[DocStateOut]) -> str: def _recommended_next_step_code(
flag_val: str, doc: Optional[DocStateOut], cascade: Optional[list] = None
) -> str:
if doc and doc.pa == "Denied": if doc and doc.pa == "Denied":
return "PA_APPEAL" return "PA_APPEAL"
if doc and doc.pa == "Required — Not Started": if doc and doc.pa == "Required — Not Started":
@ -238,16 +250,18 @@ def _recommended_next_step_code(flag_val: str, doc: Optional[DocStateOut]) -> st
return "AWAITING_VISIT_CONFIRMATION" return "AWAITING_VISIT_CONFIRMATION"
if doc and doc.swo in ("Pending", "Expired"): if doc and doc.swo in ("Pending", "Expired"):
return "REQUEST_SWO_UPDATE" return "REQUEST_SWO_UPDATE"
if doc and doc.diagnosis == "Missing":
return "OBTAIN_DIAGNOSIS"
if doc and doc.pecos == "Not Verified": if doc and doc.pecos == "Not Verified":
return "VERIFY_PRESCRIBER_PECOS" return "VERIFY_PRESCRIBER_PECOS"
if doc and doc.pa == "Pending": if doc and doc.pa == "Pending":
return "PA_FOLLOW_UP" return "PA_FOLLOW_UP"
if flag_val in ("RESUPPLY_READY", "ACTIVE") and (not doc or not doc.cascade if hasattr(doc, "cascade") else True): if flag_val in ("RESUPPLY_READY", "ACTIVE") and not cascade:
return "READY_TO_BILL" return "READY_TO_BILL"
return "NO_ACTION_NEEDED" return "NO_ACTION_NEEDED"
def _to_record_out(r, record=None) -> RecordOut: def _to_record_out(r, record=None, confirmed_visit_date=None) -> RecordOut:
flag_val = r.flag.value if hasattr(r.flag, "value") else str(r.flag) flag_val = r.flag.value if hasattr(r.flag, "value") else str(r.flag)
# Compute doc state if we have the original record # Compute doc state if we have the original record
@ -259,7 +273,7 @@ def _to_record_out(r, record=None) -> RecordOut:
payer_type=normalized_payer, payer_type=normalized_payer,
csv_swo_status=record.csv_swo_status, csv_swo_status=record.csv_swo_status,
csv_visit_date=record.csv_visit_date, csv_visit_date=record.csv_visit_date,
confirmed_visit_date=None, # already factored into r.next_visit_due_date confirmed_visit_date=confirmed_visit_date,
csv_pecos_verified=record.csv_pecos_verified, csv_pecos_verified=record.csv_pecos_verified,
csv_pa_status=record.csv_pa_status, csv_pa_status=record.csv_pa_status,
csv_diagnosis_on_file=record.csv_diagnosis_on_file, csv_diagnosis_on_file=record.csv_diagnosis_on_file,
@ -292,7 +306,15 @@ def _to_record_out(r, record=None) -> RecordOut:
cascade=cascade, cascade=cascade,
order_number=getattr(record, "order_number", None) if record else None, order_number=getattr(record, "order_number", None) if record else None,
hcpcs=getattr(record, "hcpcs", None) if record else None, hcpcs=getattr(record, "hcpcs", None) if record else None,
recommended_next_step_code=_recommended_next_step_code(flag_val, doc_state_out), recommended_next_step_code=_recommended_next_step_code(flag_val, doc_state_out, cascade),
last_shipment_date=r.last_shipment_date.isoformat() if r.last_shipment_date else None,
quantity=getattr(record, "quantity", 1) if record else 1,
csv_visit_date=record.csv_visit_date.isoformat() if record and record.csv_visit_date else None,
csv_swo_status=getattr(record, "csv_swo_status", None) if record else None,
csv_pecos_verified=getattr(record, "csv_pecos_verified", None) if record else None,
csv_pa_status=getattr(record, "csv_pa_status", None) if record else None,
csv_diagnosis_on_file=getattr(record, "csv_diagnosis_on_file", None) if record else None,
csv_transfer_from=getattr(record, "csv_transfer_from", None) if record else None,
) )
@ -376,7 +398,16 @@ async def upload_csv(
# Build a lookup from patient_id to original record for doc state computation # Build a lookup from patient_id to original record for doc state computation
record_lookup = {r.patient_id: r for r in records} 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] out = [
_to_record_out(
r,
record=record_lookup.get(r.patient_id),
confirmed_visit_date=confirmed_visits.get(
hashlib.sha256(r.patient_id.encode()).hexdigest()
),
)
for r in results
]
log_event(AuditAction.CSV_INGEST, file.filename or "unknown", "demo_user", log_event(AuditAction.CSV_INGEST, file.filename or "unknown", "demo_user",
"success", "0.0.0.0", detail=f"{len(out)} records scored") "success", "0.0.0.0", detail=f"{len(out)} records scored")
@ -485,6 +516,16 @@ class ConfirmVisitRequest(BaseModel):
device_type: str device_type: str
quantity: int = 1 quantity: int = 1
component: str = "sensor" component: str = "sensor"
# Doc status fields echoed from the original upload so the recompute
# preserves checklist state instead of regressing it to defaults
csv_visit_date: Optional[str] = None
csv_swo_status: Optional[str] = None
csv_pecos_verified: Optional[str] = None
csv_pa_status: Optional[str] = None
csv_diagnosis_on_file: Optional[str] = None
csv_transfer_from: Optional[str] = None
order_number: Optional[str] = None
hcpcs: Optional[str] = None
@app.post("/api/confirm-visit") @app.post("/api/confirm-visit")
@ -534,7 +575,14 @@ async def confirm_visit(
if not success: if not success:
raise HTTPException(status_code=503, detail="Failed to save confirmed visit.") raise HTTPException(status_code=503, detail="Failed to save confirmed visit.")
# Recompute coverage with confirmed visit date # Recompute coverage with confirmed visit date, preserving the doc status
# fields echoed from the original upload
csv_visit = None
if body.csv_visit_date:
try:
csv_visit = date_type.fromisoformat(body.csv_visit_date)
except ValueError:
csv_visit = None
record = ShipmentRecord( record = ShipmentRecord(
patient_id=body.patient_id, patient_id=body.patient_id,
device_type=body.device_type, device_type=body.device_type,
@ -542,6 +590,14 @@ async def confirm_visit(
quantity=body.quantity, quantity=body.quantity,
payer=body.payer, payer=body.payer,
component=body.component, component=body.component,
csv_visit_date=csv_visit,
csv_swo_status=body.csv_swo_status,
csv_pecos_verified=body.csv_pecos_verified,
csv_pa_status=body.csv_pa_status,
csv_diagnosis_on_file=body.csv_diagnosis_on_file,
csv_transfer_from=body.csv_transfer_from,
order_number=body.order_number,
hcpcs=body.hcpcs,
) )
result = calculate_coverage(record, confirmed_visit_date=confirmed) result = calculate_coverage(record, confirmed_visit_date=confirmed)
@ -554,4 +610,4 @@ async def confirm_visit(
detail=f"Visit confirmed {confirmed.isoformat()}", detail=f"Visit confirmed {confirmed.isoformat()}",
) )
return _to_record_out(result, record=record) return _to_record_out(result, record=record, confirmed_visit_date=confirmed)

View file

@ -117,30 +117,6 @@ DEVICE_MAP: dict[str, str] = {
"op5": "omnipod_5", "op5": "omnipod_5",
} }
PAYER_MAP: dict[str, str] = {
"medicare part b": "medicare",
"medicare part a": "medicare",
"medicare advantage": "medicare_advantage", # distinct from medicare FFS
"medicare": "medicare",
"cms": "medicare",
"medicaid": "medicaid",
"mcd": "medicaid",
"molina": "medicaid",
"centene": "medicaid",
"wellcare": "medicaid",
"bcbs": "commercial",
"blue cross": "commercial",
"blue shield": "commercial",
"aetna": "commercial",
"cigna": "commercial",
"unitedhealthcare": "commercial",
"united health": "commercial",
"uhc": "commercial",
"humana": "commercial",
"anthem": "commercial",
"united": "commercial",
}
DATE_FORMATS = [ DATE_FORMATS = [
"%Y-%m-%d", "%Y-%m-%d",
"%m/%d/%Y", "%m/%d/%Y",
@ -199,15 +175,6 @@ def _normalize_device(value: str) -> Optional[str]:
return None return None
def _normalize_payer(value: str) -> str:
key = _normalize_key(value)
# Longest-match first (payer_map keys are already ordered longest first for medicare)
for alias, canonical in PAYER_MAP.items():
if alias in key:
return canonical
return "commercial"
def normalize_csv(text: str) -> tuple[list[ShipmentRecord], list[str], dict]: def normalize_csv(text: str) -> tuple[list[ShipmentRecord], list[str], dict]:
""" """
Parse raw CSV text and return (records, skipped_reasons, mapping_summary). Parse raw CSV text and return (records, skipped_reasons, mapping_summary).
@ -276,7 +243,9 @@ def normalize_csv(text: str) -> tuple[list[ShipmentRecord], list[str], dict]:
except (ValueError, TypeError): except (ValueError, TypeError):
quantity = 1 quantity = 1
payer = _normalize_payer(mapped.get("payer", "")) # Raw payer string passes through untouched. coverage_calculator._normalize_payer
# is the single normalization point; the raw value survives for display.
payer = mapped.get("payer", "").strip()
component = (mapped.get("component", "sensor") or "sensor").lower().strip() component = (mapped.get("component", "sensor") or "sensor").lower().strip()
if component not in ("sensor", "transmitter", "pod"): if component not in ("sensor", "transmitter", "pod"):
component = "sensor" component = "sensor"

View file

@ -266,6 +266,43 @@ def calculate_coverage(
coverage_end = record.shipment_date + timedelta(days=total_wear_days) coverage_end = record.shipment_date + timedelta(days=total_wear_days)
days_until_end = (coverage_end - today).days days_until_end = (coverage_end - today).days
# Catch-all FIRST: patient shows no active-care signals and last shipment was
# 12+ months ago. Must precede the supply-lapsed check — a 12-month-stale record
# always reads as lapsed, so checking lapsed first made this flag unreachable.
# A confirmed or CSV visit date within the last 12 months overrides this check,
# treating the patient as still active even if the shipment record is old.
NO_RECENT_SHIPMENT_DAYS = 365
days_since_shipment = (today - record.shipment_date).days
confirmed_visit_within_12m = (
confirmed_visit_date is not None
and (today - confirmed_visit_date).days < NO_RECENT_SHIPMENT_DAYS
)
csv_visit_within_12m = (
record.csv_visit_date is not None
and (today - record.csv_visit_date).days < NO_RECENT_SHIPMENT_DAYS
)
if (
days_since_shipment >= NO_RECENT_SHIPMENT_DAYS
and not confirmed_visit_within_12m
and not csv_visit_within_12m
):
_, confidence = _resolve_visit_date(record, confirmed_visit_date)
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=None,
flag=CoverageFlag.NO_RECENT_SHIPMENT,
days_until_coverage_end=days_until_end,
days_until_visit_due=None,
priority_score=5, # lowest — drops to bottom of worklist sort
visit_date_confidence=confidence,
rule_version=RULE_VERSION,
)
# Supply lapsed — coverage cycle ended # Supply lapsed — coverage cycle ended
if days_until_end < 0: if days_until_end < 0:
visit_date, confidence = _resolve_visit_date(record, confirmed_visit_date) visit_date, confidence = _resolve_visit_date(record, confirmed_visit_date)
@ -305,31 +342,7 @@ def calculate_coverage(
if flag == CoverageFlag.ACTIVE and days_until_end <= refill_window_days: if flag == CoverageFlag.ACTIVE and days_until_end <= refill_window_days:
flag = CoverageFlag.RESUPPLY_READY flag = CoverageFlag.RESUPPLY_READY
# Catch-all: patient shows no active-care signals and last shipment was 12+ months ago. priority = _compute_priority(flag, days_until_visit)
# Only applies when no higher-priority flag has fired (flag is ACTIVE or RESUPPLY_READY).
# A confirmed visit date within the last 12 months overrides this check, treating the
# patient as still active even if the shipment record is old.
NO_RECENT_SHIPMENT_DAYS = 365
days_since_shipment = (today - record.shipment_date).days
confirmed_visit_within_12m = (
confirmed_visit_date is not None
and (today - confirmed_visit_date).days < NO_RECENT_SHIPMENT_DAYS
)
csv_visit_within_12m = (
record.csv_visit_date is not None
and (today - record.csv_visit_date).days < NO_RECENT_SHIPMENT_DAYS
)
if (
flag in (CoverageFlag.ACTIVE, CoverageFlag.RESUPPLY_READY)
and days_since_shipment >= NO_RECENT_SHIPMENT_DAYS
and not confirmed_visit_within_12m
and not csv_visit_within_12m
):
flag = CoverageFlag.NO_RECENT_SHIPMENT
days_until_visit = None
priority = 5 # lowest — drops to bottom of worklist sort
else:
priority = _compute_priority(flag, days_until_visit)
return CoverageResult( return CoverageResult(
patient_id=record.patient_id, patient_id=record.patient_id,

View file

@ -94,7 +94,7 @@ export default function Privacy() {
<p className="text-[14px] leading-[1.75] text-[var(--text-secondary,#5A7E7E)]"> <p className="text-[14px] leading-[1.75] text-[var(--text-secondary,#5A7E7E)]">
Your data is retained for the duration of your subscription plus 90 days. Upon written request, Your data is retained for the duration of your subscription plus 90 days. Upon written request,
all data associated with your account will be deleted within 10 business days, and we will all data associated with your account will be deleted within 10 business days, and we will
confirm deletion in writing. Audit logs are retained for 7 years for compliance purposes. confirm deletion in writing. Audit logs are retained for 6 years for compliance purposes.
</p> </p>
</section> </section>
@ -102,11 +102,15 @@ export default function Privacy() {
<section className="mb-8"> <section className="mb-8">
<h2 className="font-heading font-semibold text-[16px] text-[#0A3030] mb-3">HIPAA</h2> <h2 className="font-heading font-semibold text-[16px] text-[#0A3030] mb-3">HIPAA</h2>
<p className="text-[14px] leading-[1.75] text-[var(--text-secondary,#5A7E7E)]"> <p className="text-[14px] leading-[1.75] text-[var(--text-secondary,#5A7E7E)]">
Signal processes de-identified operational data. To the extent that any data processed by Signal Signal is designed so that the data it receives is not protected health information
could be considered protected health information under HIPAA, STTIL Solutions has executed a under the Health Insurance Portability and Accountability Act (HIPAA). The patient
Business Associate Agreement (BAA) with its cloud infrastructure providers and operates identifiers your staff upload are your internal account numbers, and the link between
appropriate administrative, physical, and technical safeguards. If you believe a BAA is required those identifiers and any real identity stays with your organization at all times.
for your engagement with Signal, please contact us before uploading any data. Because Signal's architecture is designed to avoid PHI ingestion, a Business Associate
Agreement (BAA) may not be required for your engagement. We encourage every customer
to consult their own compliance advisors. If your review concludes that a BAA is
required for your intended use, contact us before uploading any data and we will
review the data scope with you.
</p> </p>
</section> </section>

View file

@ -12,14 +12,17 @@ export default function ConfirmVisitModal({ record, onClose, onConfirmed, onSave
const today = new Date().toISOString().split("T")[0]; const today = new Date().toISOString().split("T")[0];
const minDate = "2020-01-01"; const minDate = "2020-01-01";
// Compute shipment_date from coverage_end_date and days_until_coverage_end // Real shipment date from the backend record. The legacy reconstruction from
// coverage_end_date is a fallback for records loaded before last_shipment_date
// existed; it never fabricates today's date.
function computeShipmentDate() { function computeShipmentDate() {
if (record.last_shipment_date) return record.last_shipment_date;
if (record.coverage_end_date && record.days_until_coverage_end !== undefined) { if (record.coverage_end_date && record.days_until_coverage_end !== undefined) {
const coverageEnd = new Date(record.coverage_end_date); const coverageEnd = new Date(record.coverage_end_date);
const shipmentMs = coverageEnd.getTime() - record.days_until_coverage_end * 86400000; const shipmentMs = coverageEnd.getTime() - record.days_until_coverage_end * 86400000;
return new Date(shipmentMs).toISOString().split("T")[0]; return new Date(shipmentMs).toISOString().split("T")[0];
} }
return today; return null;
} }
function handleValidateAndAdvance() { function handleValidateAndAdvance() {
@ -32,12 +35,16 @@ export default function ConfirmVisitModal({ record, onClose, onConfirmed, onSave
setError("Visit date cannot be in the future."); setError("Visit date cannot be in the future.");
return; return;
} }
// Validate: visit must be within 6 months before the order/shipment date // Validate: visit must be within 6 months (183 days, matching the backend)
// before the order/shipment date
const shipmentDateStr = computeShipmentDate(); const shipmentDateStr = computeShipmentDate();
if (!shipmentDateStr) {
setError("This record has no shipment date on file. Re-import the CSV and try again.");
return;
}
const shipmentDateObj = new Date(shipmentDateStr + "T00:00:00"); const shipmentDateObj = new Date(shipmentDateStr + "T00:00:00");
const visitDateObj = new Date(visitDate + "T00:00:00"); const visitDateObj = new Date(visitDate + "T00:00:00");
const sixMonthsBeforeShipment = new Date(shipmentDateObj); const sixMonthsBeforeShipment = new Date(shipmentDateObj.getTime() - 183 * 86400000);
sixMonthsBeforeShipment.setMonth(sixMonthsBeforeShipment.getMonth() - 6);
if (visitDateObj < sixMonthsBeforeShipment) { if (visitDateObj < sixMonthsBeforeShipment) {
setError("Visit date must be within 6 months of the order date."); setError("Visit date must be within 6 months of the order date.");
return; return;
@ -55,8 +62,18 @@ export default function ConfirmVisitModal({ record, onClose, onConfirmed, onSave
shipment_date: computeShipmentDate(), shipment_date: computeShipmentDate(),
payer: record.payer, payer: record.payer,
device_type: record.device_type, device_type: record.device_type,
quantity: 1, quantity: record.quantity || 1,
component: record.component || "sensor", component: record.component || "sensor",
// Echo doc status fields so the backend recompute preserves
// checklist state instead of regressing it to defaults
csv_visit_date: record.csv_visit_date || null,
csv_swo_status: record.csv_swo_status || null,
csv_pecos_verified: record.csv_pecos_verified || null,
csv_pa_status: record.csv_pa_status || null,
csv_diagnosis_on_file: record.csv_diagnosis_on_file || null,
csv_transfer_from: record.csv_transfer_from || null,
order_number: record.order_number || null,
hcpcs: record.hcpcs || null,
}, token); }, token);
onConfirmed(record.patient_id, updated); onConfirmed(record.patient_id, updated);
onSaveSuccess?.({ patient_id: record.patient_id }); onSaveSuccess?.({ patient_id: record.patient_id });

View file

@ -85,6 +85,17 @@ export function apiRecordToLocal(r) {
is_transfer: r.is_transfer || false, is_transfer: r.is_transfer || false,
doc_state: r.doc_state || null, doc_state: r.doc_state || null,
cascade: r.cascade || [], cascade: r.cascade || [],
order_number: r.order_number || null,
hcpcs: r.hcpcs || null,
recommended_next_step_code: r.recommended_next_step_code || "NO_ACTION_NEEDED",
last_shipment_date: r.last_shipment_date || null,
quantity: r.quantity || 1,
csv_visit_date: r.csv_visit_date || null,
csv_swo_status: r.csv_swo_status || null,
csv_pecos_verified: r.csv_pecos_verified || null,
csv_pa_status: r.csv_pa_status || null,
csv_diagnosis_on_file: r.csv_diagnosis_on_file || null,
csv_transfer_from: r.csv_transfer_from || null,
}; };
} }

View file

@ -9,6 +9,7 @@
export const FLAG = { export const FLAG = {
SUPPLY_LAPSED: "SUPPLY_LAPSED", SUPPLY_LAPSED: "SUPPLY_LAPSED",
VISIT_REQUIRED: "VISIT_REQUIRED", VISIT_REQUIRED: "VISIT_REQUIRED",
TRANSFER_PENDING: "TRANSFER_PENDING",
RENEWAL_CRITICAL: "RENEWAL_CRITICAL", RENEWAL_CRITICAL: "RENEWAL_CRITICAL",
RENEWAL_ELEVATED: "RENEWAL_ELEVATED", RENEWAL_ELEVATED: "RENEWAL_ELEVATED",
RENEWAL_SOON: "RENEWAL_SOON", RENEWAL_SOON: "RENEWAL_SOON",
@ -17,20 +18,23 @@ export const FLAG = {
NO_RECENT_SHIPMENT: "NO_RECENT_SHIPMENT", NO_RECENT_SHIPMENT: "NO_RECENT_SHIPMENT",
}; };
// Mirrors FLAG_LABELS in python-backend/api/main.py — keep in sync
const FLAG_LABELS = { const FLAG_LABELS = {
[FLAG.SUPPLY_LAPSED]: "Supply Lapsed", [FLAG.SUPPLY_LAPSED]: "Docs Required",
[FLAG.VISIT_REQUIRED]: "Visit Required", [FLAG.VISIT_REQUIRED]: "Escalate",
[FLAG.RENEWAL_CRITICAL]: "Renewal Due", [FLAG.TRANSFER_PENDING]: "New Patient",
[FLAG.RENEWAL_ELEVATED]: "Renewal Elevated", [FLAG.RENEWAL_CRITICAL]: "Escalate",
[FLAG.RENEWAL_SOON]: "Renewal Soon", [FLAG.RENEWAL_ELEVATED]: "Confirm Appointment",
[FLAG.RESUPPLY_READY]: "Resupply Ready", [FLAG.RENEWAL_SOON]: "Begin Outreach",
[FLAG.ACTIVE]: "Active", [FLAG.RESUPPLY_READY]: "Clear to Ship",
[FLAG.ACTIVE]: "On Track",
[FLAG.NO_RECENT_SHIPMENT]: "No Recent Shipment", [FLAG.NO_RECENT_SHIPMENT]: "No Recent Shipment",
}; };
const FLAG_ACTIONS = { const FLAG_ACTIONS = {
[FLAG.SUPPLY_LAPSED]: "Contact Prescriber", [FLAG.SUPPLY_LAPSED]: "Contact Prescriber",
[FLAG.VISIT_REQUIRED]: "Schedule Visit", [FLAG.VISIT_REQUIRED]: "Schedule Visit",
[FLAG.TRANSFER_PENDING]: "Obtain full documentation set (SWO, PECOS, PA, diagnosis) — Signal cannot assess status until records are on file",
[FLAG.RENEWAL_CRITICAL]: "Request Renewal", [FLAG.RENEWAL_CRITICAL]: "Request Renewal",
[FLAG.RENEWAL_ELEVATED]: "Request Renewal", [FLAG.RENEWAL_ELEVATED]: "Request Renewal",
[FLAG.RENEWAL_SOON]: "Plan Renewal", [FLAG.RENEWAL_SOON]: "Plan Renewal",
@ -82,21 +86,24 @@ function getWearDays(deviceType, component) {
return device[component] ?? null; return device[component] ?? null;
} }
function getPayerConfig(payer) { // Mirrors payer_rules.json — every payer type currently uses a 180-day visit
const p = payer.toLowerCase(); // renewal and 30-day resupply window. Keep in sync with the backend config.
if (p.includes("medicare")) { function getPayerConfig() {
return { visitRenewalDays: 180, refillWindowDays: 30 }; return { visitRenewalDays: 180, refillWindowDays: 30 };
}
return { visitRenewalDays: null, refillWindowDays: 30 };
} }
function computePriority(flag, daysUntilEnd) { // Mirrors _compute_priority in python-backend/core/coverage_calculator.py — keep in sync
if (flag === FLAG.SUPPLY_LAPSED) return 1000 + Math.abs(daysUntilEnd); function computePriority(flag, daysUntilVisit) {
if (flag === FLAG.VISIT_REQUIRED) return 1500 + Math.abs(daysUntilEnd); const urgencyDays = daysUntilVisit !== null && daysUntilVisit !== undefined
if (flag === FLAG.RENEWAL_CRITICAL) return 500 + Math.max(0, 90 - daysUntilEnd); ? Math.abs(daysUntilVisit)
if (flag === FLAG.RENEWAL_ELEVATED) return 700 + Math.max(0, 90 - daysUntilEnd); : 0;
if (flag === FLAG.RENEWAL_SOON) return 400 + Math.max(0, 90 - daysUntilEnd); if (flag === FLAG.SUPPLY_LAPSED) return 2000 + urgencyDays;
if (flag === FLAG.RESUPPLY_READY) return 200 + Math.max(0, 30 - daysUntilEnd); if (flag === FLAG.TRANSFER_PENDING) return 1800;
if (flag === FLAG.VISIT_REQUIRED) return 1500 + urgencyDays;
if (flag === FLAG.RENEWAL_CRITICAL) return 1000 + (45 - Math.max(0, urgencyDays));
if (flag === FLAG.RENEWAL_ELEVATED) return 700 + (60 - Math.max(0, urgencyDays));
if (flag === FLAG.RENEWAL_SOON) return 400 + (90 - Math.max(0, urgencyDays));
if (flag === FLAG.RESUPPLY_READY) return 200;
if (flag === FLAG.NO_RECENT_SHIPMENT) return 5; if (flag === FLAG.NO_RECENT_SHIPMENT) return 5;
return 0; return 0;
} }
@ -107,6 +114,19 @@ function computePriority(flag, daysUntilEnd) {
* @returns {{ flag, daysUntilEnd, daysUntilVisit, priority }} or null on error * @returns {{ flag, daysUntilEnd, daysUntilVisit, priority }} or null on error
*/ */
export function calculateCoverage(record) { export function calculateCoverage(record) {
// Transferred patients: all docs need verification before anything else
const transferFrom = record.transfer_from || record.csv_transfer_from || record.previous_supplier;
if (transferFrom && String(transferFrom).trim()) {
return {
...record,
flag: FLAG.TRANSFER_PENDING,
daysUntilEnd: 0,
daysUntilVisit: null,
priority: computePriority(FLAG.TRANSFER_PENDING, null),
coverageEndDate: record.shipment_date,
};
}
const wearDays = getWearDays(record.device_type, record.component); const wearDays = getWearDays(record.device_type, record.component);
if (wearDays === null) return null; if (wearDays === null) return null;
@ -115,11 +135,38 @@ export function calculateCoverage(record) {
const coverageEnd = addDays(record.shipment_date, totalWear); const coverageEnd = addDays(record.shipment_date, totalWear);
const daysUntilEnd = daysDiff(coverageEnd, now); const daysUntilEnd = daysDiff(coverageEnd, now);
const payerCfg = getPayerConfig(record.payer); const payerCfg = getPayerConfig();
// Visit date resolution mirrors the backend: explicit visit date column when
// present, otherwise estimated as shipment date minus 30 days.
const rawVisitDate = record.visit_date || record.csv_visit_date || record.qualifying_visit_date;
const visitDate = rawVisitDate
? new Date(rawVisitDate + "T00:00:00")
: addDays(record.shipment_date, -30);
const visitDateValid = !isNaN(visitDate.getTime());
// Stale-record check FIRST — must precede supply-lapsed, since a 12-month-old
// shipment always reads as lapsed. A visit date within 12 months overrides.
const NO_RECENT_SHIPMENT_DAYS = 365;
const shipmentDate = new Date(record.shipment_date + "T00:00:00");
const daysSinceShipment = daysDiff(now, shipmentDate);
const visitWithin12m =
Boolean(rawVisitDate) && visitDateValid &&
daysDiff(now, visitDate) < NO_RECENT_SHIPMENT_DAYS;
if (daysSinceShipment >= NO_RECENT_SHIPMENT_DAYS && !visitWithin12m) {
return {
...record,
flag: FLAG.NO_RECENT_SHIPMENT,
daysUntilEnd,
daysUntilVisit: null,
priority: computePriority(FLAG.NO_RECENT_SHIPMENT, null),
coverageEndDate: coverageEnd.toISOString().slice(0, 10),
};
}
let daysUntilVisit = null; let daysUntilVisit = null;
if (payerCfg.visitRenewalDays) { if (payerCfg.visitRenewalDays && visitDateValid) {
const visitDue = addDays(record.shipment_date, payerCfg.visitRenewalDays); const visitDue = new Date(visitDate.getTime() + payerCfg.visitRenewalDays * 86400000);
daysUntilVisit = daysDiff(visitDue, now); daysUntilVisit = daysDiff(visitDue, now);
} }
@ -140,25 +187,12 @@ export function calculateCoverage(record) {
flag = FLAG.ACTIVE; flag = FLAG.ACTIVE;
} }
// Catch-all: last shipment was 12+ months ago and no higher-priority flag fired.
// Only overrides ACTIVE or RESUPPLY_READY — all urgent flags take precedence.
const NO_RECENT_SHIPMENT_DAYS = 365;
const shipmentDate = new Date(record.shipment_date + "T00:00:00");
const daysSinceShipment = daysDiff(now, shipmentDate);
if (
(flag === FLAG.ACTIVE || flag === FLAG.RESUPPLY_READY) &&
daysSinceShipment >= NO_RECENT_SHIPMENT_DAYS
) {
flag = FLAG.NO_RECENT_SHIPMENT;
daysUntilVisit = null;
}
return { return {
...record, ...record,
flag, flag,
daysUntilEnd, daysUntilEnd,
daysUntilVisit, daysUntilVisit,
priority: computePriority(flag, daysUntilEnd), priority: computePriority(flag, daysUntilVisit),
coverageEndDate: coverageEnd.toISOString().slice(0, 10), coverageEndDate: coverageEnd.toISOString().slice(0, 10),
}; };
} }