Jaspers Hub

jaspers / screener-mcp

pluginOfficialReviewed★ 0↓ 33

Jaspers Screener: Screens US SEC registrants by fundamentals, holders and insiders, then reads filings against written criteria with cited quotes.

10 KB
1import { useEffect, useState, type ReactElement } from 'react'
2import { Mark } from './Funnel'
3import {
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
27interface 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
42export 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
133interface 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. */
139function 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
184interface 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 */
196export 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. */
235const 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. */
238function 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. */
248function 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. */
261function 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}