Jaspers Hub

jaspers / earningscall

pluginOfficialReviewed★ 0↓ 6

EarningsCall Transcripts & Calendar: Shows a company's earnings call transcripts and the daily earnings calendar from EarningsCall.biz in a single panel view.

10 KB
1import { Fragment, useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactElement } from 'react'
2import { useData, type PanelRef } from '@jaspers-ai/sdk'
3import {
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'
15import { 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
22type Write = (next: CallsState) => void
23
24export 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
76function 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
102function 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&amp;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. */
204function 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
239function text(value: unknown): string | null {
240 return typeof value === 'string' && value !== '' ? value : null
241}