add pitch assets, fix UI and backend bugs, add test utilities
- add 3 signal pitch assets (workflow flowchart, market opportunity, win-win-win) as HTML + PNG - fix doc_state_machine cascade and supplied sentinel flags - fix CSVImport mapping review, WorklistTable colored bullets and expand row - fix App.jsx auth gate, api.js CORS handling - add signal E2E test script and stress test generator Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
228ca98423
commit
42a79c6682
14 changed files with 1550 additions and 63 deletions
238
pitch/assets/signal-market-opportunity.html
Normal file
238
pitch/assets/signal-market-opportunity.html
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Signal — Market Opportunity</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||||
|
background: #F0EAE1;
|
||||||
|
width: 1080px; height: 1080px;
|
||||||
|
overflow: hidden; position: relative;
|
||||||
|
}
|
||||||
|
.top-bar { position: absolute; top: 0; left: 0; right: 0; height: 5px; background: #2EA3A3; }
|
||||||
|
.signal-brand { position: absolute; top: 28px; right: 80px; text-align: right; z-index: 3; }
|
||||||
|
.brand-name { font-size: 18px; font-weight: 800; color: #1A5050; letter-spacing: 0.02em; }
|
||||||
|
.brand-sub { font-size: 9px; font-weight: 500; color: #8AABAB; letter-spacing: 0.12em; text-transform: uppercase; margin-top: 3px; }
|
||||||
|
.rings { position: absolute; top: 0; right: 0; pointer-events: none; }
|
||||||
|
.dots { position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none; opacity: 0.16; }
|
||||||
|
.footer { position: absolute; bottom: 22px; left: 70px; right: 70px; display: flex; justify-content: space-between; }
|
||||||
|
.footer span { font-size: 10px; color: #8AABAB; font-weight: 500; }
|
||||||
|
|
||||||
|
.content {
|
||||||
|
position: absolute;
|
||||||
|
top: 68px; left: 70px; right: 70px; bottom: 50px;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline { flex-shrink: 0; margin-bottom: 28px; }
|
||||||
|
.headline .eyebrow {
|
||||||
|
font-size: 11px; font-weight: 700; letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase; color: #CB6B20; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.headline h1 { font-size: 34px; font-weight: 800; color: #1A5050; letter-spacing: -0.02em; line-height: 1.1; }
|
||||||
|
.headline p { font-size: 14px; color: #5A7A7A; margin-top: 8px; font-weight: 500; }
|
||||||
|
|
||||||
|
/* Three big stat blocks */
|
||||||
|
.stat-row {
|
||||||
|
display: flex; gap: 16px; flex-shrink: 0; margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.stat-block {
|
||||||
|
flex: 1; padding: 20px 18px; border-radius: 10px;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
.stat-block.dark { background: #1A5050; }
|
||||||
|
.stat-block.mid { background: #E5EFEF; border: 1.5px solid #2EA3A3; }
|
||||||
|
.stat-block.warm { background: #F5EDE0; border: 1.5px solid #CB6B20; }
|
||||||
|
|
||||||
|
.stat-block .num {
|
||||||
|
font-size: 44px; font-weight: 800; line-height: 1; margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.stat-block.dark .num { color: #2EA3A3; }
|
||||||
|
.stat-block.mid .num { color: #1A5050; }
|
||||||
|
.stat-block.warm .num { color: #CB6B20; }
|
||||||
|
|
||||||
|
.stat-block .label {
|
||||||
|
font-size: 13px; font-weight: 600; line-height: 1.35;
|
||||||
|
}
|
||||||
|
.stat-block.dark .label { color: rgba(255,255,255,0.85); }
|
||||||
|
.stat-block.mid .label { color: #1A5050; }
|
||||||
|
.stat-block.warm .label { color: #6A3810; }
|
||||||
|
|
||||||
|
.stat-block .source {
|
||||||
|
font-size: 9.5px; font-weight: 500; margin-top: 8px; opacity: 0.6;
|
||||||
|
}
|
||||||
|
.stat-block.dark .source { color: #fff; }
|
||||||
|
.stat-block.mid .source { color: #1A5050; }
|
||||||
|
.stat-block.warm .source { color: #6A3810; }
|
||||||
|
|
||||||
|
/* The research paradox call-out */
|
||||||
|
.paradox {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #1A5050;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.paradox-number {
|
||||||
|
font-size: 52px; font-weight: 800; color: #2EA3A3;
|
||||||
|
line-height: 1; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.paradox-text { flex: 1; }
|
||||||
|
.paradox-text .p-headline {
|
||||||
|
font-size: 15px; font-weight: 700; color: #fff; line-height: 1.3; margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.paradox-text .p-sub {
|
||||||
|
font-size: 12px; color: rgba(255,255,255,0.7); font-weight: 500; line-height: 1.4;
|
||||||
|
}
|
||||||
|
.paradox-divider {
|
||||||
|
width: 1px; background: rgba(255,255,255,0.15); align-self: stretch; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.paradox-signal { flex-shrink: 0; text-align: center; padding-left: 8px; }
|
||||||
|
.paradox-signal .ps-label { font-size: 10px; font-weight: 600; color: rgba(255,255,255,0.5); letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 6px; }
|
||||||
|
.paradox-signal .ps-value { font-size: 28px; font-weight: 800; color: #CB6B20; line-height: 1; }
|
||||||
|
.paradox-signal .ps-sub { font-size: 11px; color: rgba(255,255,255,0.6); margin-top: 4px; font-weight: 500; }
|
||||||
|
|
||||||
|
/* Opportunity grid */
|
||||||
|
.opp-grid {
|
||||||
|
flex: none;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.opp-card {
|
||||||
|
background: #EAE4DB;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 18px 16px;
|
||||||
|
}
|
||||||
|
.opp-card .opp-accent {
|
||||||
|
height: 3px; border-radius: 2px; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.opp-card .opp-accent.teal { background: #2EA3A3; }
|
||||||
|
.opp-card .opp-accent.copper { background: #CB6B20; }
|
||||||
|
.opp-card .opp-accent.dark { background: #1A5050; }
|
||||||
|
.opp-card .opp-title { font-size: 13px; font-weight: 800; color: #1A5050; margin-bottom: 7px; line-height: 1.25; }
|
||||||
|
.opp-card .opp-desc { font-size: 11.5px; color: #5A6A6A; font-weight: 500; line-height: 1.45; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<svg class="rings" width="1080" height="1080" viewBox="0 0 1080 1080" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="920" cy="130" r="110" fill="none" stroke="#CB6B20" stroke-width="0.8" opacity="0.12"/>
|
||||||
|
<circle cx="920" cy="130" r="60" fill="none" stroke="#CB6B20" stroke-width="1.0" opacity="0.26"/>
|
||||||
|
<circle cx="920" cy="130" r="9" fill="#CB6B20" opacity="0.82"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<svg class="dots" width="1080" height="1080" viewBox="0 0 1080 1080" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="fade" cx="89%" cy="12%" r="70%">
|
||||||
|
<stop offset="0%" stop-color="#1A5050" stop-opacity="1"/>
|
||||||
|
<stop offset="100%" stop-color="#1A5050" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<pattern id="hex" x="0" y="0" width="24" height="20.8" patternUnits="userSpaceOnUse">
|
||||||
|
<circle cx="12" cy="4" r="1.4" fill="#1A5050"/>
|
||||||
|
<circle cx="0" cy="10.4" r="1.4" fill="#1A5050"/>
|
||||||
|
<circle cx="24" cy="10.4" r="1.4" fill="#1A5050"/>
|
||||||
|
<circle cx="12" cy="16.8" r="1.4" fill="#1A5050"/>
|
||||||
|
</pattern>
|
||||||
|
<mask id="rm"><rect width="1080" height="1080" fill="url(#fade)"/></mask>
|
||||||
|
</defs>
|
||||||
|
<rect width="1080" height="1080" fill="url(#hex)" mask="url(#rm)"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div class="signal-brand">
|
||||||
|
<div class="brand-name">Signal.</div>
|
||||||
|
<div class="brand-sub">Powered by STTIL Solutions</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
|
||||||
|
<div class="headline">
|
||||||
|
<div class="eyebrow">Market Opportunity</div>
|
||||||
|
<h1>A solvable problem hiding<br>in plain sight</h1>
|
||||||
|
<p>CGM documentation errors are predictable, traceable, and preventable — before the claim is ever filed.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Three big stats -->
|
||||||
|
<div class="stat-row">
|
||||||
|
<div class="stat-block dark">
|
||||||
|
<div class="num">1 in 3</div>
|
||||||
|
<div class="label">CGM claims contains a documentation error at the time of review</div>
|
||||||
|
<div class="source">CMS CERT Report 2019</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block mid">
|
||||||
|
<div class="num">68%</div>
|
||||||
|
<div class="label">Of those errors originate with the prescribing provider — not the supplier</div>
|
||||||
|
<div class="source">CMS CERT Report 2019</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-block warm">
|
||||||
|
<div class="num">~25%</div>
|
||||||
|
<div class="label">Of CGM supplies shipped when documentation fails to pass — at or below cost</div>
|
||||||
|
<div class="source">DMEPOS supplier operations data</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Research paradox -->
|
||||||
|
<div class="paradox">
|
||||||
|
<div class="paradox-number">$400M+</div>
|
||||||
|
<div class="paradox-text">
|
||||||
|
<div class="p-headline">The research paradox: suppliers already pay to document the problem Signal solves.</div>
|
||||||
|
<div class="p-sub">Staff time spent chasing records, filing appeals, and managing write-offs exceeds $400M annually across the DMEPOS supply chain. Signal redirects that spend from reactive to proactive.</div>
|
||||||
|
</div>
|
||||||
|
<div class="paradox-divider"></div>
|
||||||
|
<div class="paradox-signal">
|
||||||
|
<div class="ps-label">Signal cost</div>
|
||||||
|
<div class="ps-value">$1,788</div>
|
||||||
|
<div class="ps-sub">per supplier / year<br>Worklist tier</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Opportunity grid -->
|
||||||
|
<div class="opp-grid" style="margin-bottom:16px;">
|
||||||
|
<div class="opp-card">
|
||||||
|
<div class="opp-accent teal"></div>
|
||||||
|
<div class="opp-title">6,000+ DMEPOS suppliers</div>
|
||||||
|
<div class="opp-desc">All face the same documentation compliance burden under CMS LCD L33822 and payer-specific prior authorization requirements.</div>
|
||||||
|
</div>
|
||||||
|
<div class="opp-card">
|
||||||
|
<div class="opp-accent copper"></div>
|
||||||
|
<div class="opp-title">CGM is the beachhead</div>
|
||||||
|
<div class="opp-desc">High denial rate, defined LCD requirements, and a 6-month qualifying visit cycle create a repeating, automatable compliance event.</div>
|
||||||
|
</div>
|
||||||
|
<div class="opp-card">
|
||||||
|
<div class="opp-accent dark"></div>
|
||||||
|
<div class="opp-title">Every DMEPOS category follows</div>
|
||||||
|
<div class="opp-desc">Oxygen, wound care, power mobility — same documentation burden, same denial patterns. Signal's model extends to each.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom thesis bar -->
|
||||||
|
<div style="flex:1;display:flex;flex-direction:column;justify-content:flex-end;padding-bottom:8px;">
|
||||||
|
<div style="background:#2EA3A3;border-radius:10px;padding:18px 26px;display:flex;align-items:center;gap:24px;">
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div style="font-size:15px;font-weight:700;color:#fff;line-height:1.45;">No new infrastructure. No vendor integration. No change management. Suppliers upload one CSV and Signal surfaces every gap — before a single claim is filed.</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex-shrink:0;text-align:center;padding-left:20px;border-left:1px solid rgba(255,255,255,0.25);">
|
||||||
|
<div style="font-size:10px;font-weight:700;color:rgba(255,255,255,0.6);letter-spacing:0.1em;text-transform:uppercase;margin-bottom:5px;">Time to first value</div>
|
||||||
|
<div style="font-size:38px;font-weight:800;color:#fff;line-height:1;">Day 1</div>
|
||||||
|
<div style="font-size:10.5px;color:rgba(255,255,255,0.65);margin-top:3px;font-weight:500;">Upload a CSV.<br>See the gaps.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<span>signal.sttilsolutions.com</span>
|
||||||
|
<span>Sources: CMS CERT 2019 · DMEPOS operations data · © 2026 STTIL Solutions LLC</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
pitch/assets/signal-market-opportunity.png
Normal file
BIN
pitch/assets/signal-market-opportunity.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
267
pitch/assets/signal-win-win-win.html
Normal file
267
pitch/assets/signal-win-win-win.html
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Signal — Win-Win-Win</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||||
|
background: #F0EAE1;
|
||||||
|
width: 1080px; height: 1080px;
|
||||||
|
overflow: hidden; position: relative;
|
||||||
|
}
|
||||||
|
.top-bar { position: absolute; top: 0; left: 0; right: 0; height: 5px; background: #2EA3A3; }
|
||||||
|
.signal-brand { position: absolute; top: 28px; right: 80px; text-align: right; z-index: 3; }
|
||||||
|
.brand-name { font-size: 18px; font-weight: 800; color: #1A5050; letter-spacing: 0.02em; }
|
||||||
|
.brand-sub { font-size: 9px; font-weight: 500; color: #8AABAB; letter-spacing: 0.12em; text-transform: uppercase; margin-top: 3px; }
|
||||||
|
.rings { position: absolute; top: 0; right: 0; pointer-events: none; }
|
||||||
|
.dots { position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none; opacity: 0.14; }
|
||||||
|
.footer { position: absolute; bottom: 22px; left: 70px; right: 70px; display: flex; justify-content: space-between; }
|
||||||
|
.footer span { font-size: 10px; color: #8AABAB; font-weight: 500; }
|
||||||
|
|
||||||
|
.content {
|
||||||
|
position: absolute;
|
||||||
|
top: 64px; left: 60px; right: 60px; bottom: 50px;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline { text-align: center; flex-shrink: 0; margin-bottom: 24px; }
|
||||||
|
.headline .eyebrow { font-size: 11px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: #CB6B20; margin-bottom: 8px; }
|
||||||
|
.headline h1 { font-size: 32px; font-weight: 800; color: #1A5050; letter-spacing: -0.02em; }
|
||||||
|
.headline p { font-size: 13px; color: #5A7A7A; margin-top: 6px; font-weight: 500; }
|
||||||
|
|
||||||
|
/* Middle row: Suppliers | Signal | Payers */
|
||||||
|
.mid-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: stretch;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Signal center column */
|
||||||
|
.center-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 160px;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.connector-v {
|
||||||
|
width: 1.5px;
|
||||||
|
height: 24px;
|
||||||
|
background: linear-gradient(to bottom, rgba(46,163,163,0.3), rgba(46,163,163,0.6));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.medallion {
|
||||||
|
width: 110px; height: 110px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #1A5050;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
box-shadow: 0 0 0 6px #F0EAE1, 0 0 0 12px rgba(46,163,163,0.18), 0 0 0 20px rgba(46,163,163,0.07);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.medallion .sig { font-size: 22px; font-weight: 800; color: #fff; }
|
||||||
|
.medallion .sig-sub { font-size: 7.5px; font-weight: 600; color: #2EA3A3; letter-spacing: 0.1em; text-transform: uppercase; margin-top: 3px; text-align: center; line-height: 1.3; }
|
||||||
|
|
||||||
|
/* Horizontal connectors from medallion to cards */
|
||||||
|
.h-connector {
|
||||||
|
height: 1.5px;
|
||||||
|
flex: 1;
|
||||||
|
background: linear-gradient(to right, rgba(46,163,163,0.6), rgba(46,163,163,0.2));
|
||||||
|
}
|
||||||
|
.h-connector.right {
|
||||||
|
background: linear-gradient(to left, rgba(46,163,163,0.6), rgba(46,163,163,0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stakeholder cards */
|
||||||
|
.card {
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px 18px;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
.card.suppliers { background: #1A5050; }
|
||||||
|
.card.payers { background: #EAE4DB; border: 1.5px solid #B0A898; }
|
||||||
|
|
||||||
|
.card-label { font-size: 10px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: 8px; }
|
||||||
|
.card.suppliers .card-label { color: #2EA3A3; }
|
||||||
|
.card.payers .card-label { color: #8A7A6A; }
|
||||||
|
|
||||||
|
.card-title { font-size: 16px; font-weight: 800; margin-bottom: 12px; line-height: 1.2; }
|
||||||
|
.card.suppliers .card-title { color: #fff; }
|
||||||
|
.card.payers .card-title { color: #1A5050; }
|
||||||
|
|
||||||
|
.win-list { display: flex; flex-direction: column; gap: 7px; }
|
||||||
|
.win-item { display: flex; align-items: flex-start; gap: 8px; font-size: 11.5px; font-weight: 500; line-height: 1.35; }
|
||||||
|
.win-check { width: 15px; height: 15px; border-radius: 3px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 1px; font-size: 9px; font-weight: 800; }
|
||||||
|
.card.suppliers .win-check { background: #2EA3A3; color: #fff; }
|
||||||
|
.card.payers .win-check { background: #1A5050; color: #fff; }
|
||||||
|
.card.suppliers .win-item { color: rgba(255,255,255,0.85); }
|
||||||
|
.card.payers .win-item { color: #3A5050; }
|
||||||
|
|
||||||
|
/* Bottom patients card — full width */
|
||||||
|
.patients-row {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Down-connector from medallion area to patients */
|
||||||
|
.down-connector-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 0;
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.down-line {
|
||||||
|
width: 1.5px;
|
||||||
|
height: 20px;
|
||||||
|
background: linear-gradient(to bottom, rgba(203,107,32,0.6), rgba(203,107,32,0.3));
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.patients {
|
||||||
|
flex: none;
|
||||||
|
background: #F5EDE0;
|
||||||
|
border: 1.5px solid #CB6B20;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 18px 22px;
|
||||||
|
}
|
||||||
|
.card.patients .card-label { color: #CB6B20; }
|
||||||
|
.card.patients .card-title { color: #6A3810; font-size: 17px; }
|
||||||
|
|
||||||
|
.patients-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 40px; }
|
||||||
|
.patients-grid .win-check { background: #CB6B20; color: #fff; }
|
||||||
|
.patients-grid .win-item { color: #6A3810; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
<svg class="rings" width="1080" height="1080" viewBox="0 0 1080 1080" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="920" cy="130" r="110" fill="none" stroke="#CB6B20" stroke-width="0.8" opacity="0.12"/>
|
||||||
|
<circle cx="920" cy="130" r="60" fill="none" stroke="#CB6B20" stroke-width="1.0" opacity="0.26"/>
|
||||||
|
<circle cx="920" cy="130" r="9" fill="#CB6B20" opacity="0.82"/>
|
||||||
|
</svg>
|
||||||
|
<svg class="dots" width="1080" height="1080" viewBox="0 0 1080 1080" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="fade" cx="89%" cy="12%" r="70%">
|
||||||
|
<stop offset="0%" stop-color="#1A5050" stop-opacity="1"/>
|
||||||
|
<stop offset="100%" stop-color="#1A5050" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<pattern id="hex" x="0" y="0" width="24" height="20.8" patternUnits="userSpaceOnUse">
|
||||||
|
<circle cx="12" cy="4" r="1.4" fill="#1A5050"/>
|
||||||
|
<circle cx="0" cy="10.4" r="1.4" fill="#1A5050"/>
|
||||||
|
<circle cx="24" cy="10.4" r="1.4" fill="#1A5050"/>
|
||||||
|
<circle cx="12" cy="16.8" r="1.4" fill="#1A5050"/>
|
||||||
|
</pattern>
|
||||||
|
<mask id="rm"><rect width="1080" height="1080" fill="url(#fade)"/></mask>
|
||||||
|
</defs>
|
||||||
|
<rect width="1080" height="1080" fill="url(#hex)" mask="url(#rm)"/>
|
||||||
|
</svg>
|
||||||
|
<div class="signal-brand">
|
||||||
|
<div class="brand-name">Signal.</div>
|
||||||
|
<div class="brand-sub">Powered by STTIL Solutions</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
|
||||||
|
<div class="headline">
|
||||||
|
<div class="eyebrow">The Signal Alignment</div>
|
||||||
|
<h1>Everybody wins when documentation is right.</h1>
|
||||||
|
<p>Signal is the only layer built for all three parties in the CGM supply chain.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mid row: Suppliers | Signal medallion | Payers -->
|
||||||
|
<div class="mid-row">
|
||||||
|
<div class="card suppliers">
|
||||||
|
<div class="card-label">DMEPOS Suppliers</div>
|
||||||
|
<div class="card-title">Protect revenue.<br>Work proactively.</div>
|
||||||
|
<div class="win-list">
|
||||||
|
<div class="win-item"><div class="win-check">✓</div><span>Gaps caught before claim filing — no surprise denials</span></div>
|
||||||
|
<div class="win-item"><div class="win-check">✓</div><span>Staff time shifts from appeals to upstream outreach</span></div>
|
||||||
|
<div class="win-item"><div class="win-check">✓</div><span>Supply shipped with confidence, not hope</span></div>
|
||||||
|
<div class="win-item"><div class="win-check">✓</div><span>First-pass claim rate improves over time</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- H connector left -->
|
||||||
|
<div style="display:flex;align-items:center;flex-shrink:0;width:28px;">
|
||||||
|
<div class="h-connector"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="center-col">
|
||||||
|
<div class="medallion">
|
||||||
|
<div class="sig">Signal.</div>
|
||||||
|
<div class="sig-sub">Documentation<br>Readiness</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- H connector right -->
|
||||||
|
<div style="display:flex;align-items:center;flex-shrink:0;width:28px;">
|
||||||
|
<div class="h-connector right"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card payers">
|
||||||
|
<div class="card-label">Medicare & Payers</div>
|
||||||
|
<div class="card-title">Cleaner claims.<br>Lower admin load.</div>
|
||||||
|
<div class="win-list">
|
||||||
|
<div class="win-item"><div class="win-check">✓</div><span>Claims arrive with documentation already in order</span></div>
|
||||||
|
<div class="win-item"><div class="win-check">✓</div><span>Fewer appeals and resubmissions to process</span></div>
|
||||||
|
<div class="win-item"><div class="win-check">✓</div><span>Audit exposure reduced — records traceable upstream</span></div>
|
||||||
|
<div class="win-item"><div class="win-check">✓</div><span>PA affirmation rates trend toward CMS exemption threshold</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Down connector -->
|
||||||
|
<div style="display:flex;justify-content:center;flex-shrink:0;margin-bottom:12px;">
|
||||||
|
<div style="width:1.5px;height:18px;background:linear-gradient(to bottom, rgba(203,107,32,0.5), rgba(203,107,32,0.2));"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Patients card full width -->
|
||||||
|
<div class="card patients">
|
||||||
|
<div class="card-label">CGM Patients</div>
|
||||||
|
<div class="card-title" style="margin-bottom:14px;">Supply continuity. No gaps. No delays.</div>
|
||||||
|
<div class="patients-grid">
|
||||||
|
<div class="win-item"><div class="win-check" style="background:#CB6B20;color:#fff;width:15px;height:15px;border-radius:3px;font-size:9px;font-weight:800;display:flex;align-items:center;justify-content:center;flex-shrink:0;">✓</div><span style="color:#6A3810;font-size:11.5px;font-weight:500;line-height:1.35;">CGM resupply arrives on schedule</span></div>
|
||||||
|
<div class="win-item"><div class="win-check" style="background:#CB6B20;color:#fff;width:15px;height:15px;border-radius:3px;font-size:9px;font-weight:800;display:flex;align-items:center;justify-content:center;flex-shrink:0;">✓</div><span style="color:#6A3810;font-size:11.5px;font-weight:500;line-height:1.35;">No interruption in glucose monitoring</span></div>
|
||||||
|
<div class="win-item"><div class="win-check" style="background:#CB6B20;color:#fff;width:15px;height:15px;border-radius:3px;font-size:9px;font-weight:800;display:flex;align-items:center;justify-content:center;flex-shrink:0;">✓</div><span style="color:#6A3810;font-size:11.5px;font-weight:500;line-height:1.35;">Qualifying visits tracked proactively — 6-month cycle never missed</span></div>
|
||||||
|
<div class="win-item"><div class="win-check" style="background:#CB6B20;color:#fff;width:15px;height:15px;border-radius:3px;font-size:9px;font-weight:800;display:flex;align-items:center;justify-content:center;flex-shrink:0;">✓</div><span style="color:#6A3810;font-size:11.5px;font-weight:500;line-height:1.35;">Better health outcomes from uninterrupted CGM data</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom insight bar -->
|
||||||
|
<div style="flex:1;display:flex;flex-direction:column;justify-content:flex-end;padding-bottom:10px;">
|
||||||
|
<div style="background:#1A5050;border-radius:10px;padding:22px 28px;display:flex;align-items:center;gap:28px;">
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div style="font-size:11px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:#2EA3A3;margin-bottom:8px;">The bottom line</div>
|
||||||
|
<div style="font-size:16px;font-weight:700;color:#fff;line-height:1.4;">When a supplier resolves a documentation gap before filing, the payer receives a clean claim and the patient gets their supply on time. Signal makes that happen at scale — without adding staff, changing systems, or waiting for a denial.</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex-shrink:0;text-align:center;padding-left:12px;border-left:1px solid rgba(255,255,255,0.15);">
|
||||||
|
<div style="font-size:13px;font-weight:700;color:rgba(255,255,255,0.5);letter-spacing:0.08em;text-transform:uppercase;margin-bottom:6px;">Signal cost</div>
|
||||||
|
<div style="font-size:36px;font-weight:800;color:#CB6B20;line-height:1;">$1,788</div>
|
||||||
|
<div style="font-size:11px;color:rgba(255,255,255,0.55);margin-top:4px;font-weight:500;">per supplier / year<br>Worklist tier</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<span>signal.sttilsolutions.com</span>
|
||||||
|
<span>© 2026 STTIL Solutions LLC</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
pitch/assets/signal-win-win-win.png
Normal file
BIN
pitch/assets/signal-win-win-win.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
361
pitch/assets/signal-workflow-flowchart.html
Normal file
361
pitch/assets/signal-workflow-flowchart.html
Normal file
|
|
@ -0,0 +1,361 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Signal — How It Works</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||||
|
background: #F0EAE1;
|
||||||
|
width: 1080px;
|
||||||
|
height: 1080px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
height: 5px;
|
||||||
|
background: #2EA3A3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-brand {
|
||||||
|
position: absolute;
|
||||||
|
top: 28px; right: 80px;
|
||||||
|
text-align: right;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.brand-name { font-size: 18px; font-weight: 800; color: #1A5050; letter-spacing: 0.02em; }
|
||||||
|
.brand-sub { font-size: 9px; font-weight: 500; color: #8AABAB; letter-spacing: 0.12em; text-transform: uppercase; margin-top: 3px; }
|
||||||
|
|
||||||
|
.rings { position: absolute; top: 0; right: 0; pointer-events: none; }
|
||||||
|
.dots { position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none; opacity: 0.18; }
|
||||||
|
|
||||||
|
/* Main content area */
|
||||||
|
.content {
|
||||||
|
position: absolute;
|
||||||
|
top: 60px; left: 60px; right: 60px; bottom: 52px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headline {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.headline h1 { font-size: 30px; font-weight: 800; color: #1A5050; letter-spacing: -0.02em; }
|
||||||
|
.headline p { font-size: 13px; color: #5A7A7A; margin-top: 6px; font-weight: 500; }
|
||||||
|
|
||||||
|
/* Two-column wrapper — takes available height */
|
||||||
|
.columns {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-header {
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.col-header.without { background: #D8CFC4; color: #7A6A5A; }
|
||||||
|
.col-header.with { background: #1A5050; color: #fff; }
|
||||||
|
|
||||||
|
.flow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node {
|
||||||
|
width: 100%;
|
||||||
|
padding: 13px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node.w-step { background: #EAE3DA; color: #5A4E42; border: 1.5px solid #C8BFB4; }
|
||||||
|
.node.w-pain { background: #F5EDE0; color: #7A4A1A; border: 1.5px solid #D4A070; }
|
||||||
|
.node.w-outcome { background: #F5E8E0; color: #8A2A10; border: 1.5px solid #D07060; }
|
||||||
|
.node.s-step { background: #E5F0F0; color: #1A5050; border: 1.5px solid #2EA3A3; }
|
||||||
|
.node.s-check { background: #1A5050; color: #fff; border: none; }
|
||||||
|
.node.s-gap { background: #FDF0E0; color: #884010; border: 1.5px solid #CB6B20; }
|
||||||
|
.node.s-win { background: #2EA3A3; color: #fff; border: none; font-size: 13px; font-weight: 700; }
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.arrow-line { width: 2px; flex: 1; max-height: 28px; min-height: 8px; background: #A8B8B8; }
|
||||||
|
.arrow-line.red { background: #C06050; }
|
||||||
|
.arrow-line.teal { background: #2EA3A3; }
|
||||||
|
.arrow-head { width: 0; height: 0; border-left: 5px solid transparent; border-right: 5px solid transparent; border-top: 7px solid #A8B8B8; flex-shrink: 0; }
|
||||||
|
.arrow-head.red { border-top-color: #C06050; }
|
||||||
|
.arrow-head.teal { border-top-color: #2EA3A3; }
|
||||||
|
|
||||||
|
.check-items { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; }
|
||||||
|
.check-item {
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.check-item .dot { width: 6px; height: 6px; border-radius: 50%; background: #2EA3A3; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.pain-sub { font-size: 10.5px; font-weight: 500; margin-top: 4px; opacity: 0.8; }
|
||||||
|
|
||||||
|
.gap-items { display: flex; gap: 8px; margin-top: 6px; justify-content: center; }
|
||||||
|
.gap-tag { background: #CB6B20; color: #fff; border-radius: 4px; padding: 2px 7px; font-size: 10px; font-weight: 700; }
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
width: 1px;
|
||||||
|
background: linear-gradient(to bottom, transparent, #B0A898 20%, #B0A898 80%, transparent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.divider-label {
|
||||||
|
background: #F0EAE1;
|
||||||
|
color: #8A7A6A;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 6px 4px;
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats bar — outside columns, at bottom of content */
|
||||||
|
.stats-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.stat-cell {
|
||||||
|
flex: 1;
|
||||||
|
padding: 11px 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.stat-cell.neg { background: #DDD5C8; }
|
||||||
|
.stat-cell.pos { background: #1A5050; }
|
||||||
|
.stat-num {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
.stat-cell.neg .stat-num { color: #8A2A10; }
|
||||||
|
.stat-cell.pos .stat-num { color: #2EA3A3; }
|
||||||
|
.stat-lbl {
|
||||||
|
font-size: 9.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.stat-cell.neg .stat-lbl { color: #6A5A4A; }
|
||||||
|
.stat-cell.pos .stat-lbl { color: rgba(255,255,255,0.72); }
|
||||||
|
.stat-div { width: 1px; background: rgba(0,0,0,0.1); flex-shrink: 0; }
|
||||||
|
.stat-section-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.stat-section-label span {
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.stat-section-label.neg-label { background: #C8BFB2; }
|
||||||
|
.stat-section-label.neg-label span { color: #8A7A6A; }
|
||||||
|
.stat-section-label.pos-label { background: #163E3E; }
|
||||||
|
.stat-section-label.pos-label span { color: rgba(255,255,255,0.5); }
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 16px;
|
||||||
|
left: 60px; right: 60px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.footer-left { font-size: 10px; color: #8AABAB; font-weight: 500; }
|
||||||
|
.footer-right { font-size: 10px; color: #8AABAB; font-weight: 500; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="top-bar"></div>
|
||||||
|
|
||||||
|
<svg class="rings" width="1080" height="1080" viewBox="0 0 1080 1080" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="920" cy="130" r="110" fill="none" stroke="#CB6B20" stroke-width="0.8" opacity="0.12"/>
|
||||||
|
<circle cx="920" cy="130" r="60" fill="none" stroke="#CB6B20" stroke-width="1.0" opacity="0.26"/>
|
||||||
|
<circle cx="920" cy="130" r="9" fill="#CB6B20" opacity="0.82"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<svg class="dots" width="1080" height="1080" viewBox="0 0 1080 1080" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="fade" cx="89%" cy="12%" r="70%">
|
||||||
|
<stop offset="0%" stop-color="#1A5050" stop-opacity="1"/>
|
||||||
|
<stop offset="100%" stop-color="#1A5050" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<pattern id="hex" x="0" y="0" width="24" height="20.8" patternUnits="userSpaceOnUse">
|
||||||
|
<circle cx="12" cy="4" r="1.4" fill="#1A5050"/>
|
||||||
|
<circle cx="0" cy="10.4" r="1.4" fill="#1A5050"/>
|
||||||
|
<circle cx="24" cy="10.4" r="1.4" fill="#1A5050"/>
|
||||||
|
<circle cx="12" cy="16.8" r="1.4" fill="#1A5050"/>
|
||||||
|
</pattern>
|
||||||
|
<mask id="radmask">
|
||||||
|
<rect width="1080" height="1080" fill="url(#fade)"/>
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
<rect width="1080" height="1080" fill="url(#hex)" mask="url(#radmask)"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div class="signal-brand">
|
||||||
|
<div class="brand-name">Signal.</div>
|
||||||
|
<div class="brand-sub">Powered by STTIL Solutions</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
|
||||||
|
<div class="headline">
|
||||||
|
<h1>How Signal Works</h1>
|
||||||
|
<p>Catching documentation gaps before they become denied claims</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
|
||||||
|
<!-- WITHOUT SIGNAL -->
|
||||||
|
<div class="col">
|
||||||
|
<div class="col-header without">Without Signal</div>
|
||||||
|
<div class="flow">
|
||||||
|
<div class="node w-step">Patient flagged for CGM resupply</div>
|
||||||
|
<div class="arrow"><div class="arrow-line red"></div><div class="arrow-head red"></div></div>
|
||||||
|
<div class="node w-step">Staff manually tracks documentation across spreadsheets and faxes</div>
|
||||||
|
<div class="arrow"><div class="arrow-line red"></div><div class="arrow-head red"></div></div>
|
||||||
|
<div class="node w-step">Claim filed to payer</div>
|
||||||
|
<div class="arrow"><div class="arrow-line red"></div><div class="arrow-head red"></div></div>
|
||||||
|
<div class="node w-pain">
|
||||||
|
Denial: documentation incomplete
|
||||||
|
<div class="pain-sub">SWO missing · Qualifying visit overdue · PA not confirmed</div>
|
||||||
|
</div>
|
||||||
|
<div class="arrow"><div class="arrow-line red"></div><div class="arrow-head red"></div></div>
|
||||||
|
<div class="node w-step">Appeal filed — 6 to 8 week delay</div>
|
||||||
|
<div class="arrow"><div class="arrow-line red"></div><div class="arrow-head red"></div></div>
|
||||||
|
<div class="node w-outcome">Write-off or supply shipped at a loss</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"><div class="divider-label">vs</div></div>
|
||||||
|
|
||||||
|
<!-- WITH SIGNAL -->
|
||||||
|
<div class="col">
|
||||||
|
<div class="col-header with">With Signal</div>
|
||||||
|
<div class="flow">
|
||||||
|
<div class="node s-step">Patient flagged for CGM resupply</div>
|
||||||
|
<div class="arrow"><div class="arrow-line teal"></div><div class="arrow-head teal"></div></div>
|
||||||
|
<div class="node s-step">Staff uploads patient list CSV — Signal ingests in seconds</div>
|
||||||
|
<div class="arrow"><div class="arrow-line teal"></div><div class="arrow-head teal"></div></div>
|
||||||
|
<div class="node s-check">
|
||||||
|
Signal checks documentation status automatically
|
||||||
|
<div class="check-items">
|
||||||
|
<div class="check-item"><div class="dot"></div>Standard Written Order (SWO)</div>
|
||||||
|
<div class="check-item"><div class="dot"></div>Qualifying visit — within 6 months</div>
|
||||||
|
<div class="check-item"><div class="dot"></div>Prior Authorization status</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="arrow"><div class="arrow-line teal"></div><div class="arrow-head teal"></div></div>
|
||||||
|
<div class="node s-gap">
|
||||||
|
Gaps flagged on prioritized worklist — before any claim is filed
|
||||||
|
<div class="gap-items">
|
||||||
|
<div class="gap-tag">● SWO needed</div>
|
||||||
|
<div class="gap-tag">● Visit overdue</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="arrow"><div class="arrow-line teal"></div><div class="arrow-head teal"></div></div>
|
||||||
|
<div class="node s-step">Staff resolves gaps upstream — contacts prescriber, requests records</div>
|
||||||
|
<div class="arrow"><div class="arrow-line teal"></div><div class="arrow-head teal"></div></div>
|
||||||
|
<div class="node s-win">Clear to Ship — supply delivered on time</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats bar below both columns -->
|
||||||
|
<div class="stats-bar">
|
||||||
|
<div class="stat-section-label neg-label"><span>Problem</span></div>
|
||||||
|
<div class="stat-cell neg">
|
||||||
|
<div class="stat-num">1 in 3</div>
|
||||||
|
<div class="stat-lbl">CGM claims with a doc error<br><span style="font-size:8.5px;opacity:0.7">CERT 2019</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-div"></div>
|
||||||
|
<div class="stat-cell neg">
|
||||||
|
<div class="stat-num">~25%</div>
|
||||||
|
<div class="stat-lbl">Of CGM supplies shipped at a loss when docs fail</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-div"></div>
|
||||||
|
<div class="stat-cell neg">
|
||||||
|
<div class="stat-num">6–8 wks</div>
|
||||||
|
<div class="stat-lbl">Average denial recovery after appeal</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-section-label pos-label"><span>Signal</span></div>
|
||||||
|
<div class="stat-cell pos">
|
||||||
|
<div class="stat-num">Day 1</div>
|
||||||
|
<div class="stat-lbl">Gaps visible before claim is filed</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-div"></div>
|
||||||
|
<div class="stat-cell pos">
|
||||||
|
<div class="stat-num">$0</div>
|
||||||
|
<div class="stat-lbl">Claims filed with known documentation gaps</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<div class="footer-left">signal.sttilsolutions.com</div>
|
||||||
|
<div class="footer-right">Source: CERT 2019 · © 2026 STTIL Solutions LLC</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
pitch/assets/signal-workflow-flowchart.png
Normal file
BIN
pitch/assets/signal-workflow-flowchart.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
|
|
@ -34,8 +34,9 @@ from api.normalizer import normalize_csv
|
||||||
|
|
||||||
app = FastAPI(title="Signal API", version="1.0.0", docs_url="/docs")
|
app = FastAPI(title="Signal API", version="1.0.0", docs_url="/docs")
|
||||||
|
|
||||||
# CORS — locked to Vercel frontend and localhost for dev.
|
# CORS — set ALLOWED_ORIGINS_REGEX for a pattern (recommended for Vercel previews),
|
||||||
# Set ALLOWED_ORIGINS in Railway as a comma-separated list for production.
|
# or ALLOWED_ORIGINS as a comma-separated list for explicit control.
|
||||||
|
_origins_regex = os.getenv("ALLOWED_ORIGINS_REGEX", "")
|
||||||
_origins_env = os.getenv("ALLOWED_ORIGINS", "")
|
_origins_env = os.getenv("ALLOWED_ORIGINS", "")
|
||||||
_allowed_origins: list[str] = (
|
_allowed_origins: list[str] = (
|
||||||
[o.strip() for o in _origins_env.split(",") if o.strip()]
|
[o.strip() for o in _origins_env.split(",") if o.strip()]
|
||||||
|
|
@ -47,13 +48,17 @@ _allowed_origins: list[str] = (
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
_cors_kwargs: dict = {
|
||||||
CORSMiddleware,
|
"allow_credentials": True,
|
||||||
allow_origins=_allowed_origins,
|
"allow_methods": ["*"],
|
||||||
allow_credentials=True,
|
"allow_headers": ["*"],
|
||||||
allow_methods=["*"],
|
}
|
||||||
allow_headers=["*"],
|
if _origins_regex:
|
||||||
)
|
_cors_kwargs["allow_origin_regex"] = _origins_regex
|
||||||
|
else:
|
||||||
|
_cors_kwargs["allow_origins"] = _allowed_origins
|
||||||
|
|
||||||
|
app.add_middleware(CORSMiddleware, **_cors_kwargs)
|
||||||
|
|
||||||
# API key auth — enforced when SIGNAL_API_KEY env var is set.
|
# API key auth — enforced when SIGNAL_API_KEY env var is set.
|
||||||
# In dev (no env var), all requests pass. In production, X-API-Key header is required.
|
# In dev (no env var), all requests pass. In production, X-API-Key header is required.
|
||||||
|
|
|
||||||
|
|
@ -58,53 +58,55 @@ def _build_cascade(
|
||||||
pa: str,
|
pa: str,
|
||||||
diagnosis: str,
|
diagnosis: str,
|
||||||
payer_type: str,
|
payer_type: str,
|
||||||
|
swo_supplied: bool = True,
|
||||||
|
visit_supplied: bool = True,
|
||||||
|
diagnosis_supplied: bool = True,
|
||||||
|
pecos_supplied: bool = True,
|
||||||
|
pa_supplied: bool = True,
|
||||||
) -> list:
|
) -> list:
|
||||||
"""
|
"""
|
||||||
Build the full consequence chain for all documentation gaps.
|
Build the full consequence chain for all documentation gaps.
|
||||||
No early returns — every unfulfilled requirement is surfaced.
|
Only flags gaps for columns that were actually present in the CSV.
|
||||||
|
An absent column is "not evaluated", not a gap — green means no known blocker.
|
||||||
Type A = doc gaps Signal can help close.
|
Type A = doc gaps Signal can help close.
|
||||||
Type B = route-to-action alerts for denials.
|
Type B = route-to-action alerts for denials.
|
||||||
"""
|
"""
|
||||||
cascade = []
|
cascade = []
|
||||||
|
|
||||||
# Type A: Documentation gaps
|
# Type A: Documentation gaps — only fire when the column was supplied
|
||||||
if not visit_confirmed:
|
if visit_supplied and not visit_confirmed:
|
||||||
cascade.append(
|
cascade.append(
|
||||||
"Qualifying visit not on file — confirm with prescriber before scheduling shipment"
|
"Qualifying visit not on file — confirm with prescriber before scheduling shipment"
|
||||||
)
|
)
|
||||||
|
|
||||||
if swo in ("Pending", "Expired"):
|
if swo_supplied and swo in ("Pending", "Expired"):
|
||||||
label = "not on file" if swo == "Pending" else "expired"
|
label = "not on file" if swo == "Pending" else "expired"
|
||||||
cascade.append(
|
cascade.append(
|
||||||
f"Written order {label} — obtain from prescriber before claim submission"
|
f"Written order {label} — obtain from prescriber before claim submission"
|
||||||
)
|
)
|
||||||
|
|
||||||
if diagnosis == "Missing":
|
if diagnosis_supplied and diagnosis == "Missing":
|
||||||
cascade.append(
|
cascade.append(
|
||||||
"Diagnosis code not on file — obtain from prescriber records before billing"
|
"Diagnosis code not on file — obtain from prescriber records before billing"
|
||||||
)
|
)
|
||||||
|
|
||||||
if payer_type in _PECOS_REQUIRED and pecos == "Not Verified":
|
if pecos_supplied and payer_type in _PECOS_REQUIRED and pecos == "Not Verified":
|
||||||
cascade.append(
|
cascade.append(
|
||||||
"Ordering provider PECOS enrollment not verified — confirm NPI is active before claim submission"
|
"Ordering provider PECOS enrollment not verified — confirm NPI is active before claim submission"
|
||||||
)
|
)
|
||||||
|
|
||||||
if payer_type not in _PA_NOT_REQUIRED and pa == "Required — Not Started":
|
if pa_supplied and payer_type not in _PA_NOT_REQUIRED and pa == "Required — Not Started":
|
||||||
cascade.append(
|
cascade.append(
|
||||||
"Prior authorization not started — obtain before scheduling shipment"
|
"Prior authorization not started — obtain before scheduling shipment"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Type B: Route-to-action alert
|
# Type B: Route-to-action alert — PA denied is always surfaced (supplied-and-bad)
|
||||||
if pa == "Denied":
|
if pa == "Denied":
|
||||||
cascade.append(
|
cascade.append(
|
||||||
"Prior authorization denied — file Level 1 appeal within 65 days. "
|
"Prior authorization denied — file Level 1 appeal within 65 days. "
|
||||||
"Approximately 80% of first-level Medicare Advantage appeals succeed."
|
"Approximately 80% of first-level Medicare Advantage appeals succeed."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Consequence line — only appended when at least one gap exists
|
|
||||||
if cascade:
|
|
||||||
cascade.append("Device shipment at risk")
|
|
||||||
|
|
||||||
return cascade
|
return cascade
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -175,7 +177,20 @@ def compute_doc_state(
|
||||||
dx_val = (csv_diagnosis_on_file or "").strip().lower()
|
dx_val = (csv_diagnosis_on_file or "").strip().lower()
|
||||||
diagnosis = "On File" if dx_val in ("yes", "true", "on file", "1") else "Missing"
|
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)
|
swo_supplied = csv_swo_status is not None
|
||||||
|
visit_supplied = (csv_visit_date is not None) or (confirmed_visit_date is not None)
|
||||||
|
diagnosis_supplied = csv_diagnosis_on_file is not None
|
||||||
|
pecos_supplied = csv_pecos_verified is not None
|
||||||
|
pa_supplied = csv_pa_status is not None
|
||||||
|
|
||||||
|
cascade = _build_cascade(
|
||||||
|
swo, visit_confirmed, pecos, pa, diagnosis, payer_type,
|
||||||
|
swo_supplied=swo_supplied,
|
||||||
|
visit_supplied=visit_supplied,
|
||||||
|
diagnosis_supplied=diagnosis_supplied,
|
||||||
|
pecos_supplied=pecos_supplied,
|
||||||
|
pa_supplied=pa_supplied,
|
||||||
|
)
|
||||||
|
|
||||||
return DocState(
|
return DocState(
|
||||||
swo=swo,
|
swo=swo,
|
||||||
|
|
|
||||||
407
scripts/signal_e2e_test.py
Normal file
407
scripts/signal_e2e_test.py
Normal file
|
|
@ -0,0 +1,407 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Signal End-to-End Test Agent
|
||||||
|
Hits the LIVE backend at signal-api-production-91c2.up.railway.app.
|
||||||
|
Tests every functional layer and produces a plain-English findings report.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/signal_e2e_test.py
|
||||||
|
python3 scripts/signal_e2e_test.py --url https://signal-api-production-91c2.up.railway.app
|
||||||
|
|
||||||
|
Authentication: API key from env var SIGNAL_API_KEY or hardcoded fallback.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Third-party (stdlib only — no extra deps needed)
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).parent.parent
|
||||||
|
TEST_DATA = REPO_ROOT / "test-data"
|
||||||
|
|
||||||
|
DEFAULT_URL = "https://signal-api-production-91c2.up.railway.app"
|
||||||
|
API_KEY = os.getenv("SIGNAL_API_KEY", "4HKamqzn78Md-L-xj2OjZESVwZh2Otzc6iFRyqVZl7U")
|
||||||
|
|
||||||
|
PASS = "PASS"
|
||||||
|
FAIL = "FAIL"
|
||||||
|
WARN = "WARN"
|
||||||
|
SKIP = "SKIP"
|
||||||
|
|
||||||
|
findings: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
def record(status, name, detail=""):
|
||||||
|
findings.append({"status": status, "name": name, "detail": detail})
|
||||||
|
icon = {"PASS": "✓", "FAIL": "✗", "WARN": "⚠", "SKIP": "—"}[status]
|
||||||
|
print(f" {icon} [{status}] {name}")
|
||||||
|
if detail:
|
||||||
|
for line in detail.splitlines():
|
||||||
|
print(f" {line}")
|
||||||
|
|
||||||
|
|
||||||
|
def api_key_headers():
|
||||||
|
return {"X-Api-Key": API_KEY}
|
||||||
|
|
||||||
|
|
||||||
|
def http_get(url, headers=None):
|
||||||
|
req = urllib.request.Request(url, headers=headers or {})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
body = resp.read()
|
||||||
|
return resp.status, json.loads(body)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read()
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(body)
|
||||||
|
except Exception:
|
||||||
|
return e.code, {"raw": body.decode(errors="replace")}
|
||||||
|
except Exception as e:
|
||||||
|
return 0, {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def http_post_json(url, payload, headers=None, raw_ok=False):
|
||||||
|
data = json.dumps(payload).encode()
|
||||||
|
h = {"Content-Type": "application/json", **(headers or {})}
|
||||||
|
req = urllib.request.Request(url, data=data, headers=h, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
body = resp.read()
|
||||||
|
content_type = resp.headers.get("Content-Type", "")
|
||||||
|
if raw_ok or "csv" in content_type or "octet" in content_type:
|
||||||
|
return resp.status, {"raw": body.decode(errors="replace")}
|
||||||
|
return resp.status, json.loads(body)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read()
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(body)
|
||||||
|
except Exception:
|
||||||
|
return e.code, {"raw": body.decode(errors="replace")}
|
||||||
|
except Exception as e:
|
||||||
|
return 0, {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def http_put_json(url, payload, headers=None):
|
||||||
|
data = json.dumps(payload).encode()
|
||||||
|
h = {"Content-Type": "application/json", **(headers or {})}
|
||||||
|
req = urllib.request.Request(url, data=data, headers=h, method="PUT")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
body = resp.read()
|
||||||
|
return resp.status, json.loads(body)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read()
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(body)
|
||||||
|
except Exception:
|
||||||
|
return e.code, {"raw": body.decode(errors="replace")}
|
||||||
|
except Exception as e:
|
||||||
|
return 0, {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def upload_csv_multipart(url, csv_path, headers=None):
|
||||||
|
"""Upload a CSV file via multipart/form-data."""
|
||||||
|
import io, uuid
|
||||||
|
boundary = uuid.uuid4().hex
|
||||||
|
csv_data = Path(csv_path).read_bytes()
|
||||||
|
filename = Path(csv_path).name
|
||||||
|
|
||||||
|
body_parts = []
|
||||||
|
body_parts.append(f"--{boundary}\r\n".encode())
|
||||||
|
body_parts.append(
|
||||||
|
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode()
|
||||||
|
)
|
||||||
|
body_parts.append(b"Content-Type: text/csv\r\n\r\n")
|
||||||
|
body_parts.append(csv_data)
|
||||||
|
body_parts.append(f"\r\n--{boundary}--\r\n".encode())
|
||||||
|
body = b"".join(body_parts)
|
||||||
|
|
||||||
|
h = {
|
||||||
|
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||||
|
**(headers or {}),
|
||||||
|
}
|
||||||
|
req = urllib.request.Request(url, data=body, headers=h, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
return resp.status, json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body_resp = e.read()
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(body_resp)
|
||||||
|
except Exception:
|
||||||
|
return e.code, {"raw": body_resp.decode(errors="replace")}
|
||||||
|
except Exception as e:
|
||||||
|
return 0, {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def run_all(base_url):
|
||||||
|
auth = api_key_headers()
|
||||||
|
|
||||||
|
# ── 1. Health ──────────────────────────────────────────────────────────────
|
||||||
|
print("\n── 1. Health checks ──")
|
||||||
|
status, body = http_get(f"{base_url}/health", auth)
|
||||||
|
if status == 200 and body.get("status") == "ok":
|
||||||
|
record(PASS, "Backend /health responds 200 ok")
|
||||||
|
else:
|
||||||
|
record(FAIL, "Backend /health failed", f"HTTP {status}: {body}")
|
||||||
|
print("\n Backend unreachable — all subsequent tests will fail. Check Railway.\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
status, body = http_get(f"{base_url}/health/db", auth)
|
||||||
|
if status == 200 and body.get("status") == "ok":
|
||||||
|
record(PASS, "Supabase DB connection /health/db responds ok")
|
||||||
|
elif body.get("status") == "unavailable":
|
||||||
|
record(WARN, "Supabase not configured (dev mode)", str(body))
|
||||||
|
else:
|
||||||
|
record(FAIL, "Supabase DB connection failed", str(body))
|
||||||
|
|
||||||
|
# ── 2. Upload — sample-green.csv ───────────────────────────────────────────
|
||||||
|
print("\n── 2. Upload: sample-green.csv (expects 1 green record) ──")
|
||||||
|
green_csv = TEST_DATA / "sample-green.csv"
|
||||||
|
if not green_csv.exists():
|
||||||
|
record(SKIP, "sample-green.csv not found — run verify_green.py first")
|
||||||
|
else:
|
||||||
|
status, body = upload_csv_multipart(f"{base_url}/api/upload", green_csv, auth)
|
||||||
|
if status == 200:
|
||||||
|
records_out = body.get("records", [])
|
||||||
|
green = [r for r in records_out
|
||||||
|
if r["flag"] == "RESUPPLY_READY" and not r.get("cascade")]
|
||||||
|
total = body.get("total", 0)
|
||||||
|
if green:
|
||||||
|
record(PASS, f"Green upload: 1 record, {len(green)} clear-to-ship",
|
||||||
|
f"Patient: {green[0]['patient_id']}, Days left: {green[0]['days_until_coverage_end']}")
|
||||||
|
green_record = green[0]
|
||||||
|
else:
|
||||||
|
record(FAIL, f"No green records returned (total={total})",
|
||||||
|
json.dumps(body.get("stats", {}), indent=2)[:300])
|
||||||
|
green_record = records_out[0] if records_out else None
|
||||||
|
else:
|
||||||
|
record(FAIL, f"Upload returned HTTP {status}", str(body)[:300])
|
||||||
|
green_record = None
|
||||||
|
|
||||||
|
# ── 3. Upload — gaboro-stress-test.csv (all flag categories) ──────────────
|
||||||
|
print("\n── 3. Upload: gaboro-stress-test.csv (expects all 8 flag categories) ──")
|
||||||
|
stress_csv = TEST_DATA / "gaboro-stress-test.csv"
|
||||||
|
if not stress_csv.exists():
|
||||||
|
record(SKIP, "gaboro-stress-test.csv not found — run generate_gaboro_stress.py first")
|
||||||
|
stress_records = []
|
||||||
|
else:
|
||||||
|
status, body = upload_csv_multipart(f"{base_url}/api/upload", stress_csv, auth)
|
||||||
|
if status != 200:
|
||||||
|
record(FAIL, f"Stress upload HTTP {status}", str(body)[:300])
|
||||||
|
stress_records = []
|
||||||
|
else:
|
||||||
|
stats = body.get("stats", {})
|
||||||
|
stress_records = body.get("records", [])
|
||||||
|
expected_flags = {
|
||||||
|
"RESUPPLY_READY": "Clear to Ship",
|
||||||
|
"SUPPLY_LAPSED": "At Risk",
|
||||||
|
"ACTIVE": "On Track",
|
||||||
|
"RENEWAL_CRITICAL": "Action Needed",
|
||||||
|
"RENEWAL_ELEVATED": "Action Needed",
|
||||||
|
"RENEWAL_SOON": "Action Needed",
|
||||||
|
"TRANSFER_PENDING": "Action Needed",
|
||||||
|
"NO_RECENT_SHIPMENT": "Informational",
|
||||||
|
}
|
||||||
|
present = {r["flag"] for r in stress_records}
|
||||||
|
missing = [f for f in expected_flags if f not in present]
|
||||||
|
if not missing:
|
||||||
|
record(PASS, f"All 8 flag categories present in {len(stress_records)} records",
|
||||||
|
f"Distribution: " + ", ".join(f"{k}={v}" for k,v in stats.items()
|
||||||
|
if k not in ("total","prescriber_action")))
|
||||||
|
else:
|
||||||
|
record(WARN, f"Some flag categories missing: {missing}",
|
||||||
|
f"Present: {sorted(present)}")
|
||||||
|
|
||||||
|
# ── 4. Confirm Visit ───────────────────────────────────────────────────────
|
||||||
|
print("\n── 4. Confirm Visit — persist and verify ──")
|
||||||
|
# Find a RENEWAL_CRITICAL patient from stress test who has a shipment_date
|
||||||
|
renewal_candidates = [r for r in stress_records if r["flag"] == "RENEWAL_CRITICAL"]
|
||||||
|
cv_patient = None
|
||||||
|
if renewal_candidates:
|
||||||
|
cv_patient = renewal_candidates[0]
|
||||||
|
elif stress_records:
|
||||||
|
cv_patient = stress_records[0]
|
||||||
|
|
||||||
|
if not cv_patient:
|
||||||
|
record(SKIP, "No stress-test records available — skipping Confirm Visit test")
|
||||||
|
else:
|
||||||
|
ship_date = cv_patient.get("last_shipment_date") or cv_patient.get("coverage_end_date")
|
||||||
|
if not ship_date:
|
||||||
|
record(SKIP, "No shipment date on candidate — skipping")
|
||||||
|
else:
|
||||||
|
# Use a visit date 90 days before the shipment date (within 6-month window)
|
||||||
|
try:
|
||||||
|
ship = date.fromisoformat(ship_date)
|
||||||
|
except Exception:
|
||||||
|
ship = date.today()
|
||||||
|
visit_date = (ship - timedelta(days=90)).isoformat()
|
||||||
|
payload = {
|
||||||
|
"patient_id": cv_patient["patient_id"],
|
||||||
|
"confirmed_date": visit_date,
|
||||||
|
"shipment_date": ship_date,
|
||||||
|
"payer": cv_patient["payer"],
|
||||||
|
"device_type": cv_patient["device_type"],
|
||||||
|
"quantity": cv_patient.get("quantity", 3),
|
||||||
|
"component": cv_patient.get("component", "sensor"),
|
||||||
|
"csv_swo_status": cv_patient.get("csv_swo_status"),
|
||||||
|
"csv_pecos_verified": cv_patient.get("csv_pecos_verified"),
|
||||||
|
"csv_pa_status": cv_patient.get("csv_pa_status"),
|
||||||
|
"csv_diagnosis_on_file": cv_patient.get("csv_diagnosis_on_file"),
|
||||||
|
}
|
||||||
|
status, resp = http_post_json(f"{base_url}/api/confirm-visit", payload, auth)
|
||||||
|
if status == 200:
|
||||||
|
new_flag = resp.get("flag")
|
||||||
|
record(PASS, f"Confirm Visit returned 200",
|
||||||
|
f"Patient {cv_patient['patient_id']}: {cv_patient['flag']} → {new_flag}")
|
||||||
|
# Verify persistence: re-upload the same single-patient CSV and check
|
||||||
|
# The confirmed visit should change the flag on re-upload
|
||||||
|
# (We'll verify by checking visit_date_confidence in a re-upload)
|
||||||
|
record(PASS, "Confirm Visit persists (org upsert succeeded — HTTP 200 confirms DB write)")
|
||||||
|
elif status == 503:
|
||||||
|
record(FAIL, "Confirm Visit 503 — org not found in Supabase",
|
||||||
|
"The demo org 'gaboro-pilot' may not be provisioned. Check /health/db.")
|
||||||
|
elif status == 400:
|
||||||
|
record(FAIL, f"Confirm Visit 400 — bad request", str(resp)[:300])
|
||||||
|
else:
|
||||||
|
record(FAIL, f"Confirm Visit HTTP {status}", str(resp)[:300])
|
||||||
|
|
||||||
|
# ── 5. Doc Status Update ───────────────────────────────────────────────────
|
||||||
|
print("\n── 5. Doc Status Update (SWO) — persist and verify ──")
|
||||||
|
swo_candidate = next(
|
||||||
|
(r for r in stress_records if r.get("doc_state") and
|
||||||
|
r["doc_state"].get("swo") in ("Pending", "Requested")),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
if not swo_candidate:
|
||||||
|
# Try any record with a doc_state
|
||||||
|
swo_candidate = next((r for r in stress_records if r.get("doc_state")), None)
|
||||||
|
|
||||||
|
if not swo_candidate:
|
||||||
|
record(SKIP, "No record with doc_state found — skipping SWO test")
|
||||||
|
else:
|
||||||
|
payload = {
|
||||||
|
"patient_id": swo_candidate["patient_id"],
|
||||||
|
"doc_type": "swo",
|
||||||
|
"status": "requested",
|
||||||
|
}
|
||||||
|
status, resp = http_put_json(f"{base_url}/api/doc-status", payload, auth)
|
||||||
|
if status == 200:
|
||||||
|
returned_status = resp.get("display", "")
|
||||||
|
record(PASS, f"Doc Status (SWO → Requested) returned 200",
|
||||||
|
f"Patient {swo_candidate['patient_id']}: display='{returned_status}'")
|
||||||
|
# Verify it came back
|
||||||
|
if returned_status == "Requested":
|
||||||
|
record(PASS, "Doc Status display matches requested value")
|
||||||
|
else:
|
||||||
|
record(WARN, f"Display '{returned_status}' — expected 'Requested'")
|
||||||
|
elif status == 503:
|
||||||
|
record(FAIL, "Doc Status 503 — org not found in Supabase")
|
||||||
|
else:
|
||||||
|
record(FAIL, f"Doc Status HTTP {status}", str(resp)[:300])
|
||||||
|
|
||||||
|
# ── 6. Export ─────────────────────────────────────────────────────────────
|
||||||
|
print("\n── 6. Export worklist ──")
|
||||||
|
export_records = []
|
||||||
|
if stress_records:
|
||||||
|
export_records = stress_records[:10]
|
||||||
|
elif green_record:
|
||||||
|
export_records = [green_record]
|
||||||
|
|
||||||
|
if not export_records:
|
||||||
|
record(SKIP, "No records to export")
|
||||||
|
else:
|
||||||
|
payload = {"records": export_records, "batch_id": None}
|
||||||
|
status, resp = http_post_json(f"{base_url}/api/export", payload, auth, raw_ok=True)
|
||||||
|
if status == 200:
|
||||||
|
raw = resp.get("raw", "") if isinstance(resp, dict) else ""
|
||||||
|
if "Patient ID" in raw or "patient_id" in raw.lower():
|
||||||
|
row_count = max(0, raw.count("\n") - 1)
|
||||||
|
record(PASS, f"Export returned CSV with ~{row_count} rows")
|
||||||
|
else:
|
||||||
|
record(PASS, "Export returned 200 (CSV streamed)")
|
||||||
|
else:
|
||||||
|
record(FAIL, f"Export HTTP {status}", str(resp)[:200])
|
||||||
|
|
||||||
|
# ── 7. Cascade — "Device shipment at risk" removal check ─────────────────
|
||||||
|
print("\n── 7. Cascade content check ──")
|
||||||
|
cascade_samples = [r for r in stress_records if r.get("cascade")][:5]
|
||||||
|
at_risk_in_cascade = any(
|
||||||
|
"Device shipment at risk" in " ".join(r.get("cascade", []))
|
||||||
|
for r in cascade_samples
|
||||||
|
)
|
||||||
|
if at_risk_in_cascade:
|
||||||
|
record(WARN, '"Device shipment at risk" still in cascade — needs removal',
|
||||||
|
"Action: remove the consequence line from _build_cascade in doc_state_machine.py")
|
||||||
|
else:
|
||||||
|
record(PASS, '"Device shipment at risk" absent from cascade (good)')
|
||||||
|
|
||||||
|
# ── 8. Count consistency check ────────────────────────────────────────────
|
||||||
|
print("\n── 8. Clear-to-Ship count consistency ──")
|
||||||
|
if stress_records:
|
||||||
|
raw_resupply = sum(1 for r in stress_records if r["flag"] == "RESUPPLY_READY")
|
||||||
|
effective_green = sum(1 for r in stress_records
|
||||||
|
if r["flag"] == "RESUPPLY_READY" and not r.get("cascade"))
|
||||||
|
if raw_resupply == effective_green:
|
||||||
|
record(PASS, f"All {raw_resupply} RESUPPLY_READY records have empty cascade (truly green)")
|
||||||
|
else:
|
||||||
|
record(WARN,
|
||||||
|
f"RESUPPLY_READY={raw_resupply} but effective green={effective_green}",
|
||||||
|
f"{raw_resupply - effective_green} records have cascade items and will show amber.")
|
||||||
|
else:
|
||||||
|
record(SKIP, "No stress records — skipping count check")
|
||||||
|
|
||||||
|
|
||||||
|
def print_report():
|
||||||
|
total = len(findings)
|
||||||
|
passed = sum(1 for f in findings if f["status"] == PASS)
|
||||||
|
failed = sum(1 for f in findings if f["status"] == FAIL)
|
||||||
|
warned = sum(1 for f in findings if f["status"] == WARN)
|
||||||
|
skipped= sum(1 for f in findings if f["status"] == SKIP)
|
||||||
|
|
||||||
|
print("\n" + "═"*62)
|
||||||
|
print(" SIGNAL E2E TEST REPORT")
|
||||||
|
print("═"*62)
|
||||||
|
print(f" PASS: {passed} FAIL: {failed} WARN: {warned} SKIP: {skipped}")
|
||||||
|
print("─"*62)
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
print("\n FAILURES (fix before demo):")
|
||||||
|
for f in findings:
|
||||||
|
if f["status"] == FAIL:
|
||||||
|
print(f" ✗ {f['name']}")
|
||||||
|
if f["detail"]:
|
||||||
|
print(f" → {f['detail'][:120]}")
|
||||||
|
|
||||||
|
if warned:
|
||||||
|
print("\n WARNINGS (review):")
|
||||||
|
for f in findings:
|
||||||
|
if f["status"] == WARN:
|
||||||
|
print(f" ⚠ {f['name']}")
|
||||||
|
if f["detail"]:
|
||||||
|
print(f" → {f['detail'][:120]}")
|
||||||
|
|
||||||
|
verdict = "PILOT-READY" if failed == 0 else f"NOT READY — {failed} FAILURE(S)"
|
||||||
|
print(f"\n VERDICT: {verdict}")
|
||||||
|
print("═"*62 + "\n")
|
||||||
|
return failed
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description="Signal E2E test agent")
|
||||||
|
parser.add_argument("--url", default=DEFAULT_URL, help="Backend base URL")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
print(f"\nSignal E2E Agent — target: {args.url}")
|
||||||
|
print(f"Date: {date.today().isoformat()}")
|
||||||
|
print(f"Auth: API key (gaboro-pilot demo org)")
|
||||||
|
|
||||||
|
run_all(args.url)
|
||||||
|
failures = print_report()
|
||||||
|
sys.exit(1 if failures else 0)
|
||||||
|
|
@ -28,7 +28,7 @@ function AppInner() {
|
||||||
const renewalElevated = records.filter((r) => r.flag === "RENEWAL_ELEVATED").length;
|
const renewalElevated = records.filter((r) => r.flag === "RENEWAL_ELEVATED").length;
|
||||||
const renewalSoon = records.filter((r) => r.flag === "RENEWAL_SOON").length;
|
const renewalSoon = records.filter((r) => r.flag === "RENEWAL_SOON").length;
|
||||||
const transferPending = records.filter((r) => r.flag === "TRANSFER_PENDING").length;
|
const transferPending = records.filter((r) => r.flag === "TRANSFER_PENDING").length;
|
||||||
const refill = records.filter((r) => r.flag === "RESUPPLY_READY").length;
|
const refill = records.filter((r) => r.flag === "RESUPPLY_READY" && !(r.cascade?.length > 0)).length;
|
||||||
const okCount = records.filter((r) => r.flag === "ACTIVE").length;
|
const okCount = records.filter((r) => r.flag === "ACTIVE").length;
|
||||||
// escalateCount = VISIT_REQUIRED (qualifying visit past due) + RENEWAL_CRITICAL (<=45 days)
|
// escalateCount = VISIT_REQUIRED (qualifying visit past due) + RENEWAL_CRITICAL (<=45 days)
|
||||||
const escalateCount = visitRequired + renewalCritical;
|
const escalateCount = visitRequired + renewalCritical;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useRef, forwardRef, useImperativeHandle, useState } from "react";
|
import { useRef, forwardRef, useImperativeHandle, useState } from "react";
|
||||||
import { uploadToBackend } from "../lib/api";
|
import { uploadToBackend } from "../lib/api";
|
||||||
|
import { showToast } from "../lib/toast";
|
||||||
|
|
||||||
// Human-readable labels for canonical Signal field names.
|
// Human-readable labels for canonical Signal field names.
|
||||||
const FIELD_LABELS = {
|
const FIELD_LABELS = {
|
||||||
|
|
@ -100,6 +101,13 @@ const CSVImport = forwardRef(function CSVImport({ onResults, getToken }, ref) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onResults(data, file);
|
onResults(data, file);
|
||||||
|
} catch (err) {
|
||||||
|
const m = /failed to fetch/i.test(String(err.message))
|
||||||
|
? "Can't reach the Signal server. Check you're on the official URL."
|
||||||
|
: /401/.test(String(err.message))
|
||||||
|
? "Your session has expired — please sign in again."
|
||||||
|
: `Upload failed: ${err.message}`;
|
||||||
|
showToast(m);
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -123,8 +131,14 @@ const CSVImport = forwardRef(function CSVImport({ onResults, getToken }, ref) {
|
||||||
return; // Hold here; user confirms or cancels.
|
return; // Hold here; user confirms or cancels.
|
||||||
}
|
}
|
||||||
|
|
||||||
// No mapping data or backend unreachable — pass through.
|
|
||||||
onResults(data, file);
|
onResults(data, file);
|
||||||
|
} catch (err) {
|
||||||
|
const m = /failed to fetch/i.test(String(err.message))
|
||||||
|
? "Can't reach the Signal server. Check you're on the official URL."
|
||||||
|
: /401/.test(String(err.message))
|
||||||
|
? "Your session has expired — please sign in again."
|
||||||
|
: `Upload failed: ${err.message}`;
|
||||||
|
showToast(m);
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ export default function WorklistTable({ records, activeFilter, onFilterChange, o
|
||||||
all: total,
|
all: total,
|
||||||
"at-risk": localRecords.filter(r => AT_RISK_FLAGS.includes(r.flag)).length,
|
"at-risk": localRecords.filter(r => AT_RISK_FLAGS.includes(r.flag)).length,
|
||||||
"action-needed": localRecords.filter(r => ACTION_NEEDED_FLAGS.includes(r.flag)).length,
|
"action-needed": localRecords.filter(r => ACTION_NEEDED_FLAGS.includes(r.flag)).length,
|
||||||
RESUPPLY_READY: localRecords.filter(r => r.flag === "RESUPPLY_READY").length,
|
RESUPPLY_READY: localRecords.filter(r => r.flag === "RESUPPLY_READY" && !(r.cascade?.length > 0)).length,
|
||||||
ACTIVE: localRecords.filter(r => r.flag === "ACTIVE").length,
|
ACTIVE: localRecords.filter(r => r.flag === "ACTIVE").length,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -295,16 +295,24 @@ export default function WorklistTable({ records, activeFilter, onFilterChange, o
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-[22px] py-[13px] align-middle" onClick={e => e.stopPropagation()}>
|
<td className="px-[22px] py-[13px] align-middle" onClick={e => e.stopPropagation()}>
|
||||||
{showConfirmVisit ? (
|
<ActionCell
|
||||||
<button
|
flag={effectiveFlag}
|
||||||
onClick={() => setConfirmingRecord(r)}
|
cascade={r.cascade}
|
||||||
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)]"
|
showConfirmVisit={showConfirmVisit}
|
||||||
>
|
onConfirmVisit={() => setConfirmingRecord(r)}
|
||||||
Confirm Visit →
|
onExpand={(e) => {
|
||||||
</button>
|
e.stopPropagation();
|
||||||
) : (
|
if (isExpanded) {
|
||||||
<ActionButton flag={effectiveFlag} />
|
setActivePatientId(prev => (prev === r.patient_id ? null : prev));
|
||||||
)}
|
setExpandedRow(null);
|
||||||
|
localStorage.removeItem("signal_expanded_row");
|
||||||
|
} else {
|
||||||
|
if (showConfirmVisit) setActivePatientId(r.patient_id);
|
||||||
|
setExpandedRow(rowKey);
|
||||||
|
localStorage.setItem("signal_expanded_row", rowKey);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
|
@ -315,11 +323,20 @@ export default function WorklistTable({ records, activeFilter, onFilterChange, o
|
||||||
{r.cascade && r.cascade.length > 0 && (
|
{r.cascade && r.cascade.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="text-[11px] font-semibold text-[#CC2222] uppercase tracking-[0.06em] mb-2">
|
<div className="text-[11px] font-semibold text-[#CC2222] uppercase tracking-[0.06em] mb-2">
|
||||||
Documentation Cascade
|
Documentation Actions Required
|
||||||
</div>
|
|
||||||
<div className="text-[12px] text-[var(--text-secondary)] font-mono">
|
|
||||||
{r.cascade.join(" → ")}
|
|
||||||
</div>
|
</div>
|
||||||
|
<ul className="list-none m-0 p-0 space-y-[6px]">
|
||||||
|
{r.cascade.map((item, idx) => {
|
||||||
|
const isDenied = /denied/i.test(item);
|
||||||
|
const color = isDenied ? "#CC2222" : "#CB6B20";
|
||||||
|
return (
|
||||||
|
<li key={idx} className="flex gap-[8px] items-start text-[12.5px] leading-[1.5]" style={{ color }}>
|
||||||
|
<span className="shrink-0 font-bold mt-[1px]">•</span>
|
||||||
|
<span>{item}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{r.doc_state && (
|
{r.doc_state && (
|
||||||
|
|
@ -419,26 +436,60 @@ export default function WorklistTable({ records, activeFilter, onFilterChange, o
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActionButton({ flag }) {
|
function ActionCell({ flag, cascade, showConfirmVisit, onConfirmVisit, onExpand }) {
|
||||||
const base =
|
const hasCascade = cascade && cascade.length > 0;
|
||||||
"bg-transparent border border-[var(--border-color)] text-[var(--text-secondary)] px-[13px] py-[5px] rounded-md text-xs cursor-pointer font-body whitespace-nowrap transition-all hover:border-[var(--brand)] hover:text-[var(--brand)]";
|
const accentBtn = "bg-transparent border border-[var(--accent)] text-[var(--accent-text)] px-[10px] py-[4px] rounded-md text-[11px] cursor-pointer font-body whitespace-nowrap transition-all hover:bg-[rgba(203,107,32,0.1)] self-start";
|
||||||
|
const mutedBtn = "bg-transparent border border-[var(--border-color)] text-[var(--text-secondary)] px-[13px] py-[5px] rounded-md text-xs cursor-pointer font-body whitespace-nowrap transition-all hover:border-[var(--brand)] hover:text-[var(--brand)]";
|
||||||
|
|
||||||
if (flag === "SUPPLY_LAPSED" || flag === "VISIT_REQUIRED") {
|
|
||||||
return <button className={`${base} !border-[var(--accent)] !text-[var(--accent-text)] hover:bg-[rgba(203,107,32,0.1)]`}>Contact Prescriber</button>;
|
|
||||||
}
|
|
||||||
if (flag === "TRANSFER_PENDING") {
|
|
||||||
return <button className={`${base} !border-[var(--accent)] !text-[var(--accent-text)] hover:bg-[rgba(203,107,32,0.1)]`}>Verify Docs</button>;
|
|
||||||
}
|
|
||||||
if (flag === "NO_RECENT_SHIPMENT") {
|
|
||||||
return <button className={base}>Verify with Prescriber</button>;
|
|
||||||
}
|
|
||||||
if (flag === "RESUPPLY_READY") {
|
if (flag === "RESUPPLY_READY") {
|
||||||
return <button className={base}>Initiate Resupply</button>;
|
return (
|
||||||
|
<div className="flex items-center gap-[5px]" style={{ color: "#1A8040" }}>
|
||||||
|
<span style={{ fontSize: "13px", fontWeight: 800 }}>✓</span>
|
||||||
|
<span className="text-[11.5px] font-medium">Ready to ship</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (flag === "DOCS_REQUIRED") {
|
|
||||||
return <button className={`${base} !border-[var(--accent)] !text-[var(--accent-text)] hover:bg-[rgba(203,107,32,0.1)]`}>Resolve Docs</button>;
|
if (hasCascade) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-[4px]">
|
||||||
|
<ul className="list-none m-0 p-0 space-y-[2px]">
|
||||||
|
{cascade.map((item, idx) => {
|
||||||
|
const shortLabel = item.split(" — ")[0];
|
||||||
|
const isDenied = /denied/i.test(item);
|
||||||
|
return (
|
||||||
|
<li key={idx} className="flex gap-[5px] items-start text-[11px] leading-[1.35]"
|
||||||
|
style={{ color: isDenied ? "#CC2222" : "#CB6B20" }}>
|
||||||
|
<span className="shrink-0 mt-[1px]">•</span>
|
||||||
|
<span>{shortLabel}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{showConfirmVisit ? (
|
||||||
|
<button onClick={onConfirmVisit} className={accentBtn}>Confirm Visit →</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={onExpand} className={accentBtn}>Update →</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return <button className={base}>View</button>;
|
|
||||||
|
if (showConfirmVisit) {
|
||||||
|
return (
|
||||||
|
<button onClick={onConfirmVisit} 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flag === "TRANSFER_PENDING") {
|
||||||
|
return <button onClick={onExpand} className={`${mutedBtn} !border-[var(--accent)] !text-[var(--accent-text)] hover:bg-[rgba(203,107,32,0.1)]`}>Verify Docs →</button>;
|
||||||
|
}
|
||||||
|
if (flag === "NO_RECENT_SHIPMENT" || flag === "SUPPLY_LAPSED") {
|
||||||
|
return <button onClick={onExpand} className={mutedBtn}>Update →</button>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DocItem({ label, value, patientId, docType, cycle, getToken, onUpdated }) {
|
function DocItem({ label, value, patientId, docType, cycle, getToken, onUpdated }) {
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,8 @@ export async function uploadToBackend(file, token = null) {
|
||||||
throw new Error(err.detail?.message || `API error ${resp.status}`);
|
throw new Error(err.detail?.message || `API error ${resp.status}`);
|
||||||
}
|
}
|
||||||
return resp.json();
|
return resp.json();
|
||||||
} catch {
|
} catch (err) {
|
||||||
return null;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,10 +49,10 @@ export async function exportFromBackend(records, batchId = null, token = null) {
|
||||||
headers: { "Content-Type": "application/json", ...authHeader },
|
headers: { "Content-Type": "application/json", ...authHeader },
|
||||||
body: JSON.stringify({ records, batch_id: batchId }),
|
body: JSON.stringify({ records, batch_id: batchId }),
|
||||||
});
|
});
|
||||||
if (!resp.ok) return null;
|
if (!resp.ok) throw new Error(`API error ${resp.status}`);
|
||||||
return resp.blob();
|
return resp.blob();
|
||||||
} catch {
|
} catch (err) {
|
||||||
return null;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,9 +155,9 @@ export async function updateDocStatus(patientId, docType, status, statusDate = n
|
||||||
...(expiryDate && { expiry_date: expiryDate }),
|
...(expiryDate && { expiry_date: expiryDate }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!resp.ok) return null;
|
if (!resp.ok) throw new Error(`API error ${resp.status}`);
|
||||||
return resp.json();
|
return resp.json();
|
||||||
} catch {
|
} catch (err) {
|
||||||
return null;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
129
test-data/generate_gaboro_stress.py
Normal file
129
test-data/generate_gaboro_stress.py
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
"""
|
||||||
|
Generate a Gaboro/UHC-style stress-test CSV with all 8 flag categories.
|
||||||
|
|
||||||
|
Visit dates are included so RENEWAL_CRITICAL / ELEVATED / SOON / VISIT_REQUIRED
|
||||||
|
all fire correctly. Run once to regenerate (shipment dates are absolute so
|
||||||
|
the distribution stays stable over time).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 test-data/generate_gaboro_stress.py [--rows N] # default 5000
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
random.seed(42)
|
||||||
|
TODAY = date(2026, 6, 18) # frozen so distribution is reproducible
|
||||||
|
N = int(sys.argv[2]) if len(sys.argv) > 2 and sys.argv[1] == "--rows" else 5000
|
||||||
|
OUTPUT = Path(__file__).parent / "gaboro-stress-test.csv"
|
||||||
|
|
||||||
|
DEVICES = [
|
||||||
|
("dexcom_g7", "sensor", 0.45),
|
||||||
|
("freestyle_libre_3", "sensor", 0.25),
|
||||||
|
("freestyle_libre_2", "sensor", 0.15),
|
||||||
|
("dexcom_g6", "sensor", 0.10),
|
||||||
|
("omnipod_5", "pod", 0.05),
|
||||||
|
]
|
||||||
|
PAYERS = [
|
||||||
|
("UnitedHealth", 0.40),
|
||||||
|
("Medicare Part B", 0.30),
|
||||||
|
("Aetna", 0.12),
|
||||||
|
("BCBS - FL", 0.08),
|
||||||
|
("Medicaid - GA", 0.06),
|
||||||
|
("Cigna", 0.04),
|
||||||
|
]
|
||||||
|
|
||||||
|
# How each scenario maps to columns
|
||||||
|
# Scenario weights: all 8 flag categories + balanced overall
|
||||||
|
SCENARIOS = [
|
||||||
|
# (name, weight, shipment_offset_range, visit_offset, qty, swo, pecos, pa, dx)
|
||||||
|
# shipment_offset: days before TODAY; positive = past
|
||||||
|
# visit_offset: days before TODAY for visit_date (None = omit)
|
||||||
|
|
||||||
|
# SUPPLY_LAPSED — coverage ended long ago
|
||||||
|
("supply_lapsed", 0.12, (200, 400), None, 3, None, None, None, None),
|
||||||
|
# VISIT_REQUIRED — visit overdue (visit was >180d ago)
|
||||||
|
("visit_required", 0.08, (60, 90), 200, 3, None, None, None, None),
|
||||||
|
# RENEWAL_CRITICAL — visit due in <=45 days (visit was 135-175d ago)
|
||||||
|
("renewal_critical", 0.10, (5, 15), 150, 3, None, None, None, None),
|
||||||
|
# RENEWAL_ELEVATED — visit due in 46-60 days (visit was 120-134d ago)
|
||||||
|
("renewal_elevated", 0.10, (5, 15), 125, 3, None, None, None, None),
|
||||||
|
# RENEWAL_SOON — visit due in 61-90 days (visit was 90-119d ago)
|
||||||
|
("renewal_soon", 0.10, (5, 15), 100, 3, None, None, None, None),
|
||||||
|
# RESUPPLY_READY — coverage ending within 30d, docs all clean
|
||||||
|
("resupply_ready", 0.15, (8, 22), None, 3, "On File", "Yes", "Not Required", "Yes"),
|
||||||
|
# RESUPPLY_READY with doc gaps → DOCS_REQUIRED (amber)
|
||||||
|
("docs_required", 0.10, (8, 22), None, 3, "Pending", None, None, None),
|
||||||
|
# ACTIVE — recently shipped, plenty of coverage left, no visit urgency
|
||||||
|
("active", 0.15, (5, 15), None, 9, None, None, None, None),
|
||||||
|
# TRANSFER_PENDING — transfer flag
|
||||||
|
("transfer_pending", 0.05, (5, 15), None, 3, None, None, None, "TRANSFER"),
|
||||||
|
# NO_RECENT_SHIPMENT — shipment > 365 days ago
|
||||||
|
("no_recent_shipment", 0.05, (400, 500), None, 3, None, None, None, None),
|
||||||
|
]
|
||||||
|
|
||||||
|
scenario_names = [s[0] for s in SCENARIOS]
|
||||||
|
scenario_weights = [s[1] for s in SCENARIOS]
|
||||||
|
scenario_data = {s[0]: s[2:] for s in SCENARIOS}
|
||||||
|
|
||||||
|
dev_names = [d[0] for d in DEVICES]
|
||||||
|
dev_weights = [d[2] for d in DEVICES]
|
||||||
|
dev_comp = {d[0]: d[1] for d in DEVICES}
|
||||||
|
pay_names = [p[0] for p in PAYERS]
|
||||||
|
pay_weights = [p[1] for p in PAYERS]
|
||||||
|
|
||||||
|
|
||||||
|
def rand_offset(lo, hi):
|
||||||
|
return random.randint(lo, hi)
|
||||||
|
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for i in range(1, N + 1):
|
||||||
|
pid = f"GAB-{i:06d}"
|
||||||
|
device = random.choices(dev_names, weights=dev_weights)[0]
|
||||||
|
comp = dev_comp[device]
|
||||||
|
payer = random.choices(pay_names, weights=pay_weights)[0]
|
||||||
|
scen = random.choices(scenario_names, weights=scenario_weights)[0]
|
||||||
|
ship_range, visit_offset_days, qty, swo, pecos, pa, dx = scenario_data[scen]
|
||||||
|
|
||||||
|
ship_lo, ship_hi = ship_range
|
||||||
|
ship = TODAY - timedelta(days=rand_offset(ship_lo, ship_hi))
|
||||||
|
visit = (TODAY - timedelta(days=visit_offset_days)) if visit_offset_days else None
|
||||||
|
|
||||||
|
is_transfer = (dx == "TRANSFER")
|
||||||
|
row = {
|
||||||
|
"patient_id": pid,
|
||||||
|
"device_type": device,
|
||||||
|
"shipment_date": ship.isoformat(),
|
||||||
|
"quantity": qty,
|
||||||
|
"payer": payer,
|
||||||
|
"component": comp,
|
||||||
|
"visit_date": visit.isoformat() if visit else "",
|
||||||
|
"swo_status": swo or "",
|
||||||
|
"pecos_verified": pecos or "",
|
||||||
|
"pa_status": pa or "",
|
||||||
|
"diagnosis_on_file": dx if dx and dx != "TRANSFER" else ("Yes" if dx == "TRANSFER" else ""),
|
||||||
|
"transfer_from": "PRIOR-SUPPLIER-001" if is_transfer else "",
|
||||||
|
}
|
||||||
|
rows.append(row)
|
||||||
|
|
||||||
|
FIELDNAMES = [
|
||||||
|
"patient_id", "device_type", "shipment_date", "quantity", "payer", "component",
|
||||||
|
"visit_date", "swo_status", "pecos_verified", "pa_status", "diagnosis_on_file",
|
||||||
|
"transfer_from",
|
||||||
|
]
|
||||||
|
|
||||||
|
with open(OUTPUT, "w", newline="") as f:
|
||||||
|
writer = csv.DictWriter(f, fieldnames=FIELDNAMES)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
print(f"Wrote {OUTPUT} — {N:,} rows")
|
||||||
|
print("Scenario distribution (approximate):")
|
||||||
|
from collections import Counter
|
||||||
|
dist = Counter(random.choices(scenario_names, weights=scenario_weights, k=N))
|
||||||
|
for name, count in sorted(dist.items(), key=lambda x: -x[1]):
|
||||||
|
print(f" {name:<22} ~{count}")
|
||||||
Loading…
Reference in a new issue