Jaspers Screener: Screens US SEC registrants by fundamentals, holders and insiders, then reads filings against written criteria with cited quotes.
| 1 | import { useEffect, useState, type ReactElement } from 'react' |
| 2 | import { Mark } from './Funnel' |
| 3 | import { |
| 4 | doingText, |
| 5 | elapsedText, |
| 6 | finished, |
| 7 | MAX_CRITERIA, |
| 8 | slug, |
| 9 | stageOf, |
| 10 | verdictTickers, |
| 11 | VERDICTS, |
| 12 | type Criterion, |
| 13 | type Progress, |
| 14 | type RowState, |
| 15 | type Run, |
| 16 | type RunSnapshot, |
| 17 | type Screen, |
| 18 | type Verdict, |
| 19 | } from './screener' |
| 20 | |
| 21 | // The qualitative stage, in two parts. Its bar: the criteria in words, and the button that has every |
| 22 | // company on the list read against them. A run costs model calls for every company, so it starts on |
| 23 | // the button (or on the orchestrator's word, which is the same write), never on a criterion alone. |
| 24 | // And where a run is, which is the funnel's last row (RunProgress): the list goes on being narrowed |
| 25 | // there, by reading instead of by a filter. |
| 26 | |
| 27 | interface Props { |
| 28 | criteria: Criterion[] |
| 29 | onCriteria: (next: Criterion[]) => void |
| 30 | run: Run | null |
| 31 | snapshot: RunSnapshot | null |
| 32 | screen: Screen | null |
| 33 | /** The server's session holds the list on screen and no screen call is out, so a run would freeze the right list. */ |
| 34 | settled: boolean |
| 35 | verdicts: Verdict[] |
| 36 | onVerdicts: (next: Verdict[]) => void |
| 37 | onRun: () => void |
| 38 | onRetry: () => void |
| 39 | onClear: () => void |
| 40 | } |
| 41 | |
| 42 | export function QualitativeBar(props: Props): ReactElement { |
| 43 | const { criteria, onCriteria, run, snapshot, screen, settled, verdicts, onVerdicts, onRun, onRetry, onClear } = props |
| 44 | const [question, setQuestion] = useState('') |
| 45 | const [problem, setProblem] = useState('') |
| 46 | // The criteria stand while a run is starting or going on. A run asked for with none is waiting for one, so the editor stays. |
| 47 | const stage = stageOf(criteria, run) |
| 48 | const busy = stage === 'starting' || stage === 'running' |
| 49 | |
| 50 | function add(): void { |
| 51 | const typed = question.trim() |
| 52 | // The server's own bounds on a criterion, said before it has to refuse. |
| 53 | if (typed.length < 5 || typed.length > 500) { |
| 54 | setProblem('A criterion is a question of 5 to 500 characters.') |
| 55 | return |
| 56 | } |
| 57 | onCriteria([...criteria, { id: slug(typed, criteria.map((c) => c.id)), question: typed }]) |
| 58 | setQuestion('') |
| 59 | setProblem('') |
| 60 | } |
| 61 | |
| 62 | return ( |
| 63 | <div className="sc-bar"> |
| 64 | <span className="sc-status">Criteria</span> |
| 65 | {criteria.map((criterion) => ( |
| 66 | <span key={criterion.id} className="sc-chip sc-q" title={`${criterion.id}: ${criterion.question}`}> |
| 67 | <span className="sc-q-text">{criterion.question}</span> |
| 68 | <button |
| 69 | type="button" |
| 70 | className="sc-chip-x" |
| 71 | aria-label={`Remove ${criterion.question}`} |
| 72 | disabled={busy} |
| 73 | onClick={() => onCriteria(criteria.filter((c) => c.id !== criterion.id))} |
| 74 | > |
| 75 | × |
| 76 | </button> |
| 77 | </span> |
| 78 | ))} |
| 79 | {!busy && criteria.length < MAX_CRITERIA && ( |
| 80 | <> |
| 81 | <input |
| 82 | className="sc-input sc-q-input" |
| 83 | aria-label="A criterion, as a yes or no question" |
| 84 | placeholder="A yes/no question the filings can answer, e.g. Does the company depend on a sole-source supplier?" |
| 85 | value={question} |
| 86 | onChange={(event) => setQuestion(event.target.value)} |
| 87 | onKeyDown={(event) => { |
| 88 | if (event.key === 'Enter') add() |
| 89 | }} |
| 90 | /> |
| 91 | <button type="button" className="sc-btn" disabled={!question.trim()} onClick={add}> |
| 92 | Add |
| 93 | </button> |
| 94 | </> |
| 95 | )} |
| 96 | {problem && <span className="sc-error">{problem}</span>} |
| 97 | |
| 98 | <span className="sc-run"> |
| 99 | <RunStatus |
| 100 | run={run} |
| 101 | screen={screen} |
| 102 | settled={settled} |
| 103 | ready={criteria.length > 0} |
| 104 | onRun={onRun} |
| 105 | onRetry={onRetry} |
| 106 | onClear={onClear} |
| 107 | /> |
| 108 | </span> |
| 109 | |
| 110 | {snapshot && ( |
| 111 | <span className="sc-verdicts"> |
| 112 | {VERDICTS.map((verdict) => { |
| 113 | const on = verdicts.includes(verdict) |
| 114 | return ( |
| 115 | <button |
| 116 | key={verdict} |
| 117 | type="button" |
| 118 | className={on ? `sc-btn sc-toggle sc-on sc-v-${verdict}` : `sc-btn sc-toggle sc-v-${verdict}`} |
| 119 | aria-pressed={on} |
| 120 | title={on ? `Showing ${verdict}; click to stop filtering on it` : `Show only ${verdict}`} |
| 121 | onClick={() => onVerdicts(on ? verdicts.filter((v) => v !== verdict) : [...verdicts, verdict])} |
| 122 | > |
| 123 | {verdict} {snapshot.progress[verdict]} |
| 124 | </button> |
| 125 | ) |
| 126 | })} |
| 127 | </span> |
| 128 | )} |
| 129 | </div> |
| 130 | ) |
| 131 | } |
| 132 | |
| 133 | interface StatusProps extends Pick<Props, 'run' | 'screen' | 'settled' | 'onRun' | 'onRetry' | 'onClear'> { |
| 134 | /** There is at least one criterion. */ |
| 135 | ready: boolean |
| 136 | } |
| 137 | |
| 138 | /** What can be done about a run: start one, try a failed one again, take one off the panel. Where a run is, the funnel says. */ |
| 139 | function RunStatus({ run, screen, settled, ready, onRun, onRetry, onClear }: StatusProps): ReactElement | null { |
| 140 | if (run === null) { |
| 141 | if (!screen) return null |
| 142 | if (!screen.ready) { |
| 143 | const over = screen.count - screen.max |
| 144 | return ( |
| 145 | <span className="sc-status"> |
| 146 | {screen.count === 0 ? 'Nothing to read' : `${over.toLocaleString('en-US')} over the ${screen.max} the qualitative stage takes`} |
| 147 | </span> |
| 148 | ) |
| 149 | } |
| 150 | return ( |
| 151 | <button |
| 152 | type="button" |
| 153 | className="sc-btn sc-primary" |
| 154 | disabled={!ready || !settled} |
| 155 | onClick={onRun} |
| 156 | title="Every company on the list is read against every criterion. It costs model calls for each." |
| 157 | > |
| 158 | Run on {screen.count} {screen.count === 1 ? 'company' : 'companies'} |
| 159 | </button> |
| 160 | ) |
| 161 | } |
| 162 | if (run.status === 'error') { |
| 163 | return ( |
| 164 | <> |
| 165 | <span className="sc-error">{run.error ?? 'The run failed.'}</span> |
| 166 | <button type="button" className="sc-btn" onClick={onRetry}> |
| 167 | {run.id ? 'Read again' : 'Try again'} |
| 168 | </button> |
| 169 | <button type="button" className="sc-btn" onClick={onClear}> |
| 170 | Clear |
| 171 | </button> |
| 172 | </> |
| 173 | ) |
| 174 | } |
| 175 | // There is no stopping a run: clearing takes it off this panel, and the server goes on reading. |
| 176 | if (!run.id) return null |
| 177 | return ( |
| 178 | <button type="button" className="sc-btn" onClick={onClear} title="Takes the run off this panel. The server keeps it, and finishes it if it is still going."> |
| 179 | Clear |
| 180 | </button> |
| 181 | ) |
| 182 | } |
| 183 | |
| 184 | interface ProgressProps { |
| 185 | criteria: Criterion[] |
| 186 | run: Run | null |
| 187 | snapshot: RunSnapshot | null |
| 188 | } |
| 189 | |
| 190 | /** |
| 191 | * The qualitative stage as a row of the funnel: still to come, at work, or in. While a run goes on it |
| 192 | * says what the run is doing and for how long, draws how far it is, and names who has passed and who |
| 193 | * has failed as each company's verdict lands. Why a run failed is said in the bar, beside the button |
| 194 | * that tries it again. |
| 195 | */ |
| 196 | export function RunProgress({ criteria, run, snapshot }: ProgressProps): ReactElement { |
| 197 | const stage = stageOf(criteria, run) |
| 198 | const state: RowState = stage === 'done' ? 'done' : stage === 'starting' || stage === 'running' ? 'working' : 'pending' |
| 199 | const now = useNow(stage === 'running' && snapshot?.status !== 'done') |
| 200 | const clock = snapshot && elapsedText(snapshot, now) |
| 201 | const fetching = snapshot?.status !== 'done' && snapshot?.phase?.status === 'fetching' ? snapshot.phase : null |
| 202 | return ( |
| 203 | <div className={`sc-step sc-step-${state}`}> |
| 204 | <Mark state={state} /> |
| 205 | <span className="sc-step-what"> |
| 206 | Criteria{criteria.length > 0 && ` · ${criteria.length} ${criteria.length === 1 ? 'question' : 'questions'}`} |
| 207 | </span> |
| 208 | <span className="sc-step-run"> |
| 209 | {stage === 'no_criteria' && <span>A run was asked for. Add a criterion and it starts.</span>} |
| 210 | {stage === 'starting' && <span className="sc-step-note">starting…</span>} |
| 211 | {stage === 'error' && <span className="sc-error">failed</span>} |
| 212 | {(stage === 'running' || stage === 'done') && !snapshot && <span className="sc-step-note">reading the run…</span>} |
| 213 | {(stage === 'running' || stage === 'done') && snapshot && ( |
| 214 | <> |
| 215 | <span className="sc-step-note"> |
| 216 | {doingText(snapshot)} |
| 217 | {clock && ` · ${clock}`} |
| 218 | </span> |
| 219 | {snapshot.phase?.status === 'off' && <span title="The run could not fetch its companies' earnings calls, so it reads their filings alone.">filings only</span>} |
| 220 | {fetching ? ( |
| 221 | <Meter label="Companies whose earnings calls are in" done={fetching.done} parts={[['fetched', fetching.done], ['queued', fetching.total - fetching.done]]} /> |
| 222 | ) : ( |
| 223 | <Meter label="Companies read" done={finished(snapshot.progress)} parts={SEGMENTS.map((key) => [key, snapshot.progress[key]])} /> |
| 224 | )} |
| 225 | <Names verdict="pass" count={snapshot.progress.pass} tickers={verdictTickers(snapshot, 'pass')} /> |
| 226 | <Names verdict="fail" count={snapshot.progress.fail} tickers={verdictTickers(snapshot, 'fail')} /> |
| 227 | </> |
| 228 | )} |
| 229 | </span> |
| 230 | </div> |
| 231 | ) |
| 232 | } |
| 233 | |
| 234 | /** The order a run's companies are drawn in along its meter: what was found, then what is still to be. */ |
| 235 | const SEGMENTS = ['pass', 'unverified', 'unclear', 'fail', 'error', 'running', 'queued'] as const satisfies readonly (keyof Progress)[] |
| 236 | |
| 237 | /** How far something is, as one bar cut into its parts, each as wide as its count. */ |
| 238 | function Meter({ label, done, parts }: { label: string; done: number; parts: [key: string, count: number][] }): ReactElement { |
| 239 | const total = parts.reduce((sum, [, count]) => sum + count, 0) |
| 240 | return ( |
| 241 | <span className="sc-meter" role="progressbar" aria-label={label} aria-valuemin={0} aria-valuemax={total} aria-valuenow={done}> |
| 242 | {parts.map(([key, count]) => count > 0 && <i key={key} className={`sc-seg sc-seg-${key}`} style={{ flexGrow: count }} title={`${count} ${key}`} />)} |
| 243 | </span> |
| 244 | ) |
| 245 | } |
| 246 | |
| 247 | /** How many companies have one verdict, and who: the tickers in the verdict's colour, as many as there is room for. Nothing until one has it. */ |
| 248 | function Names({ verdict, count, tickers }: { verdict: Verdict; count: number; tickers: string[] }): ReactElement | null { |
| 249 | if (count === 0) return null |
| 250 | return ( |
| 251 | <span className={`sc-step-verdict sc-v-${verdict}`} title={tickers.join(', ') || undefined}> |
| 252 | <span> |
| 253 | <b>{count}</b> {verdict} |
| 254 | </span> |
| 255 | {tickers.length > 0 && <span className="sc-names">{tickers.join(' · ')}</span>} |
| 256 | </span> |
| 257 | ) |
| 258 | } |
| 259 | |
| 260 | /** The time now, once a second while something is going on. */ |
| 261 | function useNow(ticking: boolean): number { |
| 262 | const [now, setNow] = useState(() => Date.now()) |
| 263 | useEffect(() => { |
| 264 | if (!ticking) return |
| 265 | setNow(Date.now()) |
| 266 | const timer = setInterval(() => setNow(Date.now()), 1000) |
| 267 | return () => clearInterval(timer) |
| 268 | }, [ticking]) |
| 269 | return now |
| 270 | } |