feat: the claims tile counts the lakehouse rows the link opens; live trace fills the screen

Claims denied showed Ava's affected_claims (225) while "See the claims"
opened 237 rows — her quantify query and the link's filters are two
different queries. The tile now asks the record view for the link's own
filter set (limit 1, pagination.total_count) and shows that; her figure
appears underneath only when it disagrees. The URL→filter mapping moves
to lib/claimsLink so the table and the tile share one definition.

While a review runs the trace is the page: the layout widens to the
screen, the list takes the viewport height, follows the newest step
unless the viewer scrolled up, and shows a working row with a spinner
and an indeterminate strip under the header. Reduced motion honoured.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-10-07 13:14:00 +05:30
parent fc1e9dc27c
commit e775fb0e88
6 changed files with 164 additions and 40 deletions

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import {
AlertTriangle,
Brain,
@ -70,6 +70,7 @@ export function AvaLive({
steps,
decisions,
working,
fill = false,
}: {
steps: AvaTraceStep[];
decisions: AvaDecision[];
@ -77,8 +78,27 @@ export function AvaLive({
* the "still working" footer, so an empty trace reads as "starting" rather
* than "nothing happened". */
working: boolean;
/** While the review runs the trace is the page: let the list take the
* viewport instead of a fixed 32rem box. */
fill?: boolean;
}) {
const [open, setOpen] = useState<number | null>(null);
// Follow the run: when a new step lands while she is working, keep the
// newest step in view — unless the viewer has scrolled up to read.
const listRef = useRef<HTMLOListElement | null>(null);
const stuck = useRef(true);
useEffect(() => {
const el = listRef.current;
if (!el || !working || !stuck.current) return;
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
}, [steps.length, working]);
function onScroll() {
const el = listRef.current;
if (!el) return;
stuck.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48;
}
const last = steps[steps.length - 1];
const sinceLast = last ? Math.max(0, (Date.now() - new Date(last.ended_at ?? last.started_at).getTime()) / 1000) : 0;
const queries = steps.filter((s) => s.type === 'api');
const failed = queries.filter((s) => s.query_state === 'FAILED').length;
@ -112,6 +132,7 @@ export function AvaLive({
{spend > 0 && <Badge tone="slate">{money(spend.toFixed(2))}</Badge>}
</div>
</header>
{working && <div className="progress-strip" aria-hidden="true" />}
{steps.length === 0 ? (
<p className="flex items-center gap-2 px-4 py-8 text-sm text-faint">
@ -121,7 +142,11 @@ export function AvaLive({
: 'No steps recorded for this case.'}
</p>
) : (
<ol className="max-h-[32rem] overflow-y-auto px-4">
<ol
ref={listRef}
onScroll={onScroll}
className={`overflow-y-auto px-4 ${fill ? 'max-h-[calc(100vh-21rem)] min-h-[24rem]' : 'max-h-[32rem]'}`}
>
{steps.map((s, i) => {
const m = meta(s.type);
const Icon = m.icon;
@ -196,6 +221,24 @@ export function AvaLive({
</li>
);
})}
{working && (
<li className="flex items-center gap-3 py-3">
<span className="flex shrink-0 flex-col items-center gap-1 pt-0.5">
<span className="rounded-full bg-ai-soft p-1.5 text-ai ring-1 ring-ai-edge ring-inset">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
</span>
<span className="tnum text-[10px] text-faint">{steps.length + 1}</span>
</span>
<span className="min-w-0 flex-1">
<span className="text-[13px] font-semibold text-ai">Working on the next step</span>
<span className="mt-1 block text-xs text-muted">
{last?.type === 'api' ? 'Reading the result and deciding what to query next' : 'Running'}
{sinceLast >= 5 ? ` · ${Math.round(sinceLast)}s so far` : ''}
</span>
</span>
<span className="working-dots" aria-hidden="true"><i /><i /><i /></span>
</li>
)}
</ol>
)}

View File

@ -3,7 +3,8 @@ import { Link } from 'react-router-dom';
import { AlertTriangle, CheckCircle2, ChevronDown, ExternalLink, FileSearch } from 'lucide-react';
import type { Slice } from '../api/charts';
import type { ReviewedFacts } from '../format';
import { affectedClaimsHref, recoverableClaimsHref } from '../lib/claimsLink';
import { affectedClaimsHref, filtersFromHref, recoverableClaimsHref } from '../lib/claimsLink';
import { useQuery, useZino } from '../api/provider';
import type { LinkDates } from '../lib/claimsLink';
import { Badge, Card, Facts } from './core';
import type { AvaDecision, AvaTraceStep, Row } from '../api/types';
@ -39,14 +40,27 @@ export function Verdict({
dataSpan?: { from: string; to: string } | null;
}) {
const summary = str(row.summary);
const affectedHref = affectedClaimsHref(kind, slice, dates);
const recoverableHref = recoverableClaimsHref(kind, slice, dates);
// THE COUNT COMES FROM THE DATA, NOT FROM AVA. She writes affected_claims
// into her decision; the lakehouse is asked for the exact rows the "See the
// claims" link opens, and that total is the number on the tile. Her figure
// is shown underneath only when it disagrees, so a discrepancy is visible
// rather than silently replaced.
const live = useLiveCount(affectedHref);
if (!summary) return null;
const line = prose(headline(summary));
const scanned = str(row.scanned_summary);
const affectedHref = affectedClaimsHref(kind, slice, dates);
const recoverableHref = recoverableClaimsHref(kind, slice, dates);
const atRisk = num(row.amount_at_risk);
const recoverable = num(row.recoverable_amount);
const affected = num(row.affected_claims);
const reported = num(row.affected_claims);
const affected = live.count ?? reported;
const countNote =
live.count != null && reported != null && live.count !== reported
? `AI analysis reported ${reported.toLocaleString('en-US')}; the lakehouse count is shown`
: live.count != null
? 'counted from the lakehouse'
: undefined;
const deadline = firstDeadline(row.recommendations, dates.anchor);
const confidence = num(row.confidence);
const reconciled = row.data_reconciled === true || str(row.data_reconciled) === 'true';
@ -74,8 +88,9 @@ export function Verdict({
<div className="grid gap-px bg-line sm:grid-cols-3">
<Big
label={kind === 'slow_payer' ? 'Claims unanswered' : kind === 'underpayment' ? 'Claims short-paid' : 'Claims denied'}
value={affected == null ? '—' : affected.toLocaleString('en-US')}
value={affected == null ? '—' : `${affected.toLocaleString('en-US')}${live.capped ? '+' : ''}`}
href={affectedHref}
note={countNote}
/>
<Big label="Money at stake" value={money(atRisk)} tone="bad" />
<Big
@ -399,6 +414,21 @@ export function Recheck({ row }: { row: Row }) {
// ---------------------------------------------------------------------------
/** The lakehouse's own count of the rows behind a claims link: one record-view
* call with the link's filters and limit 1, reading pagination.total_count.
* No link → nothing asked. */
function useLiveCount(href: string | null): { count: number | null; capped: boolean } {
const { client } = useZino();
const q = useQuery(
() => client.recordView('dni-rv-claims', { page: 1, limit: 1, filters: filtersFromHref(href ?? '') }),
[href],
!!href,
);
const pg = q.data?.pagination;
const count = pg?.total_count ?? (q.data?.total as number | undefined) ?? null;
return { count: typeof count === 'number' ? count : null, capped: !!pg?.total_is_capped };
}
function Big({ label, value, tone, note, href }: { label: string; value: string; tone?: 'good' | 'bad'; note?: string; href?: string | null }) {
const text = tone === 'good' ? 'text-good' : tone === 'bad' ? 'text-bad' : 'text-ink';
const body = (

View File

@ -1,4 +1,5 @@
import type { Slice } from '../api/charts';
import type { RecordFilter } from '../api/types';
import { addDays, isDenialKind } from '../api/charts';
/**
@ -30,6 +31,47 @@ export type ClaimsQuery = Partial<Record<
string
>>;
/** URL param → record-view column. The claims table and the finding tiles
* both read this, so "the rows the link opens" and "the number on the tile"
* are the same query by construction. */
export const CLAIM_DIMS: Array<{ key: string; col: string; label: string }> = [
{ key: 'payer', col: 'payer', label: 'Insurer' },
{ key: 'department', col: 'department', label: 'Department' },
{ key: 'status', col: 'status', label: 'Status' },
{ key: 'code', col: 'denial_reason_code', label: 'Reason code' },
{ key: 'appeal', col: 'appeal_status', label: 'Appeal' },
{ key: 'site', col: 'site', label: 'Site' },
{ key: 'desk', col: 'registration_desk', label: 'Desk' },
{ key: 'provider', col: 'provider', label: 'Provider' },
{ key: 'cpt', col: 'procedure_code', label: 'CPT' },
{ key: 'category', col: 'denial_category', label: 'Denial category' },
];
export const CLAIM_RANGES: Array<{ from: string; to: string; col: string; label: string }> = [
{ from: 'from', to: 'to', col: 'service_date', label: 'Service date' },
{ from: 'sub_from', to: 'sub_to', col: 'submitted_date', label: 'Submitted' },
{ from: 'dl_from', to: 'dl_to', col: 'appeal_deadline', label: 'Appeal deadline' },
];
/** The record-view filters a /claims URL (or its query string) stands for. */
export function filtersFromParams(params: URLSearchParams): RecordFilter[] {
const f: RecordFilter[] = [];
for (const d of CLAIM_DIMS) {
const v = params.get(d.key);
if (v) f.push({ field_key: d.col, value: v, data_type: 'string' });
}
for (const r of CLAIM_RANGES) {
const a = params.get(r.from) ?? undefined;
const b = params.get(r.to) ?? undefined;
if (a || b) f.push({ field_key: r.col, data_type: 'date', range: { from: a, to: b } });
}
return f;
}
export function filtersFromHref(href: string): RecordFilter[] {
const qs = href.includes('?') ? href.slice(href.indexOf('?') + 1) : '';
return filtersFromParams(new URLSearchParams(qs));
}
export function claimsHref(q: ClaimsQuery): string {
const p = new URLSearchParams();
for (const [k, v] of Object.entries(q)) if (v) p.set(k, v);

View File

@ -116,10 +116,14 @@ export function CaseScreen() {
if (cases.error) return <ErrorNote>{cases.error}</ErrorNote>;
if (!row) return <ErrorNote>Case {id} not found.</ErrorNote>;
const work = <AvaLive steps={trace.data ?? []} decisions={decisions.data ?? []} working={aiOwned} />;
const running = aiOwned && !hasFindings;
const work = <AvaLive steps={trace.data ?? []} decisions={decisions.data ?? []} working={aiOwned} fill={running} />;
// While the review runs there is nothing else on the page, so the trace is
// given the width and height of the screen; once findings exist the page
// narrows back to reading width.
return (
<div className="mx-auto max-w-5xl space-y-4">
<div className={`mx-auto space-y-4 ${running ? 'max-w-7xl' : 'max-w-5xl'}`}>
<div className="flex flex-wrap items-center gap-3">
<Link to="/q/review" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-ink">
<ArrowLeft className="h-4 w-4" /> Back

View File

@ -7,6 +7,7 @@ import type { Tone } from '../api/config';
import type { RecordFilter, Row } from '../api/types';
import { Badge, ErrorNote, Input, Select, Spinner } from '../components/core';
import { CARC, money, num, shortDate, str } from '../format';
import { CLAIM_DIMS, CLAIM_RANGES, filtersFromParams } from '../lib/claimsLink';
/**
* The claims table — the lakehouse rows themselves, read-only.
@ -22,24 +23,8 @@ import { CARC, money, num, shortDate, str } from '../format';
const PAGE = 50;
/** URL param → record-view column. */
const DIMS: Array<{ key: string; col: string; label: string }> = [
{ key: 'payer', col: 'payer', label: 'Insurer' },
{ key: 'department', col: 'department', label: 'Department' },
{ key: 'status', col: 'status', label: 'Status' },
{ key: 'code', col: 'denial_reason_code', label: 'Reason code' },
{ key: 'appeal', col: 'appeal_status', label: 'Appeal' },
{ key: 'site', col: 'site', label: 'Site' },
{ key: 'desk', col: 'registration_desk', label: 'Desk' },
{ key: 'provider', col: 'provider', label: 'Provider' },
{ key: 'cpt', col: 'procedure_code', label: 'CPT' },
{ key: 'category', col: 'denial_category', label: 'Denial category' },
];
const RANGES: Array<{ from: string; to: string; col: string; label: string }> = [
{ from: 'from', to: 'to', col: 'service_date', label: 'Service date' },
{ from: 'sub_from', to: 'sub_to', col: 'submitted_date', label: 'Submitted' },
{ from: 'dl_from', to: 'dl_to', col: 'appeal_deadline', label: 'Appeal deadline' },
];
const DIMS = CLAIM_DIMS;
const RANGES = CLAIM_RANGES;
const STATUS_TONE: Record<string, Tone> = { paid: 'emerald', partial: 'amber', denied: 'rose', pending: 'slate' };
const APPEAL_LABEL: Record<string, string> = { not_filed: 'not filed', filed: 'filed', overturned: 'overturned', upheld: 'upheld' };
@ -53,19 +38,7 @@ export function ClaimsScreen() {
const [draft, setDraft] = useState(search);
useEffect(() => setDraft(search), [search]);
const filters = useMemo<RecordFilter[]>(() => {
const f: RecordFilter[] = [];
for (const d of DIMS) {
const v = params.get(d.key);
if (v) f.push({ field_key: d.col, value: v, data_type: 'string' });
}
for (const r of RANGES) {
const a = params.get(r.from) ?? undefined;
const b = params.get(r.to) ?? undefined;
if (a || b) f.push({ field_key: r.col, data_type: 'date', range: { from: a, to: b } });
}
return f;
}, [params]);
const filters = useMemo<RecordFilter[]>(() => filtersFromParams(params), [params]);
const filtered = filters.length > 0 || !!search;
const key = params.toString();

View File

@ -241,3 +241,35 @@ body {
white-space: pre-wrap;
word-break: break-word;
}
/* Review in progress: an indeterminate strip under the trace header, and
three breathing dots on the "working" row. Both honour reduced motion. */
@keyframes nl-slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(300%); }
}
.progress-strip {
position: relative;
height: 3px;
overflow: hidden;
background: color-mix(in srgb, var(--color-ai) 18%, transparent);
}
.progress-strip::after {
content: '';
position: absolute;
inset: 0 auto 0 0;
width: 33%;
background: var(--color-ai);
animation: nl-slide 1.8s ease-in-out infinite;
}
.working-dots { display: inline-flex; gap: 4px; align-items: center; }
.working-dots i {
width: 6px; height: 6px; border-radius: 9999px;
background: var(--color-ai);
animation: nl-pulse 1.2s ease-in-out infinite;
}
.working-dots i:nth-child(2) { animation-delay: 0.2s; }
.working-dots i:nth-child(3) { animation-delay: 0.4s; }
@media (prefers-reduced-motion: reduce) {
.progress-strip::after, .working-dots i { animation: none; }
}