import { useState } from 'react'; import { Bar, BarChart, CartesianGrid, Cell, ComposedChart, Line, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis, } from 'recharts'; import { Table2 } from 'lucide-react'; import type { BriefingData, Slice } from '../api/charts'; import { RECOVERY_ORDER } from '../api/charts'; import { useTheme } from '../theme'; import { money, moneyShort, shortDate } from '../format'; /** * The briefing charts — four pictures that carry the case. * * FORM FIRST, COLOUR LAST (the dataviz method): * 1. The spike — one line, the flagged slice's weekly denials, with the * bulletin date drawn as a reference line. EMPHASIS form: * weeks after the spike in the accent hue, before in gray. * 2. Where it sits — payer denial rate, baseline vs recent, grouped bar. * Two series: gray = before, accent = now. * 3. The money — the flagged slice's denied claims by what can still * happen to them. Stacked horizontal bar, one row. * 4. Expiring — unfiled appeals closing within 14 days, by payer. * Single hue, sorted; the axis carries identity. * * COLOURS are the validated reference slots (blue 1, orange 2, green 6, red 8) * stepped per theme — run through scripts/validate_palette.js on THIS console's * surfaces (#ffffff / #10233a): all checks pass. The brand teal deliberately is * NOT used as a data hue — it fails the chroma floor and would read as gray on * a chart. Gray is the de-emphasis hue of the emphasis form, never a series. * * Every chart has a table twin (the ⊞ toggle) so no value lives only in colour * or only in a tooltip. */ const HUES = { light: { accent: '#2a78d6', gray: '#94a3af', good: '#008300', warn: '#eb6834', bad: '#e34948', violet: '#4a3aa7', ink: '#0f2233', muted: '#5a7089', grid: '#e3eef1', ai: '#5b2ca0' }, dark: { accent: '#3987e5', gray: '#6f8595', good: '#22a422', warn: '#d95926', bad: '#e66767', violet: '#9085e9', ink: '#e7f1f5', muted: '#93b2c0', grid: '#22405c', ai: '#c9b3f2' }, }; const RECOVERY_HUE: Record = { recovered: 'good', 'appeal filed': 'accent', 'still appealable': 'warn', // Violet, not red: red sits beside orange in the stack and the two fail the // adjacent-pair check under deutan and even normal vision (ΔE 7.1). 'lost on appeal': 'violet', 'window closed': 'gray', }; export function Briefing({ data, slice }: { data: BriefingData; slice: Slice | null }) { const { theme } = useTheme(); const c = HUES[theme]; const title = slice ? [slice.payer, slice.department, slice.code].filter(Boolean).join(' · ') : 'the hospital'; return (
{data.recovery.length > 0 && } {data.expiring.length > 0 && }
); } // --------------------------------------------------------------------------- function Frame({ title, caption, table, children, }: { title: string; caption: string; table: { head: string[]; rows: (string | number)[][] }; children: React.ReactNode; }) { const [showTable, setShowTable] = useState(false); return (
{title} {caption}
{showTable ? (
{table.head.map((h) => ( ))} {table.rows.map((r, i) => ( {r.map((v, j) => ( ))} ))}
{h}
{v}
) : ( children )}
); } const tip = (c: typeof HUES.light) => ({ contentStyle: { background: 'var(--color-panel)', border: '1px solid var(--color-line)', borderRadius: 8, fontSize: 12, color: c.ink }, labelStyle: { color: c.muted, fontWeight: 600 }, itemStyle: { color: c.ink }, cursor: { stroke: c.grid }, }); const axis = (c: typeof HUES.light) => ({ tick: { fill: c.muted, fontSize: 11 }, axisLine: { stroke: c.grid }, tickLine: false as const }); // 1. The spike ---------------------------------------------------------------- function SpikeChart({ data, slice, c, title }: { data: BriefingData; slice: Slice | null; c: typeof HUES.light; title: string }) { const pts = data.weeklySlice.length ? data.weeklySlice : data.weeklyRate; const isSlice = data.weeklySlice.length > 0; const spike = slice?.spikeStart ?? null; const rows = pts.map((p) => ({ ...p, label: shortDate(p.week) })); return ( [r.label, isSlice ? r.denied : `${r.pct}%`]) }} > (isSlice ? String(v) : `${v}%`)} /> (isSlice ? [`${Number(v)} denied`, ''] : [`${Number(v)}%`, 'denial rate'])} /> {isSlice ? ( {rows.map((r) => ( = spike ? c.accent : c.gray} /> ))} ) : ( )} {spike && ( )} ); } // 2. Where it sits ------------------------------------------------------------ function PayerChart({ data, slice, c }: { data: BriefingData; slice: Slice | null; c: typeof HUES.light }) { const rows = data.payers.map((p) => ({ ...p, short: p.payer.replace(' Health Plan', '').replace(' Health', '').replace(' Mutual', '') })); return ( [r.payer, `${r.rateBase}%`, `${r.rateRecent}%`]) }} > `${v}%`} /> [`${Number(v)}%`, String(name) === 'rateBase' ? 'before' : 'now']} /> {rows.map((r) => ( ))} ); } // 3. The money ---------------------------------------------------------------- function RecoveryChart({ data, c, title }: { data: BriefingData; c: typeof HUES.light; title: string }) { const total = data.recovery.reduce((t, b) => t + b.billed, 0); const one = Object.fromEntries(data.recovery.map((b) => [b.bucket, b.billed])) as Record; return ( [b.bucket, b.claims, money(b.billed)]) }} >
''} /> [money(Number(v)), String(name)]} /> {RECOVERY_ORDER.filter((b) => one[b]).map((b, i, arr) => ( ))}
    {data.recovery.map((b) => (
  • {b.bucket} {moneyShort(b.billed)}
  • ))}
); } // 4. Expiring ------------------------------------------------------------------- function ExpiringChart({ data, c }: { data: BriefingData; c: typeof HUES.light }) { const rows = data.expiring.map((e) => ({ ...e, short: e.payer.replace(' Health Plan', '').replace(' Health', '').replace(' Mutual', '') })); const total = rows.reduce((t, r) => t + r.billed, 0); return ( [r.payer, r.claims, money(r.billed), shortDate(r.firstDeadline)]) }} > [money(Number(v)), 'unfiled, expiring']} /> moneyShort(Number(v)) }} /> ); }