A case now carries pictures, and a button that turns it into a deck. THE CHARTS (Briefing.tsx) answer the same three questions the page does, visually: the spike (the flagged slice's weekly denials, with the payer's rule change drawn as a line — weeks after it in the accent, before in gray), where it sits (denial rate by insurer, baseline vs now), where the money stands (the slice's denied dollars by what can still happen to them), and appeals about to expire by insurer. Every chart has a table twin. Data comes from six pre-aggregated Databricks views (sm2/…/chart_views.py) read through the ordinary record-view API, filtered by the slice Ava now records in four structured fields (slice_payer, slice_department, slice_reason_code, spike_start). Nothing is computed from raw claims in the browser. COLOUR WAS COMPUTED, NOT EYEBALLED. The palette is the dataviz reference set (blue 1, orange 2, green 6, violet 7, gray) run through validate_palette.js against this console's own surfaces, both themes — all checks pass. Two things the validator caught: the brand teal fails the chroma floor and would read as gray on a chart, so it is never a data hue; and red beside orange in the recovery stack fails adjacent-pair separation even for normal vision (ΔE 7.1), so "lost on appeal" is violet. THE SLIDES (lib/slides.ts) are six native-chart PowerPoint slides built in the browser with pptxgenjs from the same rows: what happened, the spike, where it sits, the money, the recommendations, the method. Text is Ava's verbatim. Lazy-imported so the 700 KB library is paid for only on click. Backend counterpart: sm2/custom-apps/denials-investigator/10_briefing.sql. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
283 lines
13 KiB
TypeScript
283 lines
13 KiB
TypeScript
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<string, keyof typeof HUES.light> = {
|
|
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 (
|
|
<section className="space-y-3">
|
|
<div className="grid gap-3 lg:grid-cols-2">
|
|
<SpikeChart data={data} slice={slice} c={c} title={title} />
|
|
<PayerChart data={data} slice={slice} c={c} />
|
|
{data.recovery.length > 0 && <RecoveryChart data={data} c={c} title={title} />}
|
|
{data.expiring.length > 0 && <ExpiringChart data={data} c={c} />}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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 (
|
|
<figure className="lift rounded-xl border border-line bg-panel px-4 pt-3.5 pb-2">
|
|
<figcaption className="flex items-start gap-2">
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block text-sm font-semibold text-ink">{title}</span>
|
|
<span className="block text-xs text-muted">{caption}</span>
|
|
</span>
|
|
<button
|
|
onClick={() => setShowTable(!showTable)}
|
|
title={showTable ? 'Show chart' : 'Show as table'}
|
|
aria-label={showTable ? 'Show chart' : 'Show as table'}
|
|
className="rounded p-1 text-faint hover:bg-panel2 hover:text-ink"
|
|
>
|
|
<Table2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
</figcaption>
|
|
<div className="mt-2 h-[220px]">
|
|
{showTable ? (
|
|
<div className="h-full overflow-auto">
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr>
|
|
{table.head.map((h) => (
|
|
<th key={h} className="border-b border-line px-2 py-1 text-left font-semibold text-faint">
|
|
{h}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{table.rows.map((r, i) => (
|
|
<tr key={i}>
|
|
{r.map((v, j) => (
|
|
<td key={j} className={`tnum border-b border-line px-2 py-1 ${j ? 'text-right' : ''} text-ink`}>
|
|
{v}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
children
|
|
)}
|
|
</div>
|
|
</figure>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Frame
|
|
title={isSlice ? `Denied claims per week — ${title}` : 'Hospital denial rate per week'}
|
|
caption={spike ? `Vertical line: the payer's rule change, ${shortDate(spike)}. Bars after it are the spike.` : 'Six months, one point per week'}
|
|
table={{ head: ['Week', isSlice ? 'Denied' : 'Denial %'], rows: rows.map((r) => [r.label, isSlice ? r.denied : `${r.pct}%`]) }}
|
|
>
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<ComposedChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -18 }}>
|
|
<CartesianGrid vertical={false} stroke={c.grid} />
|
|
<XAxis dataKey="label" {...axis(c)} interval="preserveStartEnd" minTickGap={28} />
|
|
<YAxis {...axis(c)} width={44} tickFormatter={(v) => (isSlice ? String(v) : `${v}%`)} />
|
|
<Tooltip {...tip(c)} formatter={(v: unknown) => (isSlice ? [`${Number(v)} denied`, ''] : [`${Number(v)}%`, 'denial rate'])} />
|
|
{isSlice ? (
|
|
<Bar dataKey="denied" radius={[4, 4, 0, 0]} maxBarSize={18}>
|
|
{rows.map((r) => (
|
|
<Cell key={r.week} fill={spike && r.week >= spike ? c.accent : c.gray} />
|
|
))}
|
|
</Bar>
|
|
) : (
|
|
<Line type="monotone" dataKey="pct" stroke={c.accent} strokeWidth={2} dot={false} activeDot={{ r: 4 }} />
|
|
)}
|
|
{spike && (
|
|
<ReferenceLine
|
|
x={shortDate(spike)}
|
|
stroke={c.ai}
|
|
strokeDasharray="4 3"
|
|
label={{ value: 'rule change', position: 'insideTopLeft', fill: c.ai, fontSize: 11 }}
|
|
/>
|
|
)}
|
|
</ComposedChart>
|
|
</ResponsiveContainer>
|
|
</Frame>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<Frame
|
|
title="Denial rate by insurer — before vs now"
|
|
caption="Gray is the 8-week baseline; blue is the last 6 weeks. One insurer moved."
|
|
table={{ head: ['Insurer', 'Before', 'Now'], rows: rows.map((r) => [r.payer, `${r.rateBase}%`, `${r.rateRecent}%`]) }}
|
|
>
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -18 }} barGap={2} barCategoryGap="28%">
|
|
<CartesianGrid vertical={false} stroke={c.grid} />
|
|
<XAxis dataKey="short" {...axis(c)} interval={0} />
|
|
<YAxis {...axis(c)} width={44} tickFormatter={(v) => `${v}%`} />
|
|
<Tooltip {...tip(c)} formatter={(v: unknown, name: unknown) => [`${Number(v)}%`, String(name) === 'rateBase' ? 'before' : 'now']} />
|
|
<Bar dataKey="rateBase" name="rateBase" fill={c.gray} radius={[4, 4, 0, 0]} maxBarSize={22} />
|
|
<Bar dataKey="rateRecent" name="rateRecent" radius={[4, 4, 0, 0]} maxBarSize={22}>
|
|
{rows.map((r) => (
|
|
<Cell key={r.payer} fill={!slice?.payer || r.payer === slice.payer ? c.accent : c.gray} />
|
|
))}
|
|
</Bar>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</Frame>
|
|
);
|
|
}
|
|
|
|
// 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<string, number>;
|
|
return (
|
|
<Frame
|
|
title={`Where the money stands — ${title}`}
|
|
caption={`${money(total)} denied since the baseline began. Orange is still recoverable if appeals are filed.`}
|
|
table={{ head: ['Status', 'Claims', 'Billed'], rows: data.recovery.map((b) => [b.bucket, b.claims, money(b.billed)]) }}
|
|
>
|
|
<div className="flex h-full flex-col justify-center gap-3">
|
|
<ResponsiveContainer width="100%" height={56}>
|
|
<BarChart data={[one]} layout="vertical" margin={{ top: 0, right: 8, bottom: 0, left: 8 }} barCategoryGap={0}>
|
|
<XAxis type="number" hide />
|
|
<YAxis type="category" hide dataKey={() => ''} />
|
|
<Tooltip {...tip(c)} formatter={(v: unknown, name: unknown) => [money(Number(v)), String(name)]} />
|
|
{RECOVERY_ORDER.filter((b) => one[b]).map((b, i, arr) => (
|
|
<Bar
|
|
key={b}
|
|
dataKey={b}
|
|
stackId="m"
|
|
fill={c[RECOVERY_HUE[b]]}
|
|
stroke="var(--color-panel)"
|
|
strokeWidth={2}
|
|
radius={i === 0 ? [6, 0, 0, 6] : i === arr.length - 1 ? [0, 6, 6, 0] : 0}
|
|
/>
|
|
))}
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
<ul className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs sm:grid-cols-3">
|
|
{data.recovery.map((b) => (
|
|
<li key={b.bucket} className="flex items-center gap-2">
|
|
<span className="h-2.5 w-2.5 shrink-0 rounded-sm" style={{ background: c[RECOVERY_HUE[b.bucket]] }} />
|
|
<span className="text-muted">{b.bucket}</span>
|
|
<span className="tnum ml-auto font-semibold text-ink">{moneyShort(b.billed)}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</Frame>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<Frame
|
|
title="Appeals about to expire, by insurer"
|
|
caption={`${money(total)} of unfiled appeals close within 14 days. Nobody has to find these — she did.`}
|
|
table={{ head: ['Insurer', 'Claims', 'Billed', 'First deadline'], rows: rows.map((r) => [r.payer, r.claims, money(r.billed), shortDate(r.firstDeadline)]) }}
|
|
>
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={rows} layout="vertical" margin={{ top: 4, right: 56, bottom: 0, left: 8 }} barCategoryGap="30%">
|
|
<CartesianGrid horizontal={false} stroke={c.grid} />
|
|
<XAxis type="number" hide />
|
|
<YAxis type="category" dataKey="short" {...axis(c)} width={70} />
|
|
<Tooltip {...tip(c)} formatter={(v: unknown) => [money(Number(v)), 'unfiled, expiring']} />
|
|
<Bar dataKey="billed" fill={c.accent} radius={[0, 4, 4, 0]} maxBarSize={18} label={{ position: 'right', fill: c.ink, fontSize: 11, formatter: (v: unknown) => moneyShort(Number(v)) }} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</Frame>
|
|
);
|
|
}
|