Jaspers Screener: Screens US SEC registrants by fundamentals, holders and insiders, then reads filings against written criteria with cited quotes.
| 1 | import { useState, type KeyboardEvent, type ReactElement } from 'react' |
| 2 | import { |
| 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. */ |
| 23 | export interface Listed { |
| 24 | filters: Filter[] |
| 25 | tickers: string[] |
| 26 | includeUnlisted: boolean |
| 27 | } |
| 28 | |
| 29 | interface 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 | |
| 38 | export 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 | |
| 68 | function 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 | |
| 81 | interface 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. */ |
| 91 | interface Row extends Draft { |
| 92 | original?: Filter |
| 93 | } |
| 94 | |
| 95 | const NEW_ROW: Row = { field: '', op: 'gte', a: '', b: '' } |
| 96 | |
| 97 | export 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 | } |