Signal/signal-ui/src/lib/coverage.js
Kisa a424ac9d13 feat: add reason strings per patient, fix export headers, add signal-ui source
- Add _build_reason() to backend — per-patient reason strings with specific
  day counts (e.g. "Supply lapsed 70 days ago. Prescriber contact required.")
- Add reason field to RecordOut model and backend /api/export CSV
- Fix export column headers: Coverage End Date → Resupply End Date,
  Days Until Coverage End → Days Until Resupply End
- Pass reason through apiRecordToLocal in frontend api.js
- Display reason as muted sub-line under status badge in WorklistTable
- Add reason column to client-side CSVExport
- Add signal-ui React source to repo (was untracked)
- CLAUDE.md: add Billing and CMS integrations to Phase 2 deferred table
- research: restore Section 14 stat verification (May 23 recovery)

Deployed to Railway production — health check confirmed live.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 09:45:02 -04:00

165 lines
No EOL
4.6 KiB
JavaScript

/**
* Client-side CGM coverage calculator — mirrors python-backend/core/coverage_calculator.py.
* Used as fallback when the Signal API is unreachable.
*
* PHI CONTRACT: This module receives only patient_id, device_type, shipment_date,
* quantity, payer, component. No names, SSNs, DOBs, or contact fields.
*/
export const FLAG = {
OUT_OF_COVERAGE: "OUT_OF_COVERAGE",
VISIT_DUE: "VISIT_DUE",
REFILL_WINDOW: "REFILL_WINDOW",
OK: "OK",
};
const FLAG_LABELS = {
[FLAG.OUT_OF_COVERAGE]: "Supply Lapsed",
[FLAG.VISIT_DUE]: "Renewal Due",
[FLAG.REFILL_WINDOW]: "Resupply Ready",
[FLAG.OK]: "Active",
};
const FLAG_ACTIONS = {
[FLAG.OUT_OF_COVERAGE]: "Contact Prescriber",
[FLAG.VISIT_DUE]: "Request Renewal",
[FLAG.REFILL_WINDOW]: "Initiate Resupply",
[FLAG.OK]: "No action needed",
};
/** Wear-day rules — mirrors payer_rules.json */
const DEVICE_RULES = {
dexcom_g7: { display: "Dexcom G7", sensor: 10 },
dexcom_g6: { display: "Dexcom G6", sensor: 10, transmitter: 90 },
freestyle_libre_2: { display: "FreeStyle Libre 2", sensor: 14 },
freestyle_libre_3: { display: "FreeStyle Libre 3", sensor: 14 },
omnipod_5: { display: "Omnipod 5", pod: 3, sensor: 14 },
};
export function getDeviceDisplay(deviceType) {
return DEVICE_RULES[deviceType]?.display ?? deviceType;
}
export function getFlagLabel(flag) {
return FLAG_LABELS[flag] ?? flag;
}
export function getFlagAction(flag) {
return FLAG_ACTIONS[flag] ?? "Review";
}
function today() {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}
function daysDiff(a, b) {
return Math.round((a.getTime() - b.getTime()) / 86400000);
}
function addDays(dateStr, days) {
const d = new Date(dateStr + "T00:00:00");
d.setDate(d.getDate() + days);
return d;
}
function getWearDays(deviceType, component) {
const device = DEVICE_RULES[deviceType];
if (!device) return null;
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 };
}
function computePriority(flag, daysUntilEnd) {
if (flag === FLAG.OUT_OF_COVERAGE) return 1000 + Math.abs(daysUntilEnd);
if (flag === FLAG.VISIT_DUE) return 500 + Math.max(0, 90 - daysUntilEnd);
if (flag === FLAG.REFILL_WINDOW) return 200 + Math.max(0, 30 - daysUntilEnd);
return 0;
}
/**
* Calculate coverage for a single record.
* @param {{ patient_id, device_type, shipment_date, quantity, payer, component }} record
* @returns {{ flag, daysUntilEnd, daysUntilVisit, priority }} or null on error
*/
export function calculateCoverage(record) {
const wearDays = getWearDays(record.device_type, record.component);
if (wearDays === null) return null;
const now = today();
const totalWear = wearDays * parseInt(record.quantity, 10);
const coverageEnd = addDays(record.shipment_date, totalWear);
const daysUntilEnd = daysDiff(coverageEnd, now);
const payerCfg = getPayerConfig(record.payer);
let daysUntilVisit = null;
if (payerCfg.visitRenewalDays) {
const visitDue = addDays(record.shipment_date, payerCfg.visitRenewalDays);
daysUntilVisit = daysDiff(visitDue, now);
}
let flag;
if (daysUntilEnd < 0) {
flag = FLAG.OUT_OF_COVERAGE;
} else if (daysUntilVisit !== null && daysUntilVisit <= 30) {
flag = FLAG.VISIT_DUE;
} else if (daysUntilEnd <= payerCfg.refillWindowDays) {
flag = FLAG.REFILL_WINDOW;
} else {
flag = FLAG.OK;
}
return {
...record,
flag,
daysUntilEnd,
daysUntilVisit,
priority: computePriority(flag, daysUntilEnd),
coverageEndDate: coverageEnd.toISOString().slice(0, 10),
};
}
/**
* Parse raw CSV text into record objects.
*/
export function parseCSV(text) {
const lines = text.trim().split(/\r?\n/);
if (lines.length < 2) return [];
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
return lines
.slice(1)
.filter((l) => l.trim())
.map((line) => {
const cols = line.split(",").map((c) => c.trim());
const row = {};
headers.forEach((h, i) => (row[h] = cols[i] || ""));
return row;
});
}
/**
* Process a batch of parsed CSV rows and return sorted results + skip list.
*/
export function processBatch(rows) {
const results = [];
const skipped = [];
for (const row of rows) {
const result = calculateCoverage(row);
if (result) {
results.push(result);
} else {
skipped.push(row.patient_id || "?");
}
}
results.sort((a, b) => b.priority - a.priority);
return { results, skipped };
}