From 1d2dc4a1b9c32a04fff00c44ba3109f178145f6f Mon Sep 17 00:00:00 2001 From: Yashas Date: Wed, 16 Sep 2026 16:31:43 +0530 Subject: [PATCH] fix: briefing picks charts by finding kind, folds partial slices, honours replay dates - expiring_appeals / slow_payer / underpayment no longer get the denial-spike charts sliced by payer alone (one bar per department x code) - weekly and recovery rows folded to their chart grain - replayed mornings show only the trend, cut at the replay date - new charts: payer speed (slow_payer) and appeal scorecard - y-axis ticks no longer clipped at two digits Co-Authored-By: Claude Fable 5.1 --- src/api/charts.ts | 97 ++++++++++++++++++++++++--- src/components/Briefing.tsx | 127 +++++++++++++++++++++++++++++++----- src/lib/slides.ts | 6 +- src/screens/CaseScreen.tsx | 9 +-- 4 files changed, 208 insertions(+), 31 deletions(-) diff --git a/src/api/charts.ts b/src/api/charts.ts index f5403f6..73fc236 100644 --- a/src/api/charts.ts +++ b/src/api/charts.ts @@ -19,8 +19,21 @@ export const CHART_VIEWS = { reasonRecentVsBase: 'dni-rv-chart-reason-recent-vs-base', expiringAppeals: 'dni-rv-chart-expiring-appeals', sliceRecovery: 'dni-rv-chart-slice-recovery', + payerSpeed: 'dni-rv-chart-payer-speed', + appealOutcomes: 'dni-rv-chart-appeal-outcomes', } as const; +/** Finding kinds that describe a DENIAL SPIKE in one payer/department/code + * slice. Only these get the slice-filtered weekly bars and recovery stack; + * the money-timing kinds (expiring appeals, slow payer, underpayment) name a + * payer at most, and slicing the views by payer alone returns one row per + * department x code — hundreds of bars, not a chart. */ +export const DENIAL_KINDS = new Set([ + 'payer_policy', 'front_desk', 'coding', 'provider_coding', 'documentation', + 'medical_necessity', 'timely_filing', 'other', '', +]); +export const isDenialKind = (kind: string) => DENIAL_KINDS.has(kind); + export interface Slice { payer: string; department: string; @@ -68,6 +81,21 @@ export interface RecoveryBucket { claims: number; billed: number; } +export interface PayerSpeed { + payer: string; + claims: number; + pending: number; + pendingPct: number; + medianDays: number; + pendingBilled: number; +} +export interface AppealScore { + payer: string; + resolved: number; + overturned: number; + overturnPct: number; + recovered: number; +} export interface BriefingData { weeklyRate: WeekPoint[]; @@ -76,6 +104,23 @@ export interface BriefingData { reasons: ReasonCompare[]; expiring: Expiring[]; recovery: RecoveryBucket[]; + speed: PayerSpeed[]; + scorecard: AppealScore[]; + /** Set when the case replayed a past morning. The views are anchored on the + * lakehouse's latest date, so only the time series can honour it. */ + asOf: string | null; +} + +/** Views filtered by a PARTIAL slice come back one row per finer key; fold them + * to the grain the chart draws. */ +function sumBy(rows: T[], key: (r: T) => string, add: (acc: T, r: T) => T): T[] { + const m = new Map(); + for (const r of rows) { + const k = key(r); + const prev = m.get(k); + m.set(k, prev ? add(prev, r) : r); + } + return [...m.values()]; } const byWeek = (a: { week: string }, b: { week: string }) => a.week.localeCompare(b.week); @@ -91,9 +136,14 @@ function week(r: Row): WeekPoint { }; } -export async function loadBriefing(client: ZinoClient, slice: Slice | null): Promise { +export async function loadBriefing( + client: ZinoClient, + slice: Slice | null, + opts: { kind?: string; asOf?: string | null } = {}, +): Promise { const f = (field_key: string, value: string) => ({ field_key, value, data_type: 'string' }); - const sliceFilters = slice + const asOf = opts.asOf?.slice(0, 10) || null; + const sliceFilters = slice && isDenialKind(opts.kind ?? '') ? [ ...(slice.payer ? [f('payer', slice.payer)] : []), ...(slice.department ? [f('department', slice.department)] : []), @@ -101,18 +151,26 @@ export async function loadBriefing(client: ZinoClient, slice: Slice | null): Pro ] : []; - const [rate, sl, payers, reasons, expiring, recovery] = await Promise.all([ + const [rate, sl, payers, reasons, expiring, recovery, speed, scorecard] = await Promise.all([ client.rows(CHART_VIEWS.weeklyRate, { limit: 200 }), sliceFilters.length ? client.rows(CHART_VIEWS.weeklyBySlice, { limit: 200, filters: sliceFilters }) : Promise.resolve([] as Row[]), client.rows(CHART_VIEWS.payerRecentVsBase, { limit: 50 }), client.rows(CHART_VIEWS.reasonRecentVsBase, { limit: 50 }), client.rows(CHART_VIEWS.expiringAppeals, { limit: 50 }), - sliceFilters.length ? client.rows(CHART_VIEWS.sliceRecovery, { limit: 50, filters: sliceFilters }) : Promise.resolve([] as Row[]), + sliceFilters.length ? client.rows(CHART_VIEWS.sliceRecovery, { limit: 200, filters: sliceFilters }) : Promise.resolve([] as Row[]), + client.rows(CHART_VIEWS.payerSpeed, { limit: 50 }).catch(() => [] as Row[]), + client.rows(CHART_VIEWS.appealOutcomes, { limit: 50 }).catch(() => [] as Row[]), ]); + const upTo = (pts: WeekPoint[]) => (asOf ? pts.filter((p) => p.week <= asOf) : pts); + const foldWeeks = (pts: WeekPoint[]) => + sumBy(pts, (p) => p.week, (a, b) => ({ ...a, claims: a.claims + b.claims, denied: a.denied + b.denied })) + .map((p) => ({ ...p, pct: p.claims ? Math.round((1000 * p.denied) / p.claims) / 10 : 0 })); + return { - weeklyRate: rate.map(week).sort(byWeek), - weeklySlice: sl.map(week).sort(byWeek), + asOf, + weeklyRate: upTo(rate.map(week).sort(byWeek)), + weeklySlice: upTo(foldWeeks(sl.map(week)).sort(byWeek)), payers: payers .map((r) => ({ payer: str(r.payer), @@ -139,9 +197,30 @@ export async function loadBriefing(client: ZinoClient, slice: Slice | null): Pro days: num(r.days_to_first_deadline) ?? 0, })) .sort((a, b) => b.billed - a.billed), - recovery: recovery - .map((r) => ({ bucket: str(r.bucket), claims: num(r.claims) ?? 0, billed: num(r.billed) ?? 0 })) - .sort((a, b) => RECOVERY_ORDER.indexOf(a.bucket) - RECOVERY_ORDER.indexOf(b.bucket)), + recovery: sumBy( + recovery.map((r) => ({ bucket: str(r.bucket), claims: num(r.claims) ?? 0, billed: num(r.billed) ?? 0 })), + (b) => b.bucket, + (a, b) => ({ ...a, claims: a.claims + b.claims, billed: a.billed + b.billed }), + ).sort((a, b) => RECOVERY_ORDER.indexOf(a.bucket) - RECOVERY_ORDER.indexOf(b.bucket)), + speed: speed + .map((r) => ({ + payer: str(r.payer), + claims: num(r.claims) ?? 0, + pending: num(r.still_pending) ?? 0, + pendingPct: num(r.pending_pct) ?? 0, + medianDays: num(r.median_days_to_remit) ?? 0, + pendingBilled: num(r.pending_billed) ?? 0, + })) + .sort((a, b) => b.pendingPct - a.pendingPct), + scorecard: scorecard + .map((r) => ({ + payer: str(r.payer), + resolved: num(r.resolved) ?? 0, + overturned: num(r.overturned) ?? 0, + overturnPct: num(r.overturn_pct) ?? 0, + recovered: num(r.recovered_billed) ?? 0, + })) + .sort((a, b) => b.overturnPct - a.overturnPct), }; } diff --git a/src/components/Briefing.tsx b/src/components/Briefing.tsx index 9a4b52d..186f9dd 100644 --- a/src/components/Briefing.tsx +++ b/src/components/Briefing.tsx @@ -14,7 +14,7 @@ import { } from 'recharts'; import { Loader2, Table2 } from 'lucide-react'; import type { BriefingData, Slice } from '../api/charts'; -import { RECOVERY_ORDER } from '../api/charts'; +import { RECOVERY_ORDER, isDenialKind } from '../api/charts'; import { useTheme } from '../theme'; import { money, moneyShort, shortDate } from '../format'; @@ -95,19 +95,56 @@ export function BriefingSkeleton({ error }: { error?: string | null }) { ); } -export function Briefing({ data, slice }: { data: BriefingData; slice: Slice | null }) { +export function Briefing({ data, slice, kind = '' }: { data: BriefingData; slice: Slice | null; kind?: string }) { const { theme } = useTheme(); const c = HUES[theme]; const title = slice ? [slice.payer, slice.department, slice.code].filter(Boolean).join(' · ') : 'the hospital'; + const denial = isDenialKind(kind); + + // A replayed morning: the lakehouse views are anchored on the data's LATEST + // date, so "recent vs baseline", "expiring within 14 days", appeal status and + // payer speed all describe today, not the morning Ava replayed. Only the + // weekly series can be cut at the replay date honestly — so that is all we + // draw, and we say why. + if (data.asOf) { + return ( +
+ +

+ Replayed morning ({shortDate(data.asOf)}): the trend is cut at that date. Comparison charts are + anchored on the latest lakehouse data and are not shown for a replay. +

+
+ ); + } + + // Which four pictures tell this kind of story. + const cards: React.ReactNode[] = []; + if (kind === 'expiring_appeals') { + if (data.expiring.length) cards.push(); + if (data.scorecard.length) cards.push(); + cards.push(); + cards.push(); + } else if (kind === 'slow_payer') { + if (data.speed.length) cards.push(); + cards.push(); + cards.push(); + if (data.expiring.length) cards.push(); + } else if (kind === 'underpayment') { + cards.push(); + cards.push(); + if (data.scorecard.length) cards.push(); + if (data.expiring.length) cards.push(); + } else { + cards.push(); + cards.push(); + if (data.recovery.length) cards.push(); + if (data.expiring.length) cards.push(); + } return (
-
- - - {data.recovery.length > 0 && } - {data.expiring.length > 0 && } -
+
{cards}
); } @@ -186,19 +223,23 @@ const tip = (c: typeof HUES.light) => ({ 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; +function SpikeChart({ data, slice, c, title, kind = '' }: { data: BriefingData; slice: Slice | null; c: typeof HUES.light; title: string; kind?: string }) { + const isSlice = slice !== null && data.weeklySlice.length > 0; + const pts = isSlice ? data.weeklySlice : data.weeklyRate; + // The dashed marker means different things: a payer bulletin date for a + // policy finding, the week a pattern began otherwise. A money-timing finding + // has no start week on the denial trend, so no marker. + const spike = slice && isDenialKind(kind) ? slice.spikeStart : null; + const marker = kind === 'payer_policy' ? 'policy effective' : 'change begins'; const rows = pts.map((p) => ({ ...p, label: shortDate(p.week) })); return ( [r.label, isSlice ? r.denied : `${r.pct}%`]) }} > - + (isSlice ? String(v) : `${v}%`)} /> @@ -217,7 +258,7 @@ function SpikeChart({ data, slice, c, title }: { data: BriefingData; slice: Slic x={shortDate(spike)} stroke={c.ai} strokeDasharray="4 3" - label={{ value: 'policy effective', position: 'insideTopLeft', fill: c.ai, fontSize: 11 }} + label={{ value: marker, position: 'insideTopLeft', fill: c.ai, fontSize: 11 }} /> )} @@ -236,7 +277,7 @@ function PayerChart({ data, slice, c }: { data: BriefingData; slice: Slice | nul table={{ head: ['Insurer', 'Before', 'Now'], rows: rows.map((r) => [r.payer, `${r.rateBase}%`, `${r.rateRecent}%`]) }} > - + `${v}%`} /> @@ -296,6 +337,60 @@ function RecoveryChart({ data, c, title }: { data: BriefingData; c: typeof HUES. ); } +// 5. Speed: who is sitting on our claims --------------------------------------- +function SpeedChart({ data, slice, c }: { data: BriefingData; slice: Slice | null; c: typeof HUES.light }) { + const rows = data.speed.map((p) => ({ ...p, short: p.payer.replace(' Health Plan', '').replace(' Health', '').replace(' Mutual', '') })); + const slow = rows.find((r) => r.payer === slice?.payer) ?? rows[0]; + return ( + r !== slow).reduce((t, r) => t + r.medianDays, 0) / Math.max(rows.length - 1, 1))} days` : 'Share of month-old claims with no payer response'} + table={{ head: ['Insurer', 'Claims', 'Unanswered', 'Median days', 'Waiting'], rows: rows.map((r) => [r.payer, r.claims, `${r.pendingPct}%`, r.medianDays, money(r.pendingBilled)]) }} + > + + + + + + [`${Number(v)}% unanswered`, '']} /> + `${Number(v)}%` }}> + {rows.map((r) => ( + + ))} + + + + + ); +} + +// 6. Scorecard: which appeals are worth the staff time ------------------------- +function ScorecardChart({ data, c }: { data: BriefingData; c: typeof HUES.light }) { + const rows = data.scorecard.map((p) => ({ ...p, short: p.payer.replace(' Health Plan', '').replace(' Health', '').replace(' Mutual', '') })); + const worst = rows[rows.length - 1]; + return ( + [r.payer, r.resolved, `${r.overturnPct}%`, money(r.recovered)]) }} + > + + + + + + [`${Number(v)}% of ${item?.payload?.resolved ?? ''} resolved`, 'overturned']} /> + `${Number(v)}%` }}> + {rows.map((r) => ( + + ))} + + + + + ); +} + // 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', '') })); diff --git a/src/lib/slides.ts b/src/lib/slides.ts index 52bfc22..1485c39 100644 --- a/src/lib/slides.ts +++ b/src/lib/slides.ts @@ -43,7 +43,7 @@ const RECOVERY_COLOR: Record = { 'window closed': GRAY, }; -export async function buildDeck(row: Row, data: BriefingData, slice: Slice | null): Promise { +export async function buildDeck(row: Row, data: BriefingData, slice: Slice | null, kind = ''): Promise { const pres = new pptxgen(); pres.layout = 'LAYOUT_WIDE'; // 13.33 x 7.5 in pres.author = 'Northlake Health — Denials Desk (AI analyst: Ava)'; @@ -223,7 +223,9 @@ export async function buildDeck(row: Row, data: BriefingData, slice: Slice | nul const bullets = [ str(row.scanned_summary) || 'Reviewed every payer, department and front desk against an 8-week baseline.', reconciled ? 'Claims data reconciled against the finance report before any analysis.' : 'Claims data could not be fully reconciled against finance — treat numbers as provisional.', - `Cause matched to the payer's own policy bulletin${slice?.spikeStart ? ` effective ${shortDate(slice.spikeStart)}` : ''}.`, + kind === 'payer_policy' + ? `Cause matched to the payer's own policy bulletin${slice?.spikeStart ? ` effective ${shortDate(slice.spikeStart)}` : ''}.` + : '', conf != null ? `Stated confidence: ${Math.round(conf * 100)}%.` : '', 'Read-only access to Databricks (workspace.rcm). Every query is logged and reproducible from the case.', ].filter(Boolean); diff --git a/src/screens/CaseScreen.tsx b/src/screens/CaseScreen.tsx index 9971e7e..4a8bf3d 100644 --- a/src/screens/CaseScreen.tsx +++ b/src/screens/CaseScreen.tsx @@ -67,9 +67,10 @@ export function CaseScreen() { // Chart rows come from six Databricks views through the record-view API. Loaded // once there are findings to picture; the slice keys decide which weekly line // and which recovery breakdown are drawn. + const asOf = str(row?.review_as_of).slice(0, 10) || null; const briefing = useQuery( - () => loadBriefing(client, slice), - [id, slice?.payer, slice?.department, slice?.code], + () => loadBriefing(client, slice, { kind, asOf }), + [id, slice?.payer, slice?.department, slice?.code, kind, asOf], hasFindings, ); @@ -79,7 +80,7 @@ export function CaseScreen() { try { // pptxgenjs is ~700 KB; nobody pays for it until they ask for slides. const { buildDeck } = await import('../lib/slides'); - await buildDeck(row, briefing.data, slice); + await buildDeck(row, briefing.data, slice, kind); } finally { setExporting(false); } @@ -172,7 +173,7 @@ export function CaseScreen() { <> {briefing.data ? ( - + ) : ( )}