fix: chart skeleton while the lakehouse answers; sentence splits survive decimals and initials

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-09-16 16:22:19 +05:30
parent de86cd9859
commit 03ff478e4a
3 changed files with 75 additions and 9 deletions

View File

@ -12,7 +12,7 @@ import {
XAxis,
YAxis,
} from 'recharts';
import { Table2 } from 'lucide-react';
import { Loader2, Table2 } from 'lucide-react';
import type { BriefingData, Slice } from '../api/charts';
import { RECOVERY_ORDER } from '../api/charts';
import { useTheme } from '../theme';
@ -57,6 +57,44 @@ const RECOVERY_HUE: Record<string, keyof typeof HUES.light> = {
'window closed': 'gray',
};
/** Holds the charts' place while the lakehouse views come back (2-6 s on a
* cold warehouse). Same frame, same height, so nothing jumps when they land. */
export function BriefingSkeleton({ error }: { error?: string | null }) {
return (
<section className="space-y-3" aria-busy={!error} aria-live="polite">
<div className="grid gap-3 lg:grid-cols-2">
{[0, 1].map((i) => (
<figure key={i} className="rounded-xl border border-line bg-panel px-4 pt-3.5 pb-2">
<figcaption className="space-y-1.5">
<span className="block h-3.5 w-2/5 animate-pulse rounded bg-panel2" />
<span className="block h-3 w-3/5 animate-pulse rounded bg-panel2" />
</figcaption>
<div className="mt-2 flex h-[220px] items-end gap-2 px-2 pb-6">
{[55, 40, 70, 35, 60, 45, 80, 50].map((h, j) => (
<span
key={j}
className="flex-1 animate-pulse rounded-t bg-panel2"
style={{ height: `${h}%`, animationDelay: `${j * 90}ms` }}
/>
))}
</div>
</figure>
))}
</div>
<p className="flex items-center gap-2 text-xs text-faint">
{error ? (
<>Charts unavailable — {error}</>
) : (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Drawing charts from the lakehouse…
</>
)}
</p>
</section>
);
}
export function Briefing({ data, slice }: { data: BriefingData; slice: Slice | null }) {
const { theme } = useTheme();
const c = HUES[theme];

View File

@ -105,10 +105,13 @@ export function splitRecommendation(block: string): { title: string; body: strin
// a period FOLLOWED BY A SPACE, a colon, or a newline. Never a bare period:
// "$1.56M" and "CO-197" must survive intact.
const rest = block.replace(/^\s*\d+\.\s*/, '');
const m = rest.match(/^([\s\S]*?)(?:\.\s|:\s|\n|$)([\s\S]*)$/);
if (!m) return { title: '', body: rest };
const title = m[1].trim();
const body = (m[2] ?? '').trim();
// Colon or newline first: "Payer liaison – M. Alvarez (BSM): negotiate…" titles at the colon,
// not at the initial. Otherwise the first real sentence (see `sentences`).
const cut = rest.search(/:\s|\n/);
const first = sentences(rest)[0] ?? rest;
const useColon = cut >= 0 && cut < first.length;
const title = (useColon ? rest.slice(0, cut) : first.replace(/[.!?]$/, '')).trim();
const body = (useColon ? rest.slice(cut + 1) : rest.slice(first.length)).trim();
// A "title" that swallowed the whole block means there was no break — show it as body.
if (title.length > 140) return { title: '', body: rest };
return { title, body };
@ -137,12 +140,33 @@ export function sqlSteps(v: unknown): Array<{ label: string; sql: string; note:
* headline is its first sentence or two — enough to say what happened, short
* enough to be set at 22px. Cut on sentence boundaries, never mid-clause.
*/
/** Split prose into sentences. A period ends a sentence only when a space (or
* the end) follows it AND it is not an initial or honorific: "44.7% (recent)",
* "M. Alvarez" and "Dr. Okafor" stay whole. */
export function sentences(s: string): string[] {
const out: string[] = [];
let start = 0;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (ch !== '.' && ch !== '!' && ch !== '?') continue;
const next = s[i + 1];
if (next !== undefined && next !== ' ') continue;
const prevWord = s.slice(start, i).match(/(\S+)$/)?.[1] ?? '';
if (ch === '.' && (/^[A-Z]$/.test(prevWord) || /^(Dr|Mr|Mrs|Ms|St|vs|approx|est|no|Inc|Co)$/i.test(prevWord))) continue;
out.push(s.slice(start, i + 1));
start = i + 1;
}
if (start < s.length) out.push(s.slice(start));
return out.map((x) => x.trim()).filter(Boolean);
}
export function headline(summary: unknown, maxChars = 220): string {
const s = str(summary).replace(/\s+/g, ' ').trim();
if (!s) return '';
const sentences = s.match(/[^.!?]+[.!?]+(\s|$)/g) ?? [s];
const parts = sentences(s).map((x) => x + ' ');
const sentences_ = parts.length ? parts : [s];
let out = '';
for (const sen of sentences) {
for (const sen of sentences_) {
if (out && (out + sen).length > maxChars) break;
out += sen;
if (out.length > maxChars * 0.6) break;

View File

@ -16,7 +16,7 @@ import { Badge, Button, ErrorNote, Facts, Spinner } from '../components/core';
import { ActivityForm } from '../components/ActivityForm';
import { AvaLive } from '../components/AvaLive';
import { Disclosure, FullStory, Recheck, Recommendations, SqlEvidence, Verdict } from '../components/Findings';
import { Briefing } from '../components/Briefing';
import { Briefing, BriefingSkeleton } from '../components/Briefing';
import { loadBriefing, sliceOf } from '../api/charts';
import type { Action } from '../api/config';
import { dateTime, num, str } from '../format';
@ -171,7 +171,11 @@ export function CaseScreen() {
{hasFindings && (
<>
<Verdict row={row} />
{briefing.data && <Briefing data={briefing.data} slice={slice} />}
{briefing.data ? (
<Briefing data={briefing.data} slice={slice} />
) : (
<BriefingSkeleton error={briefing.error} />
)}
<Recheck row={row} />
<Recommendations row={row} />
</>