diff --git a/src/components/Briefing.tsx b/src/components/Briefing.tsx index 480a4ab..9a4b52d 100644 --- a/src/components/Briefing.tsx +++ b/src/components/Briefing.tsx @@ -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 = { '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 ( +
+
+ {[0, 1].map((i) => ( +
+
+ + +
+
+ {[55, 40, 70, 35, 60, 45, 80, 50].map((h, j) => ( + + ))} +
+
+ ))} +
+

+ {error ? ( + <>Charts unavailable — {error} + ) : ( + <> + + Drawing charts from the lakehouse… + + )} +

+
+ ); +} + export function Briefing({ data, slice }: { data: BriefingData; slice: Slice | null }) { const { theme } = useTheme(); const c = HUES[theme]; diff --git a/src/format.ts b/src/format.ts index 9ac217d..4cd2a3d 100644 --- a/src/format.ts +++ b/src/format.ts @@ -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; diff --git a/src/screens/CaseScreen.tsx b/src/screens/CaseScreen.tsx index 6bddb74..9971e7e 100644 --- a/src/screens/CaseScreen.tsx +++ b/src/screens/CaseScreen.tsx @@ -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 && ( <> - {briefing.data && } + {briefing.data ? ( + + ) : ( + + )}