EarningsCall Transcripts & Calendar: Shows a company's earnings call transcripts and the daily earnings calendar from EarningsCall.biz in a single panel view.
| 1 | import { Fragment, useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactElement } from 'react' |
| 2 | import { useData, type PanelRef } from '@jaspers-ai/sdk' |
| 3 | import { |
| 4 | answers, |
| 5 | callOutput, |
| 6 | chosenQuarter, |
| 7 | findMatches, |
| 8 | readCalls, |
| 9 | readParagraphs, |
| 10 | segments, |
| 11 | transcriptText, |
| 12 | type Call, |
| 13 | type CallsState, |
| 14 | } from './calls' |
| 15 | import { cleanError, longDate, Publish, TickerField } from './shared' |
| 16 | |
| 17 | // One company's call: its calls to pick from, then the transcript in paragraphs with the questions |
| 18 | // marked, and find. The transcript waits for the list of calls, which names the exchange and, when |
| 19 | // no quarter is asked for, the latest call; that keeps the transcript's arguments the same however |
| 20 | // the call was opened, so the run is shared and cached. |
| 21 | |
| 22 | type Write = (next: CallsState) => void |
| 23 | |
| 24 | export function CallPage({ panel, state, write }: { panel: PanelRef; state: CallsState; write: Write }): ReactElement { |
| 25 | const ask = state.exchange ? { symbol: state.symbol, exchange: state.exchange } : { symbol: state.symbol } |
| 26 | const run = useData('earningscall/calls', ask) |
| 27 | // Until the new company's calls are in, the run still holds the last company's. |
| 28 | const fresh = run.data !== undefined && answers(run.meta, ask) |
| 29 | const calls = fresh ? readCalls(run.data) : [] |
| 30 | const exchange = fresh ? text(run.meta['exchange']) : null |
| 31 | const company = fresh ? text(run.meta['company']) : null |
| 32 | const chosen = fresh ? chosenQuarter(calls, state.year, state.quarter) : null |
| 33 | const error = run.error ? cleanError(run.error) : null |
| 34 | const empty = { state, exchange, company, calls, chosen: null, date: null, paragraphs: [], qaStart: null, matches: [] } |
| 35 | return ( |
| 36 | <div className="ec-root"> |
| 37 | <div className="ec-bar"> |
| 38 | <button type="button" className="ec-button" title="Earnings calendar" onClick={() => write({ ...state, symbol: '', exchange: null, year: null, quarter: null })}> |
| 39 | ‹ Calendar |
| 40 | </button> |
| 41 | <TickerField symbol={state.symbol} onSymbol={(symbol) => write({ ...state, symbol, exchange: null, year: null, quarter: null })} /> |
| 42 | {chosen && calls.length > 0 && <QuarterPicker calls={calls} chosen={chosen} onPick={(call) => write({ ...state, year: call.year, quarter: call.quarter })} />} |
| 43 | <span className="ec-muted ec-grow ec-ellipsis">{company ?? ''}</span> |
| 44 | {run.loading && <span className="ec-muted">loading…</span>} |
| 45 | </div> |
| 46 | {error ? ( |
| 47 | <div className="ec-body"> |
| 48 | <Publish panel={panel} output={{ ...callOutput({ ...empty, error }) }} text={null} /> |
| 49 | <p className="ec-error">{error}</p> |
| 50 | </div> |
| 51 | ) : !fresh ? ( |
| 52 | <div className="ec-body"> |
| 53 | <p className="ec-note">Loading calls…</p> |
| 54 | </div> |
| 55 | ) : !chosen || !exchange ? ( |
| 56 | <div className="ec-body"> |
| 57 | <Publish panel={panel} output={{ ...callOutput({ ...empty, error: `EarningsCall has no published calls for ${state.symbol}.` }) }} text={null} /> |
| 58 | <p className="ec-note">No published calls for {state.symbol}.</p> |
| 59 | </div> |
| 60 | ) : ( |
| 61 | <Transcript |
| 62 | key={`${exchange}:${state.symbol}:${chosen.year}:${chosen.quarter}`} |
| 63 | panel={panel} |
| 64 | state={state} |
| 65 | write={write} |
| 66 | exchange={exchange} |
| 67 | company={company} |
| 68 | calls={calls} |
| 69 | chosen={chosen} |
| 70 | /> |
| 71 | )} |
| 72 | </div> |
| 73 | ) |
| 74 | } |
| 75 | |
| 76 | function QuarterPicker({ calls, chosen, onPick }: { calls: Call[]; chosen: { year: number; quarter: number }; onPick: (call: Call) => void }): ReactElement { |
| 77 | const value = `${chosen.year}-${chosen.quarter}` |
| 78 | const listed = calls.some((call) => `${call.year}-${call.quarter}` === value) |
| 79 | // A quarter asked for by state but missing from the list still shows as picked. |
| 80 | const options = listed ? calls : [{ ...chosen, date: null, published: true }, ...calls] |
| 81 | return ( |
| 82 | <select |
| 83 | className="ec-select" |
| 84 | value={value} |
| 85 | title="Call" |
| 86 | onChange={(event) => { |
| 87 | const call = options.find((c) => `${c.year}-${c.quarter}` === event.target.value) |
| 88 | if (call) onPick(call) |
| 89 | }} |
| 90 | > |
| 91 | {options.map((call) => ( |
| 92 | <option key={`${call.year}-${call.quarter}`} value={`${call.year}-${call.quarter}`} disabled={!call.published}> |
| 93 | Q{call.quarter} {call.year} |
| 94 | {call.date ? ` · ${longDate(call.date)}` : ''} |
| 95 | {call.published ? '' : ' (upcoming)'} |
| 96 | </option> |
| 97 | ))} |
| 98 | </select> |
| 99 | ) |
| 100 | } |
| 101 | |
| 102 | function Transcript(props: { |
| 103 | panel: PanelRef |
| 104 | state: CallsState |
| 105 | write: Write |
| 106 | exchange: string |
| 107 | company: string | null |
| 108 | calls: Call[] |
| 109 | chosen: { year: number; quarter: number } |
| 110 | }): ReactElement { |
| 111 | const { panel, state, write, exchange, company, calls, chosen } = props |
| 112 | const run = useData('earningscall/transcript', { symbol: state.symbol, exchange, year: chosen.year, quarter: chosen.quarter }) |
| 113 | const paragraphs = useMemo(() => readParagraphs(run.data), [run.data]) |
| 114 | const qaStart = typeof run.meta['qaStart'] === 'number' ? run.meta['qaStart'] : null |
| 115 | const listed = calls.find((call) => call.year === chosen.year && call.quarter === chosen.quarter) |
| 116 | const date = text(run.meta['date']) ?? listed?.date ?? null |
| 117 | const error = run.error ? cleanError(run.error) : null |
| 118 | const matches = useMemo(() => findMatches(paragraphs, state.find), [paragraphs, state.find]) |
| 119 | // The whole transcript for a model to read; find and the match stepped to leave it as it is. |
| 120 | const transcript = useMemo( |
| 121 | () => transcriptText({ symbol: state.symbol, company, year: chosen.year, quarter: chosen.quarter, date, paragraphs, qaStart }), |
| 122 | [state.symbol, company, chosen.year, chosen.quarter, date, paragraphs, qaStart], |
| 123 | ) |
| 124 | const [current, setCurrent] = useState(0) |
| 125 | const body = useRef<HTMLDivElement>(null) |
| 126 | |
| 127 | // A transcript still out would publish as one with no paragraphs; the last output stands until it is in. |
| 128 | const settled = run.data !== undefined || error !== null |
| 129 | |
| 130 | // A new query, or a transcript that just arrived, starts again at its first match. |
| 131 | useEffect(() => setCurrent(0), [matches]) |
| 132 | useEffect(() => { |
| 133 | if (matches.length === 0) return |
| 134 | body.current?.querySelector(`[data-match="${current}"]`)?.scrollIntoView({ block: 'center' }) |
| 135 | }, [current, matches]) |
| 136 | |
| 137 | const byParagraph = useMemo(() => { |
| 138 | const map = new Map<number, { start: number; end: number; index: number }[]>() |
| 139 | matches.forEach((m, index) => { |
| 140 | const list = map.get(m.paragraph) ?? [] |
| 141 | list.push({ start: m.start, end: m.end, index }) |
| 142 | map.set(m.paragraph, list) |
| 143 | }) |
| 144 | return map |
| 145 | }, [matches]) |
| 146 | |
| 147 | const step = (by: number): void => { |
| 148 | if (matches.length > 0) setCurrent((c) => (c + by + matches.length) % matches.length) |
| 149 | } |
| 150 | |
| 151 | return ( |
| 152 | <> |
| 153 | {settled && <Publish panel={panel} output={{ ...callOutput({ state, exchange, company, calls, chosen, date, paragraphs, qaStart, matches, error }) }} text={error ? null : transcript} />} |
| 154 | <div className="ec-subbar"> |
| 155 | <span className="ec-title"> |
| 156 | Q{chosen.quarter} {chosen.year} |
| 157 | </span> |
| 158 | {date && <span className="ec-muted">{longDate(date)}</span>} |
| 159 | {qaStart !== null && paragraphs.length > 0 && ( |
| 160 | <button type="button" className="ec-button" onClick={() => body.current?.querySelector('#ec-qa')?.scrollIntoView({ block: 'start' })}> |
| 161 | Q&A |
| 162 | </button> |
| 163 | )} |
| 164 | <span className="ec-grow" /> |
| 165 | <FindField find={state.find} count={matches.length} current={current} onFind={(find) => write({ ...state, find })} onStep={step} /> |
| 166 | </div> |
| 167 | <div className="ec-body" ref={body}> |
| 168 | {error ? ( |
| 169 | <p className="ec-error">{error}</p> |
| 170 | ) : run.data === undefined ? ( |
| 171 | <p className="ec-note">Loading transcript…</p> |
| 172 | ) : ( |
| 173 | <article className="ec-transcript"> |
| 174 | <p className="ec-note">Machine-transcribed plain transcript: no speaker names, and the paragraph breaks are inferred.</p> |
| 175 | {paragraphs.map((paragraph, i) => ( |
| 176 | <Fragment key={i}> |
| 177 | {i === qaStart && ( |
| 178 | <h3 id="ec-qa" className="ec-divider"> |
| 179 | Questions and answers |
| 180 | </h3> |
| 181 | )} |
| 182 | <p className="ec-p"> |
| 183 | <span className="ec-n">{i + 1}</span> |
| 184 | {segments(paragraph, byParagraph.get(i) ?? []).map((segment, k) => |
| 185 | segment.match === null ? ( |
| 186 | <Fragment key={k}>{segment.text}</Fragment> |
| 187 | ) : ( |
| 188 | <mark key={k} data-match={segment.match} className={segment.match === current ? 'ec-current' : undefined}> |
| 189 | {segment.text} |
| 190 | </mark> |
| 191 | ), |
| 192 | )} |
| 193 | </p> |
| 194 | </Fragment> |
| 195 | ))} |
| 196 | </article> |
| 197 | )} |
| 198 | </div> |
| 199 | </> |
| 200 | ) |
| 201 | } |
| 202 | |
| 203 | /** Enter finds what was typed, or steps to the next match when it is already found; Shift+Enter steps back; Escape clears. */ |
| 204 | function FindField(props: { find: string; count: number; current: number; onFind: (find: string) => void; onStep: (by: number) => void }): ReactElement { |
| 205 | const { find, count, current, onFind, onStep } = props |
| 206 | const [draft, setDraft] = useState(find) |
| 207 | useEffect(() => setDraft(find), [find]) |
| 208 | const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => { |
| 209 | if (event.key === 'Escape') { |
| 210 | event.preventDefault() |
| 211 | setDraft('') |
| 212 | if (find) onFind('') |
| 213 | return |
| 214 | } |
| 215 | if (event.key !== 'Enter' || event.nativeEvent.isComposing) return |
| 216 | event.preventDefault() |
| 217 | const query = draft.trim() |
| 218 | if (query !== find) onFind(query) |
| 219 | else onStep(event.shiftKey ? -1 : 1) |
| 220 | } |
| 221 | return ( |
| 222 | <span className="ec-find"> |
| 223 | <input className="ec-input ec-input-find" value={draft} placeholder="Find" spellCheck={false} onChange={(event) => setDraft(event.target.value)} onKeyDown={onKeyDown} /> |
| 224 | {find && <span className="ec-muted ec-count">{count === 0 ? 'none' : `${current + 1} of ${count}`}</span>} |
| 225 | {count > 1 && ( |
| 226 | <> |
| 227 | <button type="button" className="ec-button" title="Previous match" onClick={() => onStep(-1)}> |
| 228 | ‹ |
| 229 | </button> |
| 230 | <button type="button" className="ec-button" title="Next match" onClick={() => onStep(1)}> |
| 231 | › |
| 232 | </button> |
| 233 | </> |
| 234 | )} |
| 235 | </span> |
| 236 | ) |
| 237 | } |
| 238 | |
| 239 | function text(value: unknown): string | null { |
| 240 | return typeof value === 'string' && value !== '' ? value : null |
| 241 | } |