EarningsCall Transcripts & Calendar: Shows a company's earnings call transcripts and the daily earnings calendar from EarningsCall.biz in a single panel view.
| 1 | // The view's pure half: its state read the forgiving way (main stores state as written, so a key can |
| 2 | // be missing or the wrong shape), which call it shows, find in a transcript, and what it publishes |
| 3 | // and says in the model's map. No React and no bridge here, so it runs under the tests. |
| 4 | |
| 5 | export type Row = Record<string, unknown> |
| 6 | |
| 7 | export interface CallsState { |
| 8 | /** The ticker. Empty shows the calendar. */ |
| 9 | symbol: string |
| 10 | exchange: string | null |
| 11 | /** The call's fiscal year and quarter, both or neither; neither is the latest call. */ |
| 12 | year: number | null |
| 13 | quarter: number | null |
| 14 | /** The calendar's day, YYYY-MM-DD; none is today. */ |
| 15 | date: string | null |
| 16 | find: string |
| 17 | } |
| 18 | |
| 19 | export interface Call { |
| 20 | year: number |
| 21 | quarter: number |
| 22 | date: string | null |
| 23 | published: boolean |
| 24 | } |
| 25 | |
| 26 | export interface CalendarRow { |
| 27 | exchange: string |
| 28 | symbol: string |
| 29 | company: string |
| 30 | year: number |
| 31 | quarter: number |
| 32 | date: string | null |
| 33 | ready: boolean |
| 34 | } |
| 35 | |
| 36 | /** One find match: the paragraph's index and the match's place in its text. */ |
| 37 | export interface Match { |
| 38 | paragraph: number |
| 39 | start: number |
| 40 | end: number |
| 41 | } |
| 42 | |
| 43 | export interface Segment { |
| 44 | text: string |
| 45 | /** The match's number across the whole transcript, or null for the text between matches. */ |
| 46 | match: number | null |
| 47 | } |
| 48 | |
| 49 | // State. |
| 50 | |
| 51 | export function readState(raw: unknown): CallsState { |
| 52 | const state = record(raw) ?? {} |
| 53 | const year = int(state['year']) |
| 54 | const quarter = int(state['quarter']) |
| 55 | const both = year !== null && quarter !== null && quarter >= 1 && quarter <= 4 |
| 56 | const exchange = typeof state['exchange'] === 'string' ? state['exchange'].trim().toUpperCase() : '' |
| 57 | const date = state['date'] |
| 58 | return { |
| 59 | symbol: typeof state['symbol'] === 'string' ? state['symbol'].trim().toUpperCase() : '', |
| 60 | exchange: exchange || null, |
| 61 | year: both ? year : null, |
| 62 | quarter: both ? quarter : null, |
| 63 | date: typeof date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null, |
| 64 | find: typeof state['find'] === 'string' ? state['find'].trim() : '', |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | /** The state as the view writes it back, whole: only what is set, so a cleared key is gone. */ |
| 69 | export function stored(state: CallsState): Row { |
| 70 | const out: Row = { symbol: state.symbol } |
| 71 | if (state.exchange) out['exchange'] = state.exchange |
| 72 | if (state.year !== null && state.quarter !== null) { |
| 73 | out['year'] = state.year |
| 74 | out['quarter'] = state.quarter |
| 75 | } |
| 76 | if (state.date) out['date'] = state.date |
| 77 | if (state.find) out['find'] = state.find |
| 78 | return out |
| 79 | } |
| 80 | |
| 81 | /** The quarter asked for, else the newest call with a transcript out. */ |
| 82 | export function chosenQuarter(calls: Call[], year: number | null, quarter: number | null): { year: number; quarter: number } | null { |
| 83 | if (year !== null && quarter !== null) return { year, quarter } |
| 84 | const latest = calls.find((call) => call.published) |
| 85 | return latest ? { year: latest.year, quarter: latest.quarter } : null |
| 86 | } |
| 87 | |
| 88 | // Rows from a run. |
| 89 | |
| 90 | /** |
| 91 | * Whether a run's meta answers this ask. A run keeps its last answer while the next one is out, so |
| 92 | * a new ticker would otherwise be read against the last company's calls, and a new day against the |
| 93 | * last day's calendar. |
| 94 | */ |
| 95 | export function answers(meta: Row, ask: Row): boolean { |
| 96 | return Object.entries(ask).every(([key, value]) => meta[key] === value) |
| 97 | } |
| 98 | |
| 99 | export function readCalls(rows: Row[] | undefined): Call[] { |
| 100 | const calls: Call[] = [] |
| 101 | for (const row of rows ?? []) { |
| 102 | const year = int(row['year']) |
| 103 | const quarter = int(row['quarter']) |
| 104 | if (year === null || quarter === null) continue |
| 105 | calls.push({ year, quarter, date: text(row['date']), published: row['published'] !== false }) |
| 106 | } |
| 107 | return calls |
| 108 | } |
| 109 | |
| 110 | /** One string per row, in order; a row without text stays as an empty paragraph so the Q&A index still points right. */ |
| 111 | export function readParagraphs(rows: Row[] | undefined): string[] { |
| 112 | return (rows ?? []).map((row) => (typeof row['text'] === 'string' ? row['text'] : '')) |
| 113 | } |
| 114 | |
| 115 | export function readCalendarRows(rows: Row[] | undefined): CalendarRow[] { |
| 116 | const out: CalendarRow[] = [] |
| 117 | for (const row of rows ?? []) { |
| 118 | const exchange = text(row['exchange']) |
| 119 | const symbol = text(row['symbol']) |
| 120 | const year = int(row['year']) |
| 121 | const quarter = int(row['quarter']) |
| 122 | if (!exchange || !symbol || year === null || quarter === null) continue |
| 123 | out.push({ exchange, symbol, company: text(row['company']) ?? symbol, year, quarter, date: text(row['date']), ready: row['ready'] === true }) |
| 124 | } |
| 125 | return out |
| 126 | } |
| 127 | |
| 128 | // Find. |
| 129 | |
| 130 | /** Every occurrence of the query, as typed text rather than a pattern, whatever the case. */ |
| 131 | export function findMatches(paragraphs: string[], query: string): Match[] { |
| 132 | const q = query.trim() |
| 133 | if (!q) return [] |
| 134 | const pattern = new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi') |
| 135 | const matches: Match[] = [] |
| 136 | paragraphs.forEach((paragraph, index) => { |
| 137 | for (const found of paragraph.matchAll(pattern)) matches.push({ paragraph: index, start: found.index, end: found.index + found[0].length }) |
| 138 | }) |
| 139 | return matches |
| 140 | } |
| 141 | |
| 142 | /** A paragraph cut into the text between matches and the matches themselves, in order. */ |
| 143 | export function segments(paragraph: string, ranges: { start: number; end: number; index: number }[]): Segment[] { |
| 144 | const out: Segment[] = [] |
| 145 | let at = 0 |
| 146 | for (const range of ranges) { |
| 147 | if (range.start > at) out.push({ text: paragraph.slice(at, range.start), match: null }) |
| 148 | out.push({ text: paragraph.slice(range.start, range.end), match: range.index }) |
| 149 | at = range.end |
| 150 | } |
| 151 | if (at < paragraph.length || out.length === 0) out.push({ text: paragraph.slice(at), match: null }) |
| 152 | return out |
| 153 | } |
| 154 | |
| 155 | /** The match with up to `width` characters either side, cut back to whole words. */ |
| 156 | export function snippet(paragraph: string, start: number, end: number, width = 80): string { |
| 157 | const from = Math.max(0, start - width) |
| 158 | const to = Math.min(paragraph.length, end + width) |
| 159 | let head = paragraph.slice(from, start) |
| 160 | let tail = paragraph.slice(end, to) |
| 161 | if (from > 0) head = head.includes(' ') ? head.slice(head.indexOf(' ') + 1) : '' |
| 162 | if (to < paragraph.length) tail = tail.includes(' ') ? tail.slice(0, tail.lastIndexOf(' ')) : '' |
| 163 | return `${from > 0 ? '…' : ''}${head}${paragraph.slice(start, end)}${tail}${to < paragraph.length ? '…' : ''}` |
| 164 | } |
| 165 | |
| 166 | // Output, for the orchestrator to read. Main refuses a publish over 4096 bytes of JSON. |
| 167 | |
| 168 | const PUBLISH_MAX = 4096 |
| 169 | const RECENT_CALLS = 8 |
| 170 | const SNIPPETS = 5 |
| 171 | const CALENDAR_EVENTS = 20 |
| 172 | |
| 173 | export interface CallOutput { |
| 174 | mode: 'call' |
| 175 | symbol: string |
| 176 | exchange: string | null |
| 177 | company: string | null |
| 178 | year: number | null |
| 179 | quarter: number | null |
| 180 | date: string | null |
| 181 | calls: Call[] |
| 182 | paragraphs: number |
| 183 | /** The paragraph number the questions start at, as the view numbers them. */ |
| 184 | qaParagraph: number | null |
| 185 | find: string |
| 186 | matches: number |
| 187 | snippets: { paragraph: number; text: string }[] |
| 188 | error: string | null |
| 189 | } |
| 190 | |
| 191 | export function callOutput(input: { |
| 192 | state: CallsState |
| 193 | exchange: string | null |
| 194 | company: string | null |
| 195 | calls: Call[] |
| 196 | chosen: { year: number; quarter: number } | null |
| 197 | date: string | null |
| 198 | paragraphs: string[] |
| 199 | qaStart: number | null |
| 200 | matches: Match[] |
| 201 | error: string | null |
| 202 | }): CallOutput { |
| 203 | const { paragraphs, matches } = input |
| 204 | return { |
| 205 | mode: 'call', |
| 206 | symbol: input.state.symbol, |
| 207 | exchange: input.exchange, |
| 208 | company: cap(input.company, 120), |
| 209 | year: input.chosen?.year ?? null, |
| 210 | quarter: input.chosen?.quarter ?? null, |
| 211 | date: input.date, |
| 212 | calls: input.calls.slice(0, RECENT_CALLS), |
| 213 | paragraphs: paragraphs.length, |
| 214 | qaParagraph: input.qaStart === null ? null : input.qaStart + 1, |
| 215 | find: cap(input.state.find, 100) ?? '', |
| 216 | matches: matches.length, |
| 217 | snippets: matches.slice(0, SNIPPETS).map((m) => ({ paragraph: m.paragraph + 1, text: snippet(paragraphs[m.paragraph] ?? '', m.start, m.end, 80) })), |
| 218 | error: cap(input.error, 300), |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | /** |
| 223 | * The transcript as the panel's text, which a model reads by path where output only counts it: a |
| 224 | * line naming the call, which a citation of it takes as its title, what kind of transcript it is, |
| 225 | * then the paragraphs under the numbers the view shows, the questions marked where they start. An |
| 226 | * empty paragraph keeps its number and is left out. |
| 227 | */ |
| 228 | export function transcriptText(input: { |
| 229 | symbol: string |
| 230 | company: string | null |
| 231 | year: number |
| 232 | quarter: number |
| 233 | date: string | null |
| 234 | paragraphs: string[] |
| 235 | qaStart: number | null |
| 236 | }): string { |
| 237 | const title = [`${input.symbol} Q${input.quarter} ${input.year} earnings call`, input.company, input.date?.slice(0, 10)].filter(Boolean).join(' · ') |
| 238 | const blocks = [ |
| 239 | `${title}\nMachine-transcribed plain transcript: no speaker names, and the paragraph breaks are inferred. ¶ numbers are the paragraphs as the view numbers them.`, |
| 240 | ] |
| 241 | input.paragraphs.forEach((paragraph, i) => { |
| 242 | if (i === input.qaStart) blocks.push('Questions and answers') |
| 243 | if (paragraph.trim()) blocks.push(`¶${i + 1} ${paragraph.trim()}`) |
| 244 | }) |
| 245 | return blocks.join('\n\n') |
| 246 | } |
| 247 | |
| 248 | export interface CalendarOutput { |
| 249 | mode: 'calendar' |
| 250 | date: string |
| 251 | count: number |
| 252 | events: CalendarRow[] |
| 253 | error: string | null |
| 254 | } |
| 255 | |
| 256 | export function calendarOutput(day: string, rows: CalendarRow[], error: string | null): CalendarOutput { |
| 257 | const output: CalendarOutput = { |
| 258 | mode: 'calendar', |
| 259 | date: day, |
| 260 | count: rows.length, |
| 261 | events: rows.slice(0, CALENDAR_EVENTS).map((row) => ({ ...row, company: cap(row.company, 60) ?? row.symbol })), |
| 262 | error: cap(error, 300), |
| 263 | } |
| 264 | while (output.events.length > 0 && byteLength(JSON.stringify(output)) > PUBLISH_MAX) output.events.pop() |
| 265 | return output |
| 266 | } |
| 267 | |
| 268 | /** The model's one line for this panel. An output still about another company says nothing about this one. */ |
| 269 | export function summarize(state: unknown, output: unknown): string { |
| 270 | const read = readState(state) |
| 271 | const out = record(output) |
| 272 | const error = text(out?.['error']) |
| 273 | if (!read.symbol) { |
| 274 | const date = text(out?.['date']) |
| 275 | if (out?.['mode'] !== 'calendar' || !date || (read.date && read.date !== date)) return read.date ? `Earnings calendar ${read.date}` : 'Earnings calendar, today' |
| 276 | return error ? `Earnings calendar ${date}: ${error}` : `Earnings calendar ${date}: ${int(out['count']) ?? 0} calls` |
| 277 | } |
| 278 | if (out?.['mode'] !== 'call' || out['symbol'] !== read.symbol) return `${read.symbol} earnings call` |
| 279 | if (error) return `${read.symbol} earnings call: ${error}` |
| 280 | const year = int(out['year']) |
| 281 | const quarter = int(out['quarter']) |
| 282 | if (year === null || quarter === null) return `${read.symbol} earnings call` |
| 283 | const date = text(out['date']) |
| 284 | const paragraphs = int(out['paragraphs']) |
| 285 | const find = text(out['find']) |
| 286 | const matches = int(out['matches']) ?? 0 |
| 287 | return [ |
| 288 | `${read.symbol} Q${quarter} ${year} earnings call`, |
| 289 | date ? `, ${date.slice(0, 10)}` : '', |
| 290 | paragraphs !== null ? `, ${paragraphs} paragraphs` : '', |
| 291 | find ? `; "${find}" ${matches} match${matches === 1 ? '' : 'es'}` : '', |
| 292 | ].join('') |
| 293 | } |
| 294 | |
| 295 | // Days. |
| 296 | |
| 297 | /** Today as YYYY-MM-DD in the viewer's own zone. */ |
| 298 | export function today(now: Date): string { |
| 299 | const pad = (n: number): string => String(n).padStart(2, '0') |
| 300 | return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` |
| 301 | } |
| 302 | |
| 303 | export function shiftDay(day: string, by: number): string { |
| 304 | const [year, month, date] = day.split('-').map(Number) |
| 305 | return new Date(Date.UTC(year!, month! - 1, date! + by)).toISOString().slice(0, 10) |
| 306 | } |
| 307 | |
| 308 | // Small readers. |
| 309 | |
| 310 | function record(value: unknown): Row | null { |
| 311 | return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Row) : null |
| 312 | } |
| 313 | |
| 314 | function text(value: unknown): string | null { |
| 315 | return typeof value === 'string' && value !== '' ? value : null |
| 316 | } |
| 317 | |
| 318 | function int(value: unknown): number | null { |
| 319 | return typeof value === 'number' && Number.isInteger(value) ? value : null |
| 320 | } |
| 321 | |
| 322 | function cap(value: string | null, max: number): string | null { |
| 323 | if (value === null) return null |
| 324 | return value.length > max ? `${value.slice(0, max - 1)}…` : value |
| 325 | } |
| 326 | |
| 327 | function byteLength(value: string): number { |
| 328 | return new TextEncoder().encode(value).length |
| 329 | } |