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.

5.9 KB
1import { useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from 'react'
2import type { Listed } from './Filters'
3import { commonPrefix, filterText, funnelRows, funnelStates, removedText, type Field, type FunnelRow, type RowState, type Screen } from './screener'
4
5// The funnel: the screen as it goes. A row for the universe and one for each filter in its turn —
6// how many companies it left, how many it removed, and the first of those by name — and under them
7// the row of the stage after the filters. The rows are the state's, so they are on screen before the
8// server has answered, and they fill in from the top: the rows of an answer are shown one after
9// another, so a list that was narrowed in one call still reads as the narrowing it was.
10
11/** How long a row of an answer waits for the row before it. */
12const ROW_MS = 320
13
14interface Props {
15 listed: Listed
16 screen: Screen | null
17 /** A screen call is out. */
18 loading: boolean
19 fields: Map<string, Field>
20 /** The row of the stage after the filters. */
21 children?: ReactNode
22}
23
24export function Funnel({ listed, screen, loading, fields, children }: Props): ReactElement {
25 const { filters, tickers, includeUnlisted } = listed
26 const rows = useMemo(() => funnelRows({ filters, tickers, includeUnlisted }, screen), [filters, tickers, includeUnlisted, screen])
27 const unanswered = rows.findIndex((row) => !row.answer)
28 const answered = unanswered < 0 ? rows.length : unanswered
29 const shown = useShown(rows.slice(0, answered).map((row) => row.key))
30 const states = funnelStates(rows.length, answered, shown, loading)
31 // What the list was before its last change is said of the list, so on its last row, and only by an answer that is of this list.
32 const whole = screen !== null && answered === rows.length && screen.applied.length === filters.length
33 const before = whole && screen.countBefore !== screen.count ? screen.countBefore : null
34 return (
35 <div className="sc-funnel" aria-busy={states.includes('working')}>
36 {rows.map((row, at) => (
37 <Step key={row.key} row={row} state={states[at]!} fields={fields} before={at === rows.length - 1 ? before : null}>
38 {at === 0 && (
39 <>
40 {tickers.length > 0 ? 'Own list' : includeUnlisted ? 'All companies, unlisted too' : 'Listed companies'}
41 {screen && screen.unknownTickers.length > 0 && <span className="sc-error"> · no company for {screen.unknownTickers.join(', ')}</span>}
42 </>
43 )}
44 </Step>
45 ))}
46 {children}
47 </div>
48 )
49}
50
51/**
52 * How many of the rows with an answer are on screen. The ones that were there stay; the rest come one
53 * at a time, the first of an answer at once. Someone who asked for less motion gets them all at once.
54 */
55function useShown(keys: string[]): number {
56 const [seen, setSeen] = useState<string[]>([])
57 const shown = commonPrefix(seen, keys)
58 const all = keys.join('\n')
59 /** The answer whose first row has been shown. */
60 const begun = useRef<string | null>(null)
61 useEffect(() => {
62 if (shown >= keys.length) return
63 const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches
64 const timer = setTimeout(
65 () => {
66 begun.current = all
67 setSeen(keys.slice(0, still ? keys.length : shown + 1))
68 },
69 begun.current === all ? ROW_MS : 0,
70 )
71 return () => clearTimeout(timer)
72 // `keys` is read through `all`, its value: it is a new list every render.
73 }, [all, shown])
74 return shown
75}
76
77interface StepProps {
78 row: FunnelRow
79 state: RowState
80 fields: Map<string, Field>
81 /** What the list was before its last change, on the row that says what it is now. */
82 before: number | null
83 /** What the row of the universe says it is. */
84 children?: ReactNode
85}
86
87function Step({ row, state, fields, before, children }: StepProps): ReactElement {
88 const field = row.filter ? fields.get(row.filter.field) : undefined
89 const what = row.filter ? filterText(row.filter, field) : null
90 const answer = state === 'done' ? row.answer : null
91 const named = answer?.removed ?? []
92 const more = answer && answer.out !== null ? answer.out - named.length : 0
93 return (
94 <div className={`sc-step sc-step-${state}`}>
95 <Mark state={state} />
96 <span className="sc-step-what" title={what ?? undefined}>
97 {what ?? children}
98 </span>
99 {state === 'working' && <span className="sc-step-note">screening…</span>}
100 {answer && (
101 <>
102 <span className="sc-step-n" title={answer.matches === null ? undefined : `${n(answer.matches)} pass this filter on its own`}>
103 {answer.left !== null ? (
104 <>
105 <b>{n(answer.left)}</b>
106 {row.filter && ' left'}
107 </>
108 ) : (
109 // A server that does not say what a filter left says what it passes alone.
110 answer.matches !== null && (
111 <>
112 <b>{n(answer.matches)}</b> on its own
113 </>
114 )
115 )}
116 {before !== null && <> (was {n(before)})</>}
117 </span>
118 <span className="sc-step-n">{answer.out !== null && `${n(answer.out)} out`}</span>
119 <span className="sc-step-names">
120 {named.length > 0 && (
121 <span className="sc-names" title={`The first of them in the table's order: ${named.map((company) => company.name ?? company.ticker ?? '—').join(', ')}`}>
122 {named.map((company) => removedText(company, field)).join(' · ')}
123 </span>
124 )}
125 {named.length > 0 && more > 0 && <span className="sc-names-more">+{n(more)}</span>}
126 </span>
127 </>
128 )}
129 </div>
130 )
131}
132
133/** A row's mark: a tick once it is in, a square while it is at work (CSS has it pulse) or still to come. */
134export function Mark({ state }: { state: RowState }): ReactElement {
135 return (
136 <span className="sc-step-mark" aria-hidden="true">
137 {state === 'done' && '✓'}
138 </span>
139 )
140}
141
142function n(value: number): string {
143 return value.toLocaleString('en-US')
144}