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 { useState, type KeyboardEvent, type ReactElement } from 'react'
2import {
3 fieldLabel,
4 filterText,
5 fromDraft,
6 groupFields,
7 opText,
8 readTickers,
9 toDraft,
10 unitHint,
11 type Draft,
12 type Field,
13 type Filter,
14 type Op,
15} from './screener'
16
17// What is filtered, and how it is changed by hand: one chip per filter with a cross, and a dialog
18// over the view to add and edit them. The dialog edits a draft and writes the list once, on Apply,
19// because the orchestrator writes it the same way — the complete set, one re-run. Which fields
20// there are, and what each takes, comes from the server's guide.
21
22/** The part of the state that says which companies are on the list. */
23export interface Listed {
24 filters: Filter[]
25 tickers: string[]
26 includeUnlisted: boolean
27}
28
29interface BarProps {
30 listed: Listed
31 fields: Map<string, Field>
32 onChange: (next: Listed) => void
33 /** Opens the dialog, from its button or from a chip: a filter is edited where the rest are. */
34 onOpen: () => void
35 onColumns: () => void
36}
37
38export function FilterBar({ listed, fields, onChange, onOpen, onColumns }: BarProps): ReactElement {
39 const { filters, tickers, includeUnlisted } = listed
40 return (
41 <>
42 <button type="button" className="sc-btn" onClick={onOpen}>
43 Filters
44 </button>
45 <button type="button" className="sc-btn" onClick={onColumns}>
46 Columns
47 </button>
48 {tickers.length > 0 && (
49 <Chip
50 label={tickers.length <= 3 ? tickers.join(', ') : `Own list of ${tickers.length}`}
51 onOpen={onOpen}
52 onRemove={() => onChange({ ...listed, tickers: [] })}
53 />
54 )}
55 {filters.map((filter, index) => (
56 <Chip
57 key={`${index}:${filter.field}`}
58 label={filterText(filter, fields.get(filter.field))}
59 onOpen={onOpen}
60 onRemove={() => onChange({ ...listed, filters: filters.filter((_, i) => i !== index) })}
61 />
62 ))}
63 {includeUnlisted && <Chip label="Incl. unlisted" onOpen={onOpen} onRemove={() => onChange({ ...listed, includeUnlisted: false })} />}
64 </>
65 )
66}
67
68function Chip({ label, onOpen, onRemove }: { label: string; onOpen: () => void; onRemove: () => void }): ReactElement {
69 return (
70 <span className="sc-chip">
71 <button type="button" className="sc-chip-label" onClick={onOpen} title="Edit">
72 {label}
73 </button>
74 <button type="button" className="sc-chip-x" aria-label={`Remove ${label}`} onClick={onRemove}>
75 ×
76 </button>
77 </span>
78 )
79}
80
81interface DialogProps {
82 listed: Listed
83 fields: Field[]
84 /** Why there are no fields to pick from, when there are none. */
85 fieldsError: string | null
86 onApply: (next: Listed) => void
87 onClose: () => void
88}
89
90/** A row of the dialog: the filter as typed, and the filter it came from while nobody has touched it. */
91interface Row extends Draft {
92 original?: Filter
93}
94
95const NEW_ROW: Row = { field: '', op: 'gte', a: '', b: '' }
96
97export function FilterDialog({ listed, fields, fieldsError, onApply, onClose }: DialogProps): ReactElement {
98 const byName = new Map(fields.map((field) => [field.field, field]))
99 // The dialog is mounted when it opens, so the draft is seeded once, here, rather than in an effect.
100 const [rows, setRows] = useState<Row[]>(() => listed.filters.map((filter) => ({ ...toDraft(filter, byName.get(filter.field)), original: filter })))
101 const [tickers, setTickers] = useState(listed.tickers.join(' '))
102 const [includeUnlisted, setIncludeUnlisted] = useState(listed.includeUnlisted)
103 const [errors, setErrors] = useState<Record<number, string>>({})
104 const groups = groupFields(fields.filter((field) => field.ops.length > 0))
105
106 function change(index: number, patch: Partial<Draft>): void {
107 setRows((previous) => previous.map((row, i) => (i === index ? { ...row, ...patch, original: undefined } : row)))
108 setErrors((previous) => ({ ...previous, [index]: '' }))
109 }
110
111 /** A new field keeps the op when it takes it, and never the value: a percentage is not a dollar figure. */
112 function changeField(index: number, name: string): void {
113 const ops = byName.get(name)?.ops ?? []
114 const op = rows[index]!.op
115 change(index, { field: name, op: ops.includes(op) ? op : (ops[0] ?? 'eq'), a: '', b: '' })
116 }
117
118 function apply(): void {
119 const filters: Filter[] = []
120 const found: Record<number, string> = {}
121 rows.forEach((row, index) => {
122 // A row nobody filled in is not a filter, and one nobody touched is the filter it was: the
123 // orchestrator may have set one on a field the guide does not list, or the guide may be out.
124 if (!row.field) return
125 if (row.original) {
126 filters.push(row.original)
127 return
128 }
129 const read = fromDraft(row, byName.get(row.field))
130 if ('error' in read) found[index] = read.error
131 else filters.push(read.filter)
132 })
133 setErrors(found)
134 if (Object.keys(found).length > 0) return
135 onApply({ filters, tickers: readTickers(tickers), includeUnlisted })
136 }
137
138 /** A form never submits in a view's frame, so Enter is read here. */
139 function onEnter(event: KeyboardEvent): void {
140 if (event.key === 'Enter') apply()
141 }
142
143 return (
144 <div className="sc-dialog" onMouseDown={onClose}>
145 <div className="sc-dialog-panel" onMouseDown={(event) => event.stopPropagation()}>
146 <div className="sc-dialog-head">
147 <span>Screener filters</span>
148 <button type="button" className="sc-btn" style={{ marginLeft: 'auto' }} onClick={onClose} aria-label="Close">
149 ×
150 </button>
151 </div>
152
153 <div className="sc-dialog-body">
154 <div className="sc-section">
155 <span className="sc-label">Filters (a company passes every one)</span>
156 {fields.length === 0 && <p className="sc-error">{fieldsError ?? 'The server sent no list of fields.'}</p>}
157 {rows.map((row, index) => {
158 const field = byName.get(row.field)
159 const valued = row.op !== 'is_null' && row.op !== 'not_null'
160 return (
161 <div key={index} className="sc-filter">
162 <div className="sc-filter-row">
163 <select
164 className="sc-input sc-filter-field"
165 aria-label="Field"
166 value={row.field}
167 title={field?.description}
168 onChange={(event) => changeField(index, event.target.value)}
169 >
170 <option value="">Pick a field…</option>
171 {/* A filter on a field the guide does not list still shows what it is. */}
172 {row.field && !field && <option value={row.field}>{row.field}</option>}
173 {groups.map(([group, members]) => (
174 <optgroup key={group} label={group}>
175 {members.map((member) => (
176 <option key={member.field} value={member.field}>
177 {fieldLabel(member.field)}
178 </option>
179 ))}
180 </optgroup>
181 ))}
182 </select>
183 <select
184 className="sc-input"
185 aria-label="Comparison"
186 value={row.op}
187 onChange={(event) => change(index, { op: event.target.value as Op })}
188 >
189 {(field?.ops ?? [row.op]).map((op) => (
190 <option key={op} value={op}>
191 {opText(op)}
192 </option>
193 ))}
194 </select>
195 {valued && (
196 <input
197 className="sc-input sc-filter-value"
198 aria-label={row.op === 'between' ? 'From' : 'Value'}
199 placeholder={row.op === 'in' ? 'values, separated by commas' : row.op === 'between' ? 'from' : 'value'}
200 value={row.a}
201 onChange={(event) => change(index, { a: event.target.value })}
202 onKeyDown={onEnter}
203 />
204 )}
205 {row.op === 'between' && (
206 <input
207 className="sc-input sc-filter-value"
208 aria-label="To"
209 placeholder="to"
210 value={row.b}
211 onChange={(event) => change(index, { b: event.target.value })}
212 onKeyDown={onEnter}
213 />
214 )}
215 {valued && field && <span className="sc-unit">{unitHint(field)}</span>}
216 <button
217 type="button"
218 className="sc-chip-x"
219 style={{ marginLeft: 'auto' }}
220 aria-label="Remove this filter"
221 onClick={() => {
222 setRows((previous) => previous.filter((_, i) => i !== index))
223 setErrors({})
224 }}
225 >
226 ×
227 </button>
228 </div>
229 {field && <p className="sc-hint">{field.description}</p>}
230 {errors[index] && <p className="sc-error">{errors[index]}</p>}
231 </div>
232 )
233 })}
234 <button type="button" className="sc-btn" disabled={fields.length === 0} onClick={() => setRows((previous) => [...previous, NEW_ROW])}>
235 Add a filter
236 </button>
237 </div>
238
239 <div className="sc-section">
240 <label className="sc-label" htmlFor="sc-tickers">
241 Own list: screen only these tickers, as the SEC writes them (BRK-B)
242 </label>
243 <textarea
244 id="sc-tickers"
245 className="sc-input sc-tickers"
246 rows={2}
247 value={tickers}
248 onChange={(event) => setTickers(event.target.value)}
249 placeholder="Empty for every company. AAPL MSFT NVDA …"
250 />
251 </div>
252
253 <div className="sc-section">
254 <label className="sc-check">
255 <input type="checkbox" checked={includeUnlisted} onChange={(event) => setIncludeUnlisted(event.target.checked)} />
256 <span>Include companies without a ticker (trusts, non-traded REITs, shells)</span>
257 </label>
258 </div>
259 </div>
260
261 <div className="sc-dialog-foot">
262 <button type="button" className="sc-btn" onClick={onClose}>
263 Cancel
264 </button>
265 <button type="button" className="sc-btn" onClick={apply}>
266 Apply
267 </button>
268 </div>
269 </div>
270 </div>
271 )
272}