fix: review coverage renders as a comparison and a list, not chip soup
The key=value row was being humanised before it was parsed, so the period tiles never appeared and raw window=... chips showed instead. Parse the raw text; humanise only what is displayed. Findings are now a bulleted list at reading size, and the watched / checked lines are one small line each. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
245a6a8f75
commit
811a0ca283
@ -38,7 +38,7 @@ export function Verdict({
|
|||||||
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 = str(row.scanned_summary);
|
||||||
const affectedHref = affectedClaimsHref(kind, slice, dates);
|
const affectedHref = affectedClaimsHref(kind, slice, dates);
|
||||||
const recoverableHref = recoverableClaimsHref(kind, slice, dates);
|
const recoverableHref = recoverableClaimsHref(kind, slice, dates);
|
||||||
const atRisk = num(row.amount_at_risk);
|
const atRisk = num(row.amount_at_risk);
|
||||||
@ -98,6 +98,8 @@ export function Verdict({
|
|||||||
* bullets so nothing renders as a wall of text. */
|
* bullets so nothing renders as a wall of text. */
|
||||||
/** The comparison, drawn: this period against that one, then what was set aside. */
|
/** The comparison, drawn: this period against that one, then what was set aside. */
|
||||||
function Periods({ f }: { f: ReviewedFacts }) {
|
function Periods({ f }: { f: ReviewedFacts }) {
|
||||||
|
// longDate passes non-ISO text through unchanged, so an already-humanised
|
||||||
|
// range renders as it was written.
|
||||||
const span = (r: { from: string; to: string } | null) => (r ? `${longDate(r.from)} – ${longDate(r.to)}` : '—');
|
const span = (r: { from: string; to: string } | null) => (r ? `${longDate(r.from)} – ${longDate(r.to)}` : '—');
|
||||||
return (
|
return (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
@ -105,12 +107,12 @@ function Periods({ f }: { f: ReviewedFacts }) {
|
|||||||
<div className="bg-panel px-4 py-3">
|
<div className="bg-panel px-4 py-3">
|
||||||
<p className="text-[11px] font-semibold tracking-wide text-faint uppercase">This period</p>
|
<p className="text-[11px] font-semibold tracking-wide text-faint uppercase">This period</p>
|
||||||
<p className="mt-0.5 text-[15px] font-semibold text-ink">{span(f.window)}</p>
|
<p className="mt-0.5 text-[15px] font-semibold text-ink">{span(f.window)}</p>
|
||||||
{f.windowClaims && <p className="tnum text-xs text-muted">{f.windowClaims} claims</p>}
|
{f.windowClaims && <p className="text-xs text-muted"><span className="tnum">{f.windowClaims}</span> claims reviewed</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-panel px-4 py-3">
|
<div className="bg-panel px-4 py-3">
|
||||||
<p className="text-[11px] font-semibold tracking-wide text-faint uppercase">Compared with</p>
|
<p className="text-[11px] font-semibold tracking-wide text-faint uppercase">Compared with</p>
|
||||||
<p className="mt-0.5 text-[15px] font-semibold text-ink">{span(f.baseline)}</p>
|
<p className="mt-0.5 text-[15px] font-semibold text-ink">{span(f.baseline)}</p>
|
||||||
{f.baselineClaims && <p className="tnum text-xs text-muted">{f.baselineClaims} claims</p>}
|
{f.baselineClaims && <p className="text-xs text-muted"><span className="tnum">{f.baselineClaims}</span> claims as the normal picture</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-muted">
|
<p className="mt-2 text-xs text-muted">
|
||||||
@ -135,35 +137,67 @@ function Periods({ f }: { f: ReviewedFacts }) {
|
|||||||
function WorkStatement({ text, heading = 'Review coverage', bare = false }: { text: string; heading?: string; bare?: boolean }) {
|
function WorkStatement({ text, heading = 'Review coverage', bare = false }: { text: string; heading?: string; bare?: boolean }) {
|
||||||
const rows = workStatement(text);
|
const rows = workStatement(text);
|
||||||
const reviewed = rows ? reviewedFacts(rows.find((r) => r.key === 'REVIEWED')?.items ?? []) : null;
|
const reviewed = rows ? reviewedFacts(rows.find((r) => r.key === 'REVIEWED')?.items ?? []) : null;
|
||||||
const TONE: Record<string, string> = {
|
const row = (k: string) => rows?.find((r) => r.key === k);
|
||||||
FOUND: 'text-warn', WATCHED: 'text-muted', CLEAR: 'text-good', REVIEWED: 'text-ink',
|
|
||||||
'SINCE APPROVAL': 'text-ink', ACTIONS: 'text-ink', RATE: 'text-ink', VERDICT: 'text-warn',
|
// Older cases wrote a paragraph; bullets beat a wall of text.
|
||||||
};
|
if (!rows) {
|
||||||
return (
|
return (
|
||||||
<div className={bare ? '' : 'mt-3 border-t border-ai-edge/60 pt-3'}>
|
<div className={bare ? '' : 'mt-3 border-t border-ai-edge/60 pt-3'}>
|
||||||
{heading && <p className="text-[11px] font-semibold tracking-wide text-faint uppercase">{heading}</p>}
|
{heading && <p className="text-[11px] font-semibold tracking-wide text-faint uppercase">{heading}</p>}
|
||||||
{rows && reviewed && <Periods f={reviewed} />}
|
|
||||||
{rows ? (
|
|
||||||
<dl className="mt-2 grid gap-y-2 text-[13px] sm:grid-cols-[9.5rem_1fr] sm:gap-x-4">
|
|
||||||
{rows.filter((r) => !(reviewed && r.key === 'REVIEWED')).map((r) => (
|
|
||||||
<div key={r.key} className="contents">
|
|
||||||
<dt className={`font-semibold ${TONE[r.key] ?? 'text-ink'}`}>{r.label}</dt>
|
|
||||||
<dd className="flex flex-wrap gap-x-1.5 gap-y-1">
|
|
||||||
{r.items.map((it, i) => (
|
|
||||||
<span key={i} className="rounded-md bg-panel px-2 py-0.5 text-ink ring-1 ring-line ring-inset">
|
|
||||||
{it}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
) : (
|
|
||||||
<ul className="mt-2 list-disc space-y-1 pl-4 text-[13px] text-muted">
|
<ul className="mt-2 list-disc space-y-1 pl-4 text-[13px] text-muted">
|
||||||
{sentences(text).map((s, i) => (
|
{sentences(prose(text)).map((s, i) => (
|
||||||
<li key={i}>{s}</li>
|
<li key={i}>{s}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The four lines have different jobs, so they are not four identical chip
|
||||||
|
// rows: the periods are a comparison, the findings are the point, the rest
|
||||||
|
// is reassurance and belongs in small type.
|
||||||
|
const found = row('FOUND') ?? row('ACTIONS');
|
||||||
|
const watched = row('WATCHED');
|
||||||
|
const clear = row('CLEAR');
|
||||||
|
const rest = rows.filter((r) => !['REVIEWED', 'FOUND', 'WATCHED', 'CLEAR'].includes(r.key));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={bare ? '' : 'mt-3 border-t border-ai-edge/60 pt-3'}>
|
||||||
|
{heading && <p className="text-[11px] font-semibold tracking-wide text-faint uppercase">{heading}</p>}
|
||||||
|
{reviewed && <Periods f={reviewed} />}
|
||||||
|
{!reviewed && row('REVIEWED') && (
|
||||||
|
<p className="mt-2 text-[13px] text-muted">{row('REVIEWED')!.items.map(prose).join(' · ')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{found && found.items.length > 0 && (
|
||||||
|
<ul className="mt-3 space-y-1.5">
|
||||||
|
{found.items.map((it, i) => (
|
||||||
|
<li key={i} className="flex gap-2 text-[13px] leading-relaxed text-ink">
|
||||||
|
<span aria-hidden="true" className="mt-[7px] h-1.5 w-1.5 shrink-0 rounded-full bg-warn" />
|
||||||
|
<span>{prose(it)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rest.map((r) => (
|
||||||
|
<p key={r.key} className="mt-2 text-[13px] leading-relaxed text-ink">
|
||||||
|
<span className="font-semibold">{r.label}: </span>
|
||||||
|
{r.items.map(prose).join(' · ')}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{watched && watched.items.length > 0 && (
|
||||||
|
<p className="mt-3 text-xs leading-relaxed text-muted">
|
||||||
|
<span className="font-semibold text-ink">Watched, below the threshold: </span>
|
||||||
|
{watched.items.map(prose).join(' · ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{clear && clear.items.length > 0 && (
|
||||||
|
<p className="mt-1.5 text-xs leading-relaxed text-faint">
|
||||||
|
<span className="font-semibold">Checked, nothing found: </span>
|
||||||
|
{clear.items.map(prose).join(' · ')}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -306,7 +340,7 @@ export function SqlEvidence({ row, steps, decisions }: { row: Row; steps: AvaTra
|
|||||||
|
|
||||||
/** The re-check. Same shape either way, so "fixed" and "not fixed" read alike. */
|
/** The re-check. Same shape either way, so "fixed" and "not fixed" read alike. */
|
||||||
export function Recheck({ row }: { row: Row }) {
|
export function Recheck({ row }: { row: Row }) {
|
||||||
const summary = prose(row.recheck_summary);
|
const summary = str(row.recheck_summary);
|
||||||
if (!summary) return null;
|
if (!summary) return null;
|
||||||
const current = num(row.current_rate);
|
const current = num(row.current_rate);
|
||||||
const baseline = num(row.baseline_rate);
|
const baseline = num(row.baseline_rate);
|
||||||
|
|||||||
@ -225,8 +225,24 @@ export function workStatement(v: unknown): Array<{ key: string; label: string; i
|
|||||||
const m = line.match(/^([A-Z][A-Z ]{2,20}):\s*(.*)$/);
|
const m = line.match(/^([A-Z][A-Z ]{2,20}):\s*(.*)$/);
|
||||||
if (!m) continue;
|
if (!m) continue;
|
||||||
const key = m[1].trim();
|
const key = m[1].trim();
|
||||||
const items = m[2].split(/;\s*/).map((x) => x.trim().replace(/[.]$/, '')).filter(Boolean);
|
// Split on ";" but not inside brackets — "(… $6,764,000; 60.4% pending)"
|
||||||
rows.push({ key, label: WORK_LABELS[key] ?? key.charAt(0) + key.slice(1).toLowerCase(), items });
|
// is one fact, not two.
|
||||||
|
const items: string[] = [];
|
||||||
|
let depth = 0;
|
||||||
|
let buf = '';
|
||||||
|
for (const ch of m[2]) {
|
||||||
|
if (ch === '(' || ch === '[') depth++;
|
||||||
|
else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1);
|
||||||
|
if (ch === ';' && depth === 0) {
|
||||||
|
items.push(buf);
|
||||||
|
buf = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
buf += ch;
|
||||||
|
}
|
||||||
|
items.push(buf);
|
||||||
|
const cleaned = items.map((x) => x.trim().replace(/[.]$/, '')).filter(Boolean);
|
||||||
|
rows.push({ key, label: WORK_LABELS[key] ?? key.charAt(0) + key.slice(1).toLowerCase(), items: cleaned });
|
||||||
}
|
}
|
||||||
return rows.length >= 2 ? rows : null;
|
return rows.length >= 2 ? rows : null;
|
||||||
}
|
}
|
||||||
@ -253,9 +269,13 @@ export function reviewedFacts(items: string[]): ReviewedFacts | null {
|
|||||||
if (m) kv[m[1].toLowerCase()] = m[2].trim();
|
if (m) kv[m[1].toLowerCase()] = m[2].trim();
|
||||||
}
|
}
|
||||||
if (!kv.window && !kv.baseline) return null;
|
if (!kv.window && !kv.baseline) return null;
|
||||||
|
// Accept ISO ("2026-08-02..2026-09-13") and already-humanised dates, since
|
||||||
|
// the same text is sometimes rendered before it is parsed.
|
||||||
const range = (v?: string) => {
|
const range = (v?: string) => {
|
||||||
const m = v?.match(/(\d{4}-\d{2}-\d{2})\s*(?:\.\.|to|–|-)\s*(\d{4}-\d{2}-\d{2})/);
|
const iso = v?.match(/(\d{4}-\d{2}-\d{2})\s*(?:\.\.|to|–|-)\s*(\d{4}-\d{2}-\d{2})/);
|
||||||
return m ? { from: m[1], to: m[2] } : null;
|
if (iso) return { from: iso[1], to: iso[2] };
|
||||||
|
const human = v?.match(/^(.+?)\s*(?:\.\.|\bto\b|–)\s*(.+)$/);
|
||||||
|
return human ? { from: human[1].trim(), to: human[2].trim() } : null;
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
window: range(kv.window),
|
window: range(kv.window),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user