- 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>
165 lines
No EOL
4.6 KiB
JavaScript
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 };
|
|
} |