fix: deep links reproduce Ava's counts — slice identity, window ceiling, cohort dates
- slice now carries site / desk / provider (14_slice_dims.sql), so a front-desk or provider finding opens the right rows, not every CO-27 in the hospital - every link is capped at the review's ceiling date (review_to / as-of); a custom-window run no longer opens claims Ava never looked at - slow-payer link filters to claims SENT 31–60 days before the latest submitted date (from the stats view), matching her 1,352 instead of all 2,753 pending - 'first deadline' ignores dates before the review (it was picking the spike week) - header says 'Custom run' when dates were chosen; page changes scroll to top and dim the table while loading Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
b4bff014b3
commit
91002b11c4
@ -38,6 +38,9 @@ export interface Slice {
|
|||||||
payer: string;
|
payer: string;
|
||||||
department: string;
|
department: string;
|
||||||
code: string;
|
code: string;
|
||||||
|
site: string;
|
||||||
|
desk: string;
|
||||||
|
provider: string;
|
||||||
/** ISO date of the week the change started, if Ava named one. */
|
/** ISO date of the week the change started, if Ava named one. */
|
||||||
spikeStart: string | null;
|
spikeStart: string | null;
|
||||||
}
|
}
|
||||||
@ -46,8 +49,11 @@ export function sliceOf(row: Row): Slice | null {
|
|||||||
const payer = str(row.slice_payer);
|
const payer = str(row.slice_payer);
|
||||||
const department = str(row.slice_department);
|
const department = str(row.slice_department);
|
||||||
const code = str(row.slice_reason_code);
|
const code = str(row.slice_reason_code);
|
||||||
if (!payer && !department && !code) return null;
|
const site = str(row.slice_site);
|
||||||
return { payer, department, code, spikeStart: str(row.spike_start).slice(0, 10) || null };
|
const desk = str(row.slice_desk);
|
||||||
|
const provider = str(row.slice_provider);
|
||||||
|
if (!payer && !department && !code && !site && !desk && !provider) return null;
|
||||||
|
return { payer, department, code, site, desk, provider, spikeStart: str(row.spike_start).slice(0, 10) || null };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WeekPoint {
|
export interface WeekPoint {
|
||||||
|
|||||||
@ -98,7 +98,7 @@ export function BriefingSkeleton({ error }: { error?: string | null }) {
|
|||||||
export function Briefing({ data, slice, kind = '' }: { data: BriefingData; slice: Slice | null; kind?: string }) {
|
export function Briefing({ data, slice, kind = '' }: { data: BriefingData; slice: Slice | null; kind?: string }) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const c = HUES[theme];
|
const c = HUES[theme];
|
||||||
const title = slice ? [slice.payer, slice.department, slice.code].filter(Boolean).join(' · ') : 'the hospital';
|
const title = slice ? [slice.payer, slice.department, slice.provider, slice.desk && `desk ${slice.desk}`, slice.code].filter(Boolean).join(' · ') : 'the hospital';
|
||||||
const denial = isDenialKind(kind);
|
const denial = isDenialKind(kind);
|
||||||
|
|
||||||
// A replayed morning: the lakehouse views are anchored on the data's LATEST
|
// A replayed morning: the lakehouse views are anchored on the data's LATEST
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
|||||||
import { AlertTriangle, CheckCircle2, ChevronDown, ExternalLink, FileSearch } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, ChevronDown, ExternalLink, FileSearch } from 'lucide-react';
|
||||||
import type { Slice } from '../api/charts';
|
import type { Slice } from '../api/charts';
|
||||||
import { affectedClaimsHref, recoverableClaimsHref } from '../lib/claimsLink';
|
import { affectedClaimsHref, recoverableClaimsHref } from '../lib/claimsLink';
|
||||||
|
import type { LinkDates } from '../lib/claimsLink';
|
||||||
import { Badge, Card, Facts } from './core';
|
import { Badge, Card, Facts } from './core';
|
||||||
import type { AvaDecision, AvaTraceStep, Row } from '../api/types';
|
import type { AvaDecision, AvaTraceStep, Row } from '../api/types';
|
||||||
import { firstDeadline, headline, money, num, pct, prose, recommendations, sentences, shortDate, splitRecommendation, sqlSteps, str, workStatement } from '../format';
|
import { firstDeadline, headline, money, num, pct, prose, recommendations, sentences, shortDate, splitRecommendation, sqlSteps, str, workStatement } from '../format';
|
||||||
@ -26,23 +27,23 @@ export function Verdict({
|
|||||||
row,
|
row,
|
||||||
slice = null,
|
slice = null,
|
||||||
kind = '',
|
kind = '',
|
||||||
anchor = null,
|
dates = { anchor: null, ceiling: null, submittedTo: null },
|
||||||
}: {
|
}: {
|
||||||
row: Row;
|
row: Row;
|
||||||
slice?: Slice | null;
|
slice?: Slice | null;
|
||||||
kind?: string;
|
kind?: string;
|
||||||
anchor?: string | null;
|
dates?: LinkDates;
|
||||||
}) {
|
}) {
|
||||||
const summary = str(row.summary);
|
const summary = str(row.summary);
|
||||||
if (!summary) return null;
|
if (!summary) return null;
|
||||||
const line = prose(headline(summary));
|
const line = prose(headline(summary));
|
||||||
const scanned = prose(row.scanned_summary);
|
const scanned = prose(row.scanned_summary);
|
||||||
const affectedHref = affectedClaimsHref(kind, slice, anchor);
|
const affectedHref = affectedClaimsHref(kind, slice, dates);
|
||||||
const recoverableHref = recoverableClaimsHref(kind, slice, anchor);
|
const recoverableHref = recoverableClaimsHref(kind, slice, dates);
|
||||||
const atRisk = num(row.amount_at_risk);
|
const atRisk = num(row.amount_at_risk);
|
||||||
const recoverable = num(row.recoverable_amount);
|
const recoverable = num(row.recoverable_amount);
|
||||||
const affected = num(row.affected_claims);
|
const affected = num(row.affected_claims);
|
||||||
const deadline = firstDeadline(row.recommendations);
|
const deadline = firstDeadline(row.recommendations, dates.anchor);
|
||||||
const confidence = num(row.confidence);
|
const confidence = num(row.confidence);
|
||||||
const reconciled = row.data_reconciled === true || str(row.data_reconciled) === 'true';
|
const reconciled = row.data_reconciled === true || str(row.data_reconciled) === 'true';
|
||||||
|
|
||||||
|
|||||||
@ -176,10 +176,12 @@ export function headline(summary: unknown, maxChars = 220): string {
|
|||||||
|
|
||||||
/** The earliest date Ava mentioned in her recommendations — usually the first
|
/** The earliest date Ava mentioned in her recommendations — usually the first
|
||||||
* appeal deadline. Null if she didn't name one. */
|
* appeal deadline. Null if she didn't name one. */
|
||||||
export function firstDeadline(recs: unknown): string | null {
|
export function firstDeadline(recs: unknown, notBefore: string | null = null): string | null {
|
||||||
const m = str(recs).match(/\b(2026-\d{2}-\d{2})\b/g);
|
const m = str(recs).match(/\b(20\d{2}-\d{2}-\d{2})\b/g);
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const sorted = [...new Set(m)].sort();
|
// A recommendation also names past dates ("since 2026-07-06"); a deadline
|
||||||
|
// is the earliest date that is still ahead of the review.
|
||||||
|
const sorted = [...new Set(m)].filter((d) => !notBefore || d >= notBefore).sort();
|
||||||
return sorted[0] ?? null;
|
return sorted[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,10 +7,23 @@ import { addDays, isDenialKind } from '../api/charts';
|
|||||||
* Ava writes "216 claims" and "172 still appealable"; these build the
|
* Ava writes "216 claims" and "172 still appealable"; these build the
|
||||||
* /claims URL whose filters reproduce those rows. Every filter here is a
|
* /claims URL whose filters reproduce those rows. Every filter here is a
|
||||||
* column the record view declares (11_claims_explorer.sql), and the date
|
* column the record view declares (11_claims_explorer.sql), and the date
|
||||||
* bounds compile to inclusive ranges on the lakehouse. The anchor is the
|
* bounds compile to inclusive ranges on the lakehouse.
|
||||||
* data's latest date (or the replay date) — without it we still link, just
|
*
|
||||||
* without the "inside the window" bound.
|
* Dates come from the case, not from the charts:
|
||||||
|
* ceiling review_to / review_as_of — the last day Ava looked at. When
|
||||||
|
* set, every link is capped there, or the count grows past hers.
|
||||||
|
* anchor the ceiling, else the lakehouse's latest service date; the
|
||||||
|
* appeal-window bound ("still inside the window", "expiring
|
||||||
|
* within 14 days").
|
||||||
|
* submittedTo the ceiling, else the latest submitted_date; the slow-payer
|
||||||
|
* cohort is claims SENT 31–60 days before it.
|
||||||
*/
|
*/
|
||||||
|
export interface LinkDates {
|
||||||
|
anchor: string | null;
|
||||||
|
ceiling: string | null;
|
||||||
|
submittedTo: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export type ClaimsQuery = Partial<Record<
|
export type ClaimsQuery = Partial<Record<
|
||||||
'payer' | 'department' | 'code' | 'category' | 'status' | 'appeal' | 'site' | 'desk' | 'provider' | 'cpt'
|
'payer' | 'department' | 'code' | 'category' | 'status' | 'appeal' | 'site' | 'desk' | 'provider' | 'cpt'
|
||||||
| 'from' | 'to' | 'sub_from' | 'sub_to' | 'dl_from' | 'dl_to' | 'page',
|
| 'from' | 'to' | 'sub_from' | 'sub_to' | 'dl_from' | 'dl_to' | 'page',
|
||||||
@ -24,42 +37,48 @@ export function claimsHref(q: ClaimsQuery): string {
|
|||||||
return s ? `/claims?${s}` : '/claims';
|
return s ? `/claims?${s}` : '/claims';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The identity part of a slice — everything Ava named. */
|
||||||
|
function who(slice: Slice): ClaimsQuery {
|
||||||
|
return {
|
||||||
|
payer: slice.payer || undefined,
|
||||||
|
department: slice.department || undefined,
|
||||||
|
code: slice.code || undefined,
|
||||||
|
site: slice.site || undefined,
|
||||||
|
desk: slice.desk || undefined,
|
||||||
|
provider: slice.provider || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** The rows behind "claims denied" / "affected". */
|
/** The rows behind "claims denied" / "affected". */
|
||||||
export function affectedClaimsHref(kind: string, slice: Slice | null, anchor: string | null): string | null {
|
export function affectedClaimsHref(kind: string, slice: Slice | null, d: LinkDates): string | null {
|
||||||
if (kind === 'expiring_appeals') {
|
if (kind === 'expiring_appeals') {
|
||||||
// Without the anchor the 14-day window cannot be expressed, and the link
|
// Without the anchor the 14-day window cannot be expressed, and the link
|
||||||
// would open every unfiled denial ever. Show no link instead.
|
// would open every unfiled denial ever. Show no link instead.
|
||||||
if (!anchor) return null;
|
if (!d.anchor) return null;
|
||||||
return claimsHref({ status: 'denied', appeal: 'not_filed', dl_from: anchor, dl_to: addDays(anchor, 14) });
|
return claimsHref({ status: 'denied', appeal: 'not_filed', dl_from: d.anchor, dl_to: addDays(d.anchor, 14) });
|
||||||
}
|
}
|
||||||
if (kind === 'slow_payer') {
|
if (kind === 'slow_payer') {
|
||||||
return slice?.payer ? claimsHref({ payer: slice.payer, status: 'pending' }) : null;
|
if (!slice?.payer || !d.submittedTo) return null;
|
||||||
|
return claimsHref({ payer: slice.payer, status: 'pending', sub_from: addDays(d.submittedTo, -60), sub_to: addDays(d.submittedTo, -31) });
|
||||||
}
|
}
|
||||||
if (kind === 'underpayment') {
|
if (kind === 'underpayment') {
|
||||||
return slice?.payer ? claimsHref({ payer: slice.payer, status: 'partial', from: slice.spikeStart ?? undefined }) : null;
|
return slice?.payer ? claimsHref({ payer: slice.payer, status: 'partial', from: slice.spikeStart ?? undefined, to: d.ceiling ?? undefined }) : null;
|
||||||
}
|
}
|
||||||
if (!slice || !isDenialKind(kind)) return null;
|
if (!slice || !isDenialKind(kind)) return null;
|
||||||
return claimsHref({
|
return claimsHref({ ...who(slice), status: 'denied', from: slice.spikeStart ?? undefined, to: d.ceiling ?? undefined });
|
||||||
payer: slice.payer || undefined,
|
|
||||||
department: slice.department || undefined,
|
|
||||||
code: slice.code || undefined,
|
|
||||||
status: 'denied',
|
|
||||||
from: slice.spikeStart ?? undefined,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The rows behind "still recoverable": the affected set, unfiled and inside its window. */
|
/** The rows behind "still recoverable": the affected set, unfiled and inside its window. */
|
||||||
export function recoverableClaimsHref(kind: string, slice: Slice | null, anchor: string | null): string | null {
|
export function recoverableClaimsHref(kind: string, slice: Slice | null, d: LinkDates): string | null {
|
||||||
if (kind === 'expiring_appeals') return affectedClaimsHref(kind, slice, anchor);
|
if (kind === 'expiring_appeals') return affectedClaimsHref(kind, slice, d);
|
||||||
if (kind === 'slow_payer' || kind === 'underpayment') return null;
|
if (kind === 'slow_payer' || kind === 'underpayment') return null;
|
||||||
if (!slice || !isDenialKind(kind)) return null;
|
if (!slice || !isDenialKind(kind)) return null;
|
||||||
return claimsHref({
|
return claimsHref({
|
||||||
payer: slice.payer || undefined,
|
...who(slice),
|
||||||
department: slice.department || undefined,
|
|
||||||
code: slice.code || undefined,
|
|
||||||
status: 'denied',
|
status: 'denied',
|
||||||
appeal: 'not_filed',
|
appeal: 'not_filed',
|
||||||
from: slice.spikeStart ?? undefined,
|
from: slice.spikeStart ?? undefined,
|
||||||
dl_from: anchor ?? undefined,
|
to: d.ceiling ?? undefined,
|
||||||
|
dl_from: d.anchor ?? undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,7 +50,7 @@ export async function buildDeck(row: Row, data: BriefingData, slice: Slice | nul
|
|||||||
pres.title = `Denials briefing — case #${str(row.instance_id)}`;
|
pres.title = `Denials briefing — case #${str(row.instance_id)}`;
|
||||||
|
|
||||||
const caseNo = str(row.instance_id);
|
const caseNo = str(row.instance_id);
|
||||||
const sliceTitle = slice ? [slice.payer, slice.department, slice.code].filter(Boolean).join(' · ') : 'Northlake Health';
|
const sliceTitle = slice ? [slice.payer, slice.department, slice.provider, slice.desk && `desk ${slice.desk}`, slice.code].filter(Boolean).join(' · ') : 'Northlake Health';
|
||||||
|
|
||||||
const chrome = (s: pptxgen.Slide, title: string, kicker?: string) => {
|
const chrome = (s: pptxgen.Slide, title: string, kicker?: string) => {
|
||||||
s.background = { color: 'FFFFFF' };
|
s.background = { color: 'FFFFFF' };
|
||||||
|
|||||||
@ -74,7 +74,13 @@ export function CaseScreen() {
|
|||||||
// take seconds to load and a link rendered without the anchor would silently
|
// take seconds to load and a link rendered without the anchor would silently
|
||||||
// drop the deadline window and open thousands of rows instead of hundreds.
|
// drop the deadline window and open thousands of rows instead of hundreds.
|
||||||
const lake = useQuery(() => client.rows('dni-rv-lakehouse-tables', { limit: 10 }), [], !asOf && hasFindings);
|
const lake = useQuery(() => client.rows('dni-rv-lakehouse-tables', { limit: 10 }), [], !asOf && hasFindings);
|
||||||
const anchor = asOf ?? str(lake.data?.find((r) => str(r.table_name) === 'claims')?.to_date).slice(0, 10) ?? null;
|
const claimsStats = lake.data?.find((r) => str(r.table_name) === 'claims');
|
||||||
|
const linkDates = {
|
||||||
|
ceiling: asOf,
|
||||||
|
anchor: asOf ?? (str(claimsStats?.to_date).slice(0, 10) || null),
|
||||||
|
submittedTo: asOf ?? (str(claimsStats?.submitted_to).slice(0, 10) || null),
|
||||||
|
};
|
||||||
|
const customRun = !!(str(row?.review_as_of) || str(row?.review_from) || str(row?.review_to));
|
||||||
const briefing = useQuery(
|
const briefing = useQuery(
|
||||||
() => loadBriefing(client, slice, { kind, asOf }),
|
() => loadBriefing(client, slice, { kind, asOf }),
|
||||||
[id, slice?.payer, slice?.department, slice?.code, kind, asOf],
|
[id, slice?.payer, slice?.department, slice?.code, kind, asOf],
|
||||||
@ -122,7 +128,7 @@ export function CaseScreen() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
{kind && FINDING_KIND[kind] && <Badge tone="violet">{FINDING_KIND[kind]}</Badge>}
|
{kind && FINDING_KIND[kind] && <Badge tone="violet">{FINDING_KIND[kind]}</Badge>}
|
||||||
<span className="ml-auto text-[11px] text-faint">
|
<span className="ml-auto text-[11px] text-faint">
|
||||||
Daily review · opened {dateTime(row.created_at)}
|
{customRun ? 'Custom run' : 'Daily review'} · opened {dateTime(row.created_at)}
|
||||||
{str(row.review_as_of) ? ` · as of ${str(row.review_as_of).slice(0, 10)}` : ''}
|
{str(row.review_as_of) ? ` · as of ${str(row.review_as_of).slice(0, 10)}` : ''}
|
||||||
</span>
|
</span>
|
||||||
{hasFindings && (
|
{hasFindings && (
|
||||||
@ -178,7 +184,7 @@ export function CaseScreen() {
|
|||||||
{/* ---------- The findings: what happened, how much, what to approve ---------- */}
|
{/* ---------- The findings: what happened, how much, what to approve ---------- */}
|
||||||
{hasFindings && (
|
{hasFindings && (
|
||||||
<>
|
<>
|
||||||
<Verdict row={row} slice={slice} kind={kind} anchor={anchor || null} />
|
<Verdict row={row} slice={slice} kind={kind} dates={linkDates} />
|
||||||
{briefing.data ? (
|
{briefing.data ? (
|
||||||
<Briefing data={briefing.data} slice={slice} kind={kind} />
|
<Briefing data={briefing.data} slice={slice} kind={kind} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -106,6 +106,9 @@ export function ClaimsScreen() {
|
|||||||
if (n <= 1) next.delete('page');
|
if (n <= 1) next.delete('page');
|
||||||
else next.set('page', String(n));
|
else next.set('page', String(n));
|
||||||
navigate(`/claims?${next}`);
|
navigate(`/claims?${next}`);
|
||||||
|
// The pager sits under the table; without this the new page loads with the
|
||||||
|
// viewport still at the bottom, which reads as "nothing changed".
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
const opts = (col: string, labels?: Record<string, string>) =>
|
const opts = (col: string, labels?: Record<string, string>) =>
|
||||||
((options[col] ?? []) as unknown[]).map(String).filter(Boolean).map((v) => ({ value: v, label: labels?.[v] ?? v }));
|
((options[col] ?? []) as unknown[]).map(String).filter(Boolean).map((v) => ({ value: v, label: labels?.[v] ?? v }));
|
||||||
@ -223,7 +226,7 @@ export function ClaimsScreen() {
|
|||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<p className="px-5 py-10 text-center text-sm text-faint">No claims match these filters.</p>
|
<p className="px-5 py-10 text-center text-sm text-faint">No claims match these filters.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className={`overflow-x-auto transition-opacity ${q.loading ? 'opacity-50' : ''}`} aria-busy={q.loading}>
|
||||||
<table className="w-full text-[13px]">
|
<table className="w-full text-[13px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-[11px] font-semibold tracking-wide text-faint uppercase">
|
<tr className="text-left text-[11px] font-semibold tracking-wide text-faint uppercase">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user