From cc2a5f4b472a0eb153bdf6b31168ff6200363105 Mon Sep 17 00:00:00 2001 From: Kisa Date: Fri, 12 Jun 2026 13:04:23 -0400 Subject: [PATCH] coverage calculator and UI refinements; add demo MVP gap analysis and quality review docs Co-Authored-By: Claude Sonnet 4.6 --- docs/compliance/data-handling.md | 2 +- .../2026-06-11-demo-mvp-gap-analysis.md | 46 ++++++++ docs/reviews/2026-06-11-quality-review.md | 57 +++++++++ python-backend/api/main.py | 74 ++++++++++-- python-backend/api/normalizer.py | 37 +----- python-backend/core/coverage_calculator.py | 63 ++++++---- signal-ui/src/Privacy.jsx | 16 ++- .../src/components/ConfirmVisitModal.jsx | 29 ++++- signal-ui/src/lib/api.js | 11 ++ signal-ui/src/lib/coverage.js | 108 ++++++++++++------ 10 files changed, 325 insertions(+), 118 deletions(-) create mode 100644 docs/reviews/2026-06-11-demo-mvp-gap-analysis.md create mode 100644 docs/reviews/2026-06-11-quality-review.md diff --git a/docs/compliance/data-handling.md b/docs/compliance/data-handling.md index b39b763..a7396a1 100644 --- a/docs/compliance/data-handling.md +++ b/docs/compliance/data-handling.md @@ -103,7 +103,7 @@ Uploaded CSV files are: | Upload batch metadata | Duration of subscription + 90 days | | Scored patient records (normalized_records) | 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 | 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. diff --git a/docs/reviews/2026-06-11-demo-mvp-gap-analysis.md b/docs/reviews/2026-06-11-demo-mvp-gap-analysis.md new file mode 100644 index 0000000..7b5483c --- /dev/null +++ b/docs/reviews/2026-06-11-demo-mvp-gap-analysis.md @@ -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 > shipment−30d 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. diff --git a/docs/reviews/2026-06-11-quality-review.md b/docs/reviews/2026-06-11-quality-review.md new file mode 100644 index 0000000..e1cf5e9 --- /dev/null +++ b/docs/reviews/2026-06-11-quality-review.md @@ -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 (shipment−30)+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. diff --git a/python-backend/api/main.py b/python-backend/api/main.py index 00777bd..5cb5a00 100644 --- a/python-backend/api/main.py +++ b/python-backend/api/main.py @@ -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_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", - "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", "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 hcpcs: Optional[str] = None 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): @@ -229,7 +239,9 @@ def _normalize_payer_type(payer: str) -> str: 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": return "PA_APPEAL" 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" if doc and doc.swo in ("Pending", "Expired"): return "REQUEST_SWO_UPDATE" + if doc and doc.diagnosis == "Missing": + return "OBTAIN_DIAGNOSIS" if doc and doc.pecos == "Not Verified": return "VERIFY_PRESCRIBER_PECOS" if doc and doc.pa == "Pending": 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 "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) # 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, 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 + confirmed_visit_date=confirmed_visit_date, csv_pecos_verified=record.csv_pecos_verified, csv_pa_status=record.csv_pa_status, csv_diagnosis_on_file=record.csv_diagnosis_on_file, @@ -292,7 +306,15 @@ def _to_record_out(r, record=None) -> RecordOut: cascade=cascade, order_number=getattr(record, "order_number", 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 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", "success", "0.0.0.0", detail=f"{len(out)} records scored") @@ -485,6 +516,16 @@ class ConfirmVisitRequest(BaseModel): device_type: str quantity: int = 1 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") @@ -534,7 +575,14 @@ async def confirm_visit( if not success: 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( patient_id=body.patient_id, device_type=body.device_type, @@ -542,6 +590,14 @@ async def confirm_visit( quantity=body.quantity, payer=body.payer, 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) @@ -554,4 +610,4 @@ async def confirm_visit( detail=f"Visit confirmed {confirmed.isoformat()}", ) - return _to_record_out(result, record=record) + return _to_record_out(result, record=record, confirmed_visit_date=confirmed) diff --git a/python-backend/api/normalizer.py b/python-backend/api/normalizer.py index 467cd25..380643f 100644 --- a/python-backend/api/normalizer.py +++ b/python-backend/api/normalizer.py @@ -117,30 +117,6 @@ DEVICE_MAP: dict[str, str] = { "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 = [ "%Y-%m-%d", "%m/%d/%Y", @@ -199,15 +175,6 @@ def _normalize_device(value: str) -> Optional[str]: 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]: """ 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): 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() if component not in ("sensor", "transmitter", "pod"): component = "sensor" diff --git a/python-backend/core/coverage_calculator.py b/python-backend/core/coverage_calculator.py index 58920cb..8387068 100644 --- a/python-backend/core/coverage_calculator.py +++ b/python-backend/core/coverage_calculator.py @@ -266,6 +266,43 @@ def calculate_coverage( coverage_end = record.shipment_date + timedelta(days=total_wear_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 if days_until_end < 0: 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: flag = CoverageFlag.RESUPPLY_READY - # Catch-all: patient shows no active-care signals and last shipment was 12+ months ago. - # 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) + priority = _compute_priority(flag, days_until_visit) return CoverageResult( patient_id=record.patient_id, diff --git a/signal-ui/src/Privacy.jsx b/signal-ui/src/Privacy.jsx index d814971..665f9b2 100644 --- a/signal-ui/src/Privacy.jsx +++ b/signal-ui/src/Privacy.jsx @@ -94,7 +94,7 @@ export default function Privacy() {

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 - 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.

@@ -102,11 +102,15 @@ export default function Privacy() {

HIPAA

- Signal processes de-identified operational data. To the extent that any data processed by Signal - could be considered protected health information under HIPAA, STTIL Solutions has executed a - Business Associate Agreement (BAA) with its cloud infrastructure providers and operates - appropriate administrative, physical, and technical safeguards. If you believe a BAA is required - for your engagement with Signal, please contact us before uploading any data. + Signal is designed so that the data it receives is not protected health information + under the Health Insurance Portability and Accountability Act (HIPAA). The patient + identifiers your staff upload are your internal account numbers, and the link between + those identifiers and any real identity stays with your organization at all times. + 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.

diff --git a/signal-ui/src/components/ConfirmVisitModal.jsx b/signal-ui/src/components/ConfirmVisitModal.jsx index 81c3d6b..27e23db 100644 --- a/signal-ui/src/components/ConfirmVisitModal.jsx +++ b/signal-ui/src/components/ConfirmVisitModal.jsx @@ -12,14 +12,17 @@ export default function ConfirmVisitModal({ record, onClose, onConfirmed, onSave const today = new Date().toISOString().split("T")[0]; 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() { + if (record.last_shipment_date) return record.last_shipment_date; if (record.coverage_end_date && record.days_until_coverage_end !== undefined) { const coverageEnd = new Date(record.coverage_end_date); const shipmentMs = coverageEnd.getTime() - record.days_until_coverage_end * 86400000; return new Date(shipmentMs).toISOString().split("T")[0]; } - return today; + return null; } function handleValidateAndAdvance() { @@ -32,12 +35,16 @@ export default function ConfirmVisitModal({ record, onClose, onConfirmed, onSave setError("Visit date cannot be in the future."); 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(); + 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 visitDateObj = new Date(visitDate + "T00:00:00"); - const sixMonthsBeforeShipment = new Date(shipmentDateObj); - sixMonthsBeforeShipment.setMonth(sixMonthsBeforeShipment.getMonth() - 6); + const sixMonthsBeforeShipment = new Date(shipmentDateObj.getTime() - 183 * 86400000); if (visitDateObj < sixMonthsBeforeShipment) { setError("Visit date must be within 6 months of the order date."); return; @@ -55,8 +62,18 @@ export default function ConfirmVisitModal({ record, onClose, onConfirmed, onSave shipment_date: computeShipmentDate(), payer: record.payer, device_type: record.device_type, - quantity: 1, + quantity: record.quantity || 1, 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); onConfirmed(record.patient_id, updated); onSaveSuccess?.({ patient_id: record.patient_id }); diff --git a/signal-ui/src/lib/api.js b/signal-ui/src/lib/api.js index 8bdfc69..e6c2fc5 100644 --- a/signal-ui/src/lib/api.js +++ b/signal-ui/src/lib/api.js @@ -85,6 +85,17 @@ export function apiRecordToLocal(r) { is_transfer: r.is_transfer || false, doc_state: r.doc_state || null, 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, }; } diff --git a/signal-ui/src/lib/coverage.js b/signal-ui/src/lib/coverage.js index 882d034..492e8e8 100644 --- a/signal-ui/src/lib/coverage.js +++ b/signal-ui/src/lib/coverage.js @@ -9,6 +9,7 @@ export const FLAG = { SUPPLY_LAPSED: "SUPPLY_LAPSED", VISIT_REQUIRED: "VISIT_REQUIRED", + TRANSFER_PENDING: "TRANSFER_PENDING", RENEWAL_CRITICAL: "RENEWAL_CRITICAL", RENEWAL_ELEVATED: "RENEWAL_ELEVATED", RENEWAL_SOON: "RENEWAL_SOON", @@ -17,20 +18,23 @@ export const FLAG = { NO_RECENT_SHIPMENT: "NO_RECENT_SHIPMENT", }; +// Mirrors FLAG_LABELS in python-backend/api/main.py — keep in sync const FLAG_LABELS = { - [FLAG.SUPPLY_LAPSED]: "Supply Lapsed", - [FLAG.VISIT_REQUIRED]: "Visit Required", - [FLAG.RENEWAL_CRITICAL]: "Renewal Due", - [FLAG.RENEWAL_ELEVATED]: "Renewal Elevated", - [FLAG.RENEWAL_SOON]: "Renewal Soon", - [FLAG.RESUPPLY_READY]: "Resupply Ready", - [FLAG.ACTIVE]: "Active", + [FLAG.SUPPLY_LAPSED]: "Docs Required", + [FLAG.VISIT_REQUIRED]: "Escalate", + [FLAG.TRANSFER_PENDING]: "New Patient", + [FLAG.RENEWAL_CRITICAL]: "Escalate", + [FLAG.RENEWAL_ELEVATED]: "Confirm Appointment", + [FLAG.RENEWAL_SOON]: "Begin Outreach", + [FLAG.RESUPPLY_READY]: "Clear to Ship", + [FLAG.ACTIVE]: "On Track", [FLAG.NO_RECENT_SHIPMENT]: "No Recent Shipment", }; const FLAG_ACTIONS = { [FLAG.SUPPLY_LAPSED]: "Contact Prescriber", [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_ELEVATED]: "Request Renewal", [FLAG.RENEWAL_SOON]: "Plan Renewal", @@ -82,21 +86,24 @@ function getWearDays(deviceType, component) { return device[component] ?? null; } -function getPayerConfig(payer) { - const p = payer.toLowerCase(); - if (p.includes("medicare")) { - return { visitRenewalDays: 180, refillWindowDays: 30 }; - } - return { visitRenewalDays: null, refillWindowDays: 30 }; +// Mirrors payer_rules.json — every payer type currently uses a 180-day visit +// renewal and 30-day resupply window. Keep in sync with the backend config. +function getPayerConfig() { + return { visitRenewalDays: 180, refillWindowDays: 30 }; } -function computePriority(flag, daysUntilEnd) { - if (flag === FLAG.SUPPLY_LAPSED) return 1000 + Math.abs(daysUntilEnd); - if (flag === FLAG.VISIT_REQUIRED) return 1500 + Math.abs(daysUntilEnd); - if (flag === FLAG.RENEWAL_CRITICAL) return 500 + Math.max(0, 90 - daysUntilEnd); - if (flag === FLAG.RENEWAL_ELEVATED) return 700 + Math.max(0, 90 - daysUntilEnd); - if (flag === FLAG.RENEWAL_SOON) return 400 + Math.max(0, 90 - daysUntilEnd); - if (flag === FLAG.RESUPPLY_READY) return 200 + Math.max(0, 30 - daysUntilEnd); +// Mirrors _compute_priority in python-backend/core/coverage_calculator.py — keep in sync +function computePriority(flag, daysUntilVisit) { + const urgencyDays = daysUntilVisit !== null && daysUntilVisit !== undefined + ? Math.abs(daysUntilVisit) + : 0; + if (flag === FLAG.SUPPLY_LAPSED) return 2000 + urgencyDays; + 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; return 0; } @@ -107,6 +114,19 @@ function computePriority(flag, daysUntilEnd) { * @returns {{ flag, daysUntilEnd, daysUntilVisit, priority }} or null on error */ 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); if (wearDays === null) return null; @@ -115,11 +135,38 @@ export function calculateCoverage(record) { const coverageEnd = addDays(record.shipment_date, totalWear); 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; - if (payerCfg.visitRenewalDays) { - const visitDue = addDays(record.shipment_date, payerCfg.visitRenewalDays); + if (payerCfg.visitRenewalDays && visitDateValid) { + const visitDue = new Date(visitDate.getTime() + payerCfg.visitRenewalDays * 86400000); daysUntilVisit = daysDiff(visitDue, now); } @@ -140,25 +187,12 @@ export function calculateCoverage(record) { 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 { ...record, flag, daysUntilEnd, daysUntilVisit, - priority: computePriority(flag, daysUntilEnd), + priority: computePriority(flag, daysUntilVisit), coverageEndDate: coverageEnd.toISOString().slice(0, 10), }; }