import { Fragment, useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactElement } from 'react' import { useData, type PanelRef } from '@jaspers-ai/sdk' import { answers, callOutput, chosenQuarter, findMatches, readCalls, readParagraphs, segments, transcriptText, type Call, type CallsState, } from './calls' import { cleanError, longDate, Publish, TickerField } from './shared' // One company's call: its calls to pick from, then the transcript in paragraphs with the questions // marked, and find. The transcript waits for the list of calls, which names the exchange and, when // no quarter is asked for, the latest call; that keeps the transcript's arguments the same however // the call was opened, so the run is shared and cached. type Write = (next: CallsState) => void export function CallPage({ panel, state, write }: { panel: PanelRef; state: CallsState; write: Write }): ReactElement { const ask = state.exchange ? { symbol: state.symbol, exchange: state.exchange } : { symbol: state.symbol } const run = useData('earningscall/calls', ask) // Until the new company's calls are in, the run still holds the last company's. const fresh = run.data !== undefined && answers(run.meta, ask) const calls = fresh ? readCalls(run.data) : [] const exchange = fresh ? text(run.meta['exchange']) : null const company = fresh ? text(run.meta['company']) : null const chosen = fresh ? chosenQuarter(calls, state.year, state.quarter) : null const error = run.error ? cleanError(run.error) : null const empty = { state, exchange, company, calls, chosen: null, date: null, paragraphs: [], qaStart: null, matches: [] } return (
write({ ...state, symbol, exchange: null, year: null, quarter: null })} /> {chosen && calls.length > 0 && write({ ...state, year: call.year, quarter: call.quarter })} />} {company ?? ''} {run.loading && loading…}
{error ? (

{error}

) : !fresh ? (

Loading calls…

) : !chosen || !exchange ? (

No published calls for {state.symbol}.

) : ( )}
) } function QuarterPicker({ calls, chosen, onPick }: { calls: Call[]; chosen: { year: number; quarter: number }; onPick: (call: Call) => void }): ReactElement { const value = `${chosen.year}-${chosen.quarter}` const listed = calls.some((call) => `${call.year}-${call.quarter}` === value) // A quarter asked for by state but missing from the list still shows as picked. const options = listed ? calls : [{ ...chosen, date: null, published: true }, ...calls] return ( ) } function Transcript(props: { panel: PanelRef state: CallsState write: Write exchange: string company: string | null calls: Call[] chosen: { year: number; quarter: number } }): ReactElement { const { panel, state, write, exchange, company, calls, chosen } = props const run = useData('earningscall/transcript', { symbol: state.symbol, exchange, year: chosen.year, quarter: chosen.quarter }) const paragraphs = useMemo(() => readParagraphs(run.data), [run.data]) const qaStart = typeof run.meta['qaStart'] === 'number' ? run.meta['qaStart'] : null const listed = calls.find((call) => call.year === chosen.year && call.quarter === chosen.quarter) const date = text(run.meta['date']) ?? listed?.date ?? null const error = run.error ? cleanError(run.error) : null const matches = useMemo(() => findMatches(paragraphs, state.find), [paragraphs, state.find]) // The whole transcript for a model to read; find and the match stepped to leave it as it is. const transcript = useMemo( () => transcriptText({ symbol: state.symbol, company, year: chosen.year, quarter: chosen.quarter, date, paragraphs, qaStart }), [state.symbol, company, chosen.year, chosen.quarter, date, paragraphs, qaStart], ) const [current, setCurrent] = useState(0) const body = useRef(null) // A transcript still out would publish as one with no paragraphs; the last output stands until it is in. const settled = run.data !== undefined || error !== null // A new query, or a transcript that just arrived, starts again at its first match. useEffect(() => setCurrent(0), [matches]) useEffect(() => { if (matches.length === 0) return body.current?.querySelector(`[data-match="${current}"]`)?.scrollIntoView({ block: 'center' }) }, [current, matches]) const byParagraph = useMemo(() => { const map = new Map() matches.forEach((m, index) => { const list = map.get(m.paragraph) ?? [] list.push({ start: m.start, end: m.end, index }) map.set(m.paragraph, list) }) return map }, [matches]) const step = (by: number): void => { if (matches.length > 0) setCurrent((c) => (c + by + matches.length) % matches.length) } return ( <> {settled && }
Q{chosen.quarter} {chosen.year} {date && {longDate(date)}} {qaStart !== null && paragraphs.length > 0 && ( )} write({ ...state, find })} onStep={step} />
{error ? (

{error}

) : run.data === undefined ? (

Loading transcript…

) : (

Machine-transcribed plain transcript: no speaker names, and the paragraph breaks are inferred.

{paragraphs.map((paragraph, i) => ( {i === qaStart && (

Questions and answers

)}

{i + 1} {segments(paragraph, byParagraph.get(i) ?? []).map((segment, k) => segment.match === null ? ( {segment.text} ) : ( {segment.text} ), )}

))}
)}
) } /** Enter finds what was typed, or steps to the next match when it is already found; Shift+Enter steps back; Escape clears. */ function FindField(props: { find: string; count: number; current: number; onFind: (find: string) => void; onStep: (by: number) => void }): ReactElement { const { find, count, current, onFind, onStep } = props const [draft, setDraft] = useState(find) useEffect(() => setDraft(find), [find]) const onKeyDown = (event: KeyboardEvent): void => { if (event.key === 'Escape') { event.preventDefault() setDraft('') if (find) onFind('') return } if (event.key !== 'Enter' || event.nativeEvent.isComposing) return event.preventDefault() const query = draft.trim() if (query !== find) onFind(query) else onStep(event.shiftKey ? -1 : 1) } return ( setDraft(event.target.value)} onKeyDown={onKeyDown} /> {find && {count === 0 ? 'none' : `${current + 1} of ${count}`}} {count > 1 && ( <> )} ) } function text(value: unknown): string | null { return typeof value === 'string' && value !== '' ? value : null }