Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
Telehealth and practice management on the CoinPay/Profullstack stack: scheduling,
prescriptions, tests and labs, after-visit summaries, notifications and newsletters,
AI follow-up calls on every appointment (Telnyx), and health-record import from any
provider. One account can own many orgs; each org has locations, a manager and a bill.
provider. Patient advocates (navigators) keep care plans and log their time
against care-management programs; the monthly superbill turns those minutes into
Medicare's PIN (G0023/G0024), CHI (G0019/G0022) and CCM (99490/99439) codes. One account can own many orgs; each org has locations, a manager and a bill.

**Pricing:** each org pays for every user on its team: $10/seat/month, or $199/month for
up to 1,000 seats, whichever is lower. Patients and leads are free.
Expand Down
6 changes: 6 additions & 0 deletions apps/web/public/app/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,9 @@ dialog .m-b { padding: 18px 20px 20px; }
.hero-card { border: 1px solid var(--accent); background: #0F1A15; border-radius: 18px; padding: 20px; display: flex; flex-direction: column; gap: 12px; }
.eyebrow { font-family: var(--mono); font-size: 12px; letter-spacing: .06em; color: var(--accent); text-transform: uppercase; }
.keybox { font-family: var(--mono); font-size: 13px; background: #080B0A; border: 1px solid var(--line-2); border-radius: 10px; padding: 12px; word-break: break-all; }

/* navigation: minutes toward the next billable unit */
.meter { position: relative; height: 6px; border-radius: 99px; background: #1C2220; overflow: hidden; min-width: 90px; }
.meter > i { position: absolute; inset: 0 auto 0 0; background: var(--accent); border-radius: 99px; }
.meter.held > i { background: var(--warn); }
.done-text { text-decoration: line-through; color: var(--dim); }
345 changes: 340 additions & 5 deletions apps/web/public/app/app.js

Large diffs are not rendered by default.

335 changes: 330 additions & 5 deletions apps/web/src/api.js

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion apps/web/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,9 @@ app.get('/llms.txt', (c) =>

> Telehealth and practice management: scheduling across offices, prescriptions and
> refills, labs, after-visit summaries and a patient portal. One account can own many
> practices; each has locations, a team and its own bill.
> practices; each has locations, a team and its own bill. Patient advocates run care
> plans and log navigation time, and the superbill turns it into Medicare's
> care-management codes (PIN, CHI, CCM).

Pricing: $10 per team seat per month, or $199/month for up to 1,000 seats. Patients and leads are free.

Expand All @@ -143,6 +145,14 @@ Authenticate with a session cookie or \`Authorization: Bearer th_live_...\` (cre
- GET|PUT /orgs/:org/call-settings AI calls: on/off, hours before/after, calling window, attempts
- POST /orgs/:org/appointments/:id/call call the patient now ({"kind":"reminder"|"followup"})
- GET /orgs/:org/calls/:id outcome, summary and transcript; POST .../resolve when handled
- GET|PUT /orgs/:org/patients/:id/care-plan, POST .../care-plan/items, PATCH /orgs/:org/care-plan-items/:id
goals and tasks with owners and due dates; shared plans show in the portal
- POST /orgs/:org/patients/:id/programs enroll in PIN, PIN peer support, CHI or CCM (consent, initiating visit, billing practitioner)
- PATCH /orgs/:org/programs/:id consent (renews it), billing practitioner, end
- POST /orgs/:org/programs/:id/time log navigation minutes ({"minutes", "activity", "note", "performed_on"})
- GET /orgs/:org/caseload?month=YYYY-MM enrollments, minutes toward G0023/G0024, G0019/G0022, 99490/99439, open tasks
- GET /orgs/:org/superbill?month=YYYY-MM&rule=full|midpoint&format=csv
the month's billable codes per patient, ready or on hold with the reason

## Tools
- CLI + TUI: npm i -g @profullstack/tleehealth (or: curl -fsSL https://tleehealth.com/install.sh | sh)
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export async function findOrCreateUser(email) {
return user;
}

const ORG_ROLE = { owner: 'owner', org_manager: 'admin', provider: 'member', staff: 'member' };
const ORG_ROLE = { owner: 'owner', org_manager: 'admin', provider: 'member', staff: 'member', advocate: 'member' };

/**
* Attach every org_people row added under this address to the account, and give
Expand Down
188 changes: 188 additions & 0 deletions apps/web/src/navigation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/**
* Care-management programs and the Medicare time-based codes they bill.
*
* Pure functions: the API reads minutes from care_time and asks this module what
* they add up to. Prices vary by locality and change yearly, so none are kept
* here; a superbill lists codes and units, and the biller prices them.
*
* Time rules. "full" counts a unit only when its whole time is met: G0023 at 60
* minutes, each G0024 at another 30. "midpoint" applies the CPT rule that a unit
* of time is attained once its midpoint is passed (G0023 at 31, G0024 at 16 more).
* Whether a practice's payer accepts the midpoint for these codes is the biller's
* call, so full is the default. CCM's codes say "at least 20 minutes" in the
* descriptor, so the midpoint never applies to them.
*/

export const PROGRAMS = {
pin: {
name: 'Principal Illness Navigation',
short: 'PIN',
first: { code: 'G0023', minutes: 60 },
addon: { code: 'G0024', minutes: 30, max: null },
yearlyConsent: true,
about: 'One serious, high-risk condition expected to last at least 3 months.',
},
pin_ps: {
name: 'Principal Illness Navigation, peer support',
short: 'PIN-PS',
first: { code: 'G0140', minutes: 60 },
addon: { code: 'G0146', minutes: 30, max: null },
yearlyConsent: true,
about: 'PIN for a serious behavioral health condition, by a certified peer specialist.',
},
chi: {
name: 'Community Health Integration',
short: 'CHI',
first: { code: 'G0019', minutes: 60 },
addon: { code: 'G0022', minutes: 30, max: null },
yearlyConsent: true,
about: 'Upstream drivers (housing, food, transport) that get in the way of the treatment plan.',
},
ccm: {
name: 'Chronic Care Management',
short: 'CCM',
first: { code: '99490', minutes: 20 },
addon: { code: '99439', minutes: 20, max: 2 },
yearlyConsent: false,
exactTime: true,
needsCarePlan: true,
about: 'Two or more chronic conditions expected to last 12 months or more, with a care plan.',
},
};

export const ACTIVITIES = [
'assessment',
'care_plan',
'coordination',
'referral',
'prior_auth',
'scheduling',
'education',
'community_resources',
'call',
'other',
];

export const TIME_RULES = ['full', 'midpoint'];

/** Minutes past which a unit of `minutes` counts under a rule. */
function threshold(minutes, rule, exactTime) {
return rule === 'midpoint' && !exactTime ? Math.floor(minutes / 2) + 1 : minutes;
}

/**
* What a month's minutes bill under a program: [{ code, units }], and how many
* more minutes reach the next unit (null when the add-on is capped out).
*/
export function unitsFor(program, totalMinutes, rule = 'full') {
const p = PROGRAMS[program];
if (!p) throw new Error(`unknown program ${program}`);
const total = Math.max(0, Math.floor(totalMinutes || 0));
const firstAt = threshold(p.first.minutes, rule, p.exactTime);
if (total < firstAt) return { lines: [], next: firstAt - total };

const lines = [{ code: p.first.code, units: 1 }];
// Add-on units start counting after the first code's full time, even under the
// midpoint rule: the first unit has to be complete before the next one begins.
const past = total - p.first.minutes;
const addAt = threshold(p.addon.minutes, rule, p.exactTime);
let addUnits = past < addAt ? 0 : 1 + Math.floor((past - addAt) / p.addon.minutes);
if (p.addon.max != null) addUnits = Math.min(addUnits, p.addon.max);
if (addUnits) lines.push({ code: p.addon.code, units: addUnits });

const capped = p.addon.max != null && addUnits >= p.addon.max;
return { lines, next: capped ? null : p.first.minutes + addUnits * p.addon.minutes + addAt - total };
}

/** 'YYYY-MM' -> { start: 'YYYY-MM-01', end: first day of the next month }. */
export function monthRange(month) {
if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(month ?? '')) throw new Error('month must be YYYY-MM');
const [y, m] = month.split('-').map(Number);
const pad = (n) => String(n).padStart(2, '0');
const next = m === 12 ? `${y + 1}-01` : `${y}-${pad(m + 1)}`;
return { start: `${month}-01`, end: `${next}-01` };
}

export const thisMonth = (d = new Date()) => d.toISOString().slice(0, 7);

export const ymd = (d) => (d == null ? null : d instanceof Date ? d.toISOString().slice(0, 10) : String(d).slice(0, 10));

/**
* What stops an enrollment from billing this month. Empty means ready.
* enrollment: a care_programs row plus `billing_npi` and `has_care_plan`.
*/
export function blockers(enrollment, month) {
const p = PROGRAMS[enrollment.program];
const { start, end } = monthRange(month);
const out = [];
if (!enrollment.consent_at) out.push('no consent on file');
else {
const consent = ymd(enrollment.consent_at);
if (consent >= end) out.push('consent was recorded after this month');
else if (p.yearlyConsent) {
// Renewed yearly: consent must be under a year old on the month's last day.
const last = new Date(`${end}T00:00:00Z`);
last.setUTCDate(last.getUTCDate() - 1);
const yearBefore = new Date(last);
yearBefore.setUTCFullYear(yearBefore.getUTCFullYear() - 1);
if (consent <= ymd(yearBefore)) out.push('consent is over a year old; renew it');
}
}
const visit = ymd(enrollment.initiating_visit_on);
if (!visit) out.push('no initiating visit with the billing practitioner');
else if (visit >= end) out.push('the initiating visit is after this month');
if (!enrollment.billing_provider_id) out.push('no billing practitioner');
else if (!enrollment.billing_npi) out.push("the billing practitioner's NPI is missing");
if (!enrollment.condition) out.push('no condition recorded');
if (p.needsCarePlan && !enrollment.has_care_plan) out.push('needs a care plan');
if (ymd(enrollment.started_on) >= end) out.push('enrolled after this month');
if (enrollment.status === 'ended' && ymd(enrollment.ended_on) < start) out.push('ended before this month');
return out;
}

/** One superbill row per enrollment with time this month. */
export function superbillRow(enrollment, minutes, month, rule) {
const { lines, next } = unitsFor(enrollment.program, minutes, rule);
const blocked = blockers(enrollment, month);
return {
program_id: enrollment.id,
patient_id: enrollment.patient_id,
patient: enrollment.patient,
dob: ymd(enrollment.dob),
program: enrollment.program,
program_name: PROGRAMS[enrollment.program].short,
condition: enrollment.condition,
billing_provider: enrollment.billing_provider,
billing_npi: enrollment.billing_npi,
navigator: enrollment.navigator,
minutes,
lines,
next_unit_in: next,
blockers: blocked,
ready: lines.length > 0 && blocked.length === 0,
};
}

const csvCell = (v) => {
const s = v == null ? '' : String(v);
// Leading = + - @ would run as a formula when the CSV is opened in a spreadsheet.
const safe = /^[=+\-@\t\r]/.test(s) ? `'${s}` : s;
return /[",\n\r]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
};

/** The superbill as CSV: one line per billable code, ready rows first. */
export function superbillCsv(rows, { month, practice }) {
const head = ['month', 'practice', 'patient', 'dob', 'program', 'condition', 'billing_provider', 'npi', 'code', 'units', 'minutes', 'status'];
const out = [head.join(',')];
for (const r of rows) {
const status = r.ready ? 'ready' : r.lines.length ? `hold: ${r.blockers.join('; ')}` : `under threshold (${r.next_unit_in} min to go)`;
const lines = r.lines.length ? r.lines : [{ code: '', units: 0 }];
for (const l of lines)
out.push(
[month, practice, r.patient, r.dob, r.program_name, r.condition, r.billing_provider, r.billing_npi, l.code, l.units, r.minutes, status]
.map(csvCell)
.join(','),
);
}
return `${out.join('\n')}\n`;
}
6 changes: 3 additions & 3 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@profullstack/tleehealth",
"version": "0.2.0",
"version": "0.3.0",
"description": "tleehealth from the terminal: the CLI, the front-desk TUI, and the API client the MCP server uses.",
"type": "module",
"license": "MIT",
Expand Down
58 changes: 55 additions & 3 deletions packages/cli/src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,31 @@
* tleehealth: the practice from the terminal.
*
* tleehealth schedule [--date today|tomorrow|YYYY-MM-DD] [--json]
* tleehealth caseload [--month YYYY-MM] [--all] [--json]
* tleehealth superbill [--month YYYY-MM] [--rule full|midpoint] [--csv] [--json]
* tleehealth log-time PROGRAM_ID MINUTES [--activity A] [--note TEXT] [--date YYYY-MM-DD]
* tleehealth health
* tleehealth login [KEY]
* tleehealth tui
*
* TLEEHEALTH_API_KEY and TLEEHEALTH_URL override the saved config.
* TLEEHEALTH_API_KEY and TLEEHEALTH_URL override the saved config; TLEEHEALTH_ORG
* (or --org) picks the practice when you are on more than one.
*/
import { createInterface } from 'node:readline/promises';
import { health, resolveAuth, saveConfig, schedule } from './client.js';
import { caseload, health, logTime, resolveAuth, saveConfig, schedule, superbill } from './client.js';

const HELP = `tleehealth: telehealth and practice management.

tleehealth schedule [--date today|tomorrow|YYYY-MM-DD] [--json] the front-desk day
tleehealth caseload [--month YYYY-MM] [--all] [--json] navigation patients and minutes this month
tleehealth superbill [--month YYYY-MM] [--rule full|midpoint] [--csv]
billable PIN/CHI/CCM codes for the month
tleehealth log-time PROGRAM_ID MINUTES [--activity A] [--note T] [--date YYYY-MM-DD]
tleehealth health is the API up
tleehealth login [KEY] save an API key
tleehealth tui the day view, as a screen

Environment: TLEEHEALTH_API_KEY, TLEEHEALTH_URL.`;
Environment: TLEEHEALTH_API_KEY, TLEEHEALTH_URL, TLEEHEALTH_ORG (or --org).`;

export function parse(argv) {
const positional = [];
Expand Down Expand Up @@ -46,6 +54,32 @@ export function formatDay(day) {
return lines.join('\n');
}

const codes = (r) => r.lines.map((l) => `${l.code}${l.units > 1 ? ` x${l.units}` : ''}`).join(' + ');

export function formatCaseload(c) {
const lines = [`caseload ${c.month}${c.mine ? ' (mine)' : ''}`];
if (!c.caseload.length) lines.push(' no active enrollments');
for (const r of c.caseload) {
const toGo = r.next_unit_in == null ? 'all units' : `${r.next_unit_in} min to next`;
const state = r.blockers.length ? `hold: ${r.blockers.join('; ')}` : r.lines.length ? 'ready' : '';
lines.push(
`${(r.patient ?? '').padEnd(18)} ${r.program_name.padEnd(7)} ${String(r.minutes).padStart(4)} min ${(codes(r) || '-').padEnd(16)} ${toGo.padEnd(16)} ${state} ${r.program_id}`,
);
}
return lines.join('\n');
}

export function formatSuperbill(sb) {
const lines = [`superbill ${sb.month} (${sb.rule} time): ${sb.ready} ready, ${sb.held} on hold, ${sb.under} under threshold`];
for (const r of sb.rows)
lines.push(
`${(r.patient ?? '').padEnd(18)} ${r.program_name.padEnd(7)} ${String(r.minutes).padStart(4)} min ${(codes(r) || '-').padEnd(16)} ${r.ready ? 'ready' : r.lines.length ? `hold: ${r.blockers.join('; ')}` : `${r.next_unit_in} min short`}`,
);
const totals = Object.entries(sb.totals);
if (totals.length) lines.push(`ready to bill: ${totals.map(([c, n]) => `${c} x${n}`).join(', ')}`);
return lines.join('\n');
}

export async function main(argv = process.argv.slice(2)) {
const { positional, flags } = parse(argv);
const [cmd, ...rest] = positional;
Expand All @@ -65,6 +99,24 @@ export async function main(argv = process.argv.slice(2)) {
console.log(flags.json ? JSON.stringify(day, null, 2) : formatDay(day));
return 0;
}
case 'caseload': {
const c = await caseload(auth, { org: flags.org, month: flags.month, all: Boolean(flags.all) });
console.log(flags.json ? JSON.stringify(c, null, 2) : formatCaseload(c));
return 0;
}
case 'superbill': {
const sb = await superbill(auth, { org: flags.org, month: flags.month, rule: flags.rule, csv: Boolean(flags.csv) });
console.log(flags.csv ? sb.trimEnd() : flags.json ? JSON.stringify(sb, null, 2) : formatSuperbill(sb));
return 0;
}
case 'log-time': {
const [program, minutes] = rest;
if (!program || !minutes) throw new Error('usage: tleehealth log-time PROGRAM_ID MINUTES [--activity A] [--note TEXT]');
const r = await logTime(auth, { org: flags.org, program, minutes, activity: flags.activity, note: flags.note, date: flags.date });
const p = r.program;
console.log(`${minutes} min logged. ${p.patient ?? ''} ${p.program_name}: ${p.minutes} min this month${p.lines.length ? `, ${codes(p)}` : ''}${p.blockers.length ? ` (hold: ${p.blockers.join('; ')})` : ''}`);
return 0;
}
case 'login': {
let key = rest[0];
if (!key) {
Expand Down
Loading
Loading