Jaspers Screener: Screens US SEC registrants by fundamentals, holders and insiders, then reads filings against written criteria with cited quotes.
| 1 | import type { ReactElement } from 'react' |
| 2 | import { cellText, fieldLabel, readingAt, signOf, verificationMark, type CompanyResult, type Criterion, type Field, type Sort } from './screener' |
| 3 | |
| 4 | // The table: the company, a run's verdicts when there is one, then the fields filtered, sorted and |
| 5 | // asked for. A header sorts, and sorting is the server's job — the sort goes into the panel's |
| 6 | // state, the state into the source's arguments — so a click here is a change of state like any the |
| 7 | // orchestrator makes. |
| 8 | |
| 9 | interface Props { |
| 10 | rows: Record<string, unknown>[] |
| 11 | columns: string[] |
| 12 | fields: Map<string, Field> |
| 13 | sort: Sort |
| 14 | onSort: (field: string) => void |
| 15 | /** Nothing is drawn as empty while the first run is still out. */ |
| 16 | loading: boolean |
| 17 | /** The rows are of the list before the one a call is out for. */ |
| 18 | stale: boolean |
| 19 | /** What an empty table says: that nothing matches, or that a verdict filter hides what does. */ |
| 20 | empty: string |
| 21 | /** A qualitative run's criteria and what it found, by CIK. */ |
| 22 | run: { criteria: Criterion[]; results: Map<number, CompanyResult> } | null |
| 23 | /** The ticker whose evidence is open. */ |
| 24 | open: string |
| 25 | onOpen: (ticker: string) => void |
| 26 | } |
| 27 | |
| 28 | export function Table({ rows, columns, fields, sort, onSort, loading, stale, empty, run, open, onOpen }: Props): ReactElement { |
| 29 | return ( |
| 30 | <div className={stale ? 'sc-table-wrap sc-stale' : 'sc-table-wrap'}> |
| 31 | <table className="sc-table"> |
| 32 | <thead> |
| 33 | <tr> |
| 34 | <th className="sc-company">Company</th> |
| 35 | {run && <th className="sc-verdict-head">Verdict</th>} |
| 36 | {run?.criteria.map((criterion) => ( |
| 37 | <th key={criterion.id} className="sc-verdict-head" title={criterion.question}> |
| 38 | {criterion.id} |
| 39 | </th> |
| 40 | ))} |
| 41 | {columns.map((column) => { |
| 42 | const field = fields.get(column) |
| 43 | return ( |
| 44 | <th key={column} className="sc-num" title={field?.description}> |
| 45 | {/* The server refuses a sort on a list, so its header is not a button. */} |
| 46 | {field && !field.sortable ? ( |
| 47 | <span className="sc-sort">{fieldLabel(column)}</span> |
| 48 | ) : ( |
| 49 | <button type="button" className="sc-sort" onClick={() => onSort(column)}> |
| 50 | {fieldLabel(column)} |
| 51 | {sort.field === column ? (sort.dir === 'desc' ? ' ↓' : ' ↑') : ''} |
| 52 | </button> |
| 53 | )} |
| 54 | </th> |
| 55 | ) |
| 56 | })} |
| 57 | </tr> |
| 58 | </thead> |
| 59 | <tbody> |
| 60 | {rows.map((row, index) => { |
| 61 | const ticker = typeof row.ticker === 'string' ? row.ticker : null |
| 62 | const result = run?.results.get(Number(row.cik)) |
| 63 | return ( |
| 64 | <tr key={String(row.cik ?? ticker ?? index)} className={ticker !== null && ticker === open ? 'sc-row-open' : undefined}> |
| 65 | <td className="sc-company"> |
| 66 | {result && ticker ? ( |
| 67 | <button type="button" className="sc-ticker sc-ticker-open" title="Show the evidence" onClick={() => onOpen(ticker)}> |
| 68 | {ticker} |
| 69 | </button> |
| 70 | ) : ( |
| 71 | <span className="sc-ticker">{ticker ?? '—'}</span> |
| 72 | )} |
| 73 | <span className="sc-name" title={typeof row.sic_description === 'string' ? row.sic_description : undefined}> |
| 74 | {String(row.name ?? '')} |
| 75 | </span> |
| 76 | </td> |
| 77 | {run && <Verdict result={result} />} |
| 78 | {run?.criteria.map((criterion) => <CriterionCell key={criterion.id} result={result} id={criterion.id} />)} |
| 79 | {columns.map((column) => { |
| 80 | const sign = signOf(column, row[column]) |
| 81 | return ( |
| 82 | <td key={column} className={sign ? `sc-num sc-${sign}` : 'sc-num'}> |
| 83 | {cellText(row[column], fields.get(column))} |
| 84 | </td> |
| 85 | ) |
| 86 | })} |
| 87 | </tr> |
| 88 | ) |
| 89 | })} |
| 90 | </tbody> |
| 91 | </table> |
| 92 | {rows.length === 0 && !loading && <p className="sc-empty">{empty}</p>} |
| 93 | </div> |
| 94 | ) |
| 95 | } |
| 96 | |
| 97 | /** A company's verdict over all its criteria, or where it is in the run. */ |
| 98 | function Verdict({ result }: { result: CompanyResult | undefined }): ReactElement { |
| 99 | if (!result) return <td className="sc-verdict sc-status">—</td> |
| 100 | if (result.status === 'error') { |
| 101 | return ( |
| 102 | <td className="sc-verdict sc-error" title={result.error ?? undefined}> |
| 103 | error |
| 104 | </td> |
| 105 | ) |
| 106 | } |
| 107 | if (result.status !== 'done') return <td className="sc-verdict sc-status">{result.status === 'running' ? <Reading step={result.step} /> : 'queued'}</td> |
| 108 | const verdict = result.verdict ?? 'unverified' |
| 109 | return <td className={`sc-verdict sc-v-${verdict}`}>{verdict}</td> |
| 110 | } |
| 111 | |
| 112 | /** A company being read: the steps behind it and the one it is in, where the server says which. */ |
| 113 | function Reading({ step }: { step: string | null }): ReactElement { |
| 114 | const reading = readingAt(step) |
| 115 | if (!reading) return <>reading…</> |
| 116 | return ( |
| 117 | <span className="sc-rail" title={`Step ${reading.at + 1} of ${reading.of} of reading a company`}> |
| 118 | {Array.from({ length: reading.of }, (_, at) => ( |
| 119 | <i key={at} className={at < reading.at ? 'sc-rail-done' : at === reading.at ? 'sc-rail-now' : undefined} /> |
| 120 | ))} |
| 121 | {reading.word} |
| 122 | </span> |
| 123 | ) |
| 124 | } |
| 125 | |
| 126 | /** One criterion's answer; a verification that did not hold is marked, with what was wrong as its tooltip. */ |
| 127 | function CriterionCell({ result, id }: { result: CompanyResult | undefined; id: string }): ReactElement { |
| 128 | const answer = result?.criteria.find((criterion) => criterion.id === id) |
| 129 | if (!answer) return <td className="sc-verdict sc-status">—</td> |
| 130 | const mark = verificationMark(answer.verification) |
| 131 | return ( |
| 132 | <td className={`sc-verdict sc-v-${answer.verdict}`} title={mark ?? answer.rationale}> |
| 133 | {answer.verdict} |
| 134 | {mark && <span className="sc-mark"> !</span>} |
| 135 | </td> |
| 136 | ) |
| 137 | } |