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:
parent
de86cd9859
commit
03ff478e4a
@ -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];
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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} />
|
||||
</>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user