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 })}>
‹ Calendar
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 ? (
) : !fresh ? (
) : !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 (
{
const call = options.find((c) => `${c.year}-${c.quarter}` === event.target.value)
if (call) onPick(call)
}}
>
{options.map((call) => (
Q{call.quarter} {call.year}
{call.date ? ` · ${longDate(call.date)}` : ''}
{call.published ? '' : ' (upcoming)'}
))}
)
}
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 && (
body.current?.querySelector('#ec-qa')?.scrollIntoView({ block: 'start' })}>
Q&A
)}
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 && (
<>
onStep(-1)}>
‹
onStep(1)}>
›
>
)}
)
}
function text(value: unknown): string | null {
return typeof value === 'string' && value !== '' ? value : null
}