import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, ScrollBox, useRendererHost, type ScrollBoxRenderable, } from "../../../ui"; import { Button, ConfirmDialog, DataTableStackView, DataTableView, KeyValueRow, Notice, PageStackView, PaneStatusBody, QueryBar, Tabs, usePaneHeaderTabs, TextField, usePaneFooter, usePaneNoticeFooter, useTableLoadMore, type DataTableColumn, type DataTableVisibleRange, type SelectControl, } from "../../../components"; import { useAsyncResource, useAutoRefresh, usePaneSettingValue, usePluginAppActions, usePluginPaneState, useShortcut, } from "../../../public/react"; import { useDialog, type PromptContext } from "../../../ui/dialog"; import { useThemeColors } from "../../../theme/theme-context"; import { apiClient } from "../../../api-client"; import { NUMERIC_FIELDS, type NumericField, type ScreenDefinition, type ScreenField, type ScreenRow, } from "../../../api-client/equity-screener"; import type { PaneProps } from "../../../types/plugin"; import { useResearchCloudSession } from "../shared/research-cloud-session"; import { usePlanAccess } from "../shared/plan-access"; import { SignInWall } from "../cloud/auth-actions"; import { CriterionEditor } from "./criterion-editor"; import { fetchSavedScreens, fetchScreenFields, screenerApi, validateSavedScreen, } from "./client"; import { columnWidth, criterionText, DEFAULT_SCREEN, formatScreenValue, metricDate, parseScreenDefinition, resultFields, SHORT_LABELS, screenLabel, screenRowId, } from "./model"; import { useScreenResults } from "./results"; import { overlayLiveScreenRows } from "./live"; import { useLiveQuoteEntries } from "../../../state/hooks/quote-streaming"; import { buildScreenerQuoteTargets } from "../shared/screener-live-quotes"; import { useLiveStreamingSetting } from "../shared/live-streaming"; import { getTableWidth } from "../../../components/ui/table-layout"; /** Rows streamed beyond the visible window so a short scroll lands on live prices. */ const STREAM_OVERSCAN = 8; /** Before the table reports its window, stream what a full-height pane shows. */ const INITIAL_STREAM_ROWS = 40; const TABS = [ { value: "results", label: "Results" }, { value: "criteria", label: "Criteria" }, { value: "saved", label: "Saved" }, ]; const errorText = (error: unknown) => error instanceof Error ? error.message : "Request failed."; const date = (value: string | null) => value?.slice(0, 10) ?? "--"; const rank = (value: number | null) => value == null ? "--" : value.toFixed(0); /** * Symbol and name, then the focus metric with its covered-universe percentile * and date, then the screen's criteria and context metrics as width allows. */ function resultColumns( width: number, definition: ScreenDefinition, metric: NumericField, ): DataTableColumn[] { const [focus, ...others] = resultFields(definition, metric); const metricColumn = (field: NumericField): DataTableColumn => ({ id: `m:${field}`, label: SHORT_LABELS[field], width: columnWidth(field), align: "right", }); const build = ( extra: DataTableColumn[], exchange: boolean, sector: boolean, ): DataTableColumn[] => [ { id: "symbol", label: "SYMBOL", width: 8, align: "left" }, { id: "name", label: "NAME", width: 18, flexGrow: 1, align: "left" }, ...(exchange ? [{ id: "exchange", label: "EXCH", width: 7, align: "left" as const }] : []), ...(sector ? [{ id: "sector", label: "SECTOR", width: 22, align: "left" as const }] : []), metricColumn(focus!), { id: "percentile", label: "PCTL", width: 4, align: "right" }, { id: "date", label: "AS OF", width: 10, align: "left" }, ...extra, ]; // Measured the way the table draws them (header floor, gaps, lead gaps), so a // column is only added when it fits whole. const fits = (columns: DataTableColumn[]) => getTableWidth(columns) <= width; let shown: DataTableColumn[] = []; for (const field of others) { const next = [...shown, metricColumn(field)]; if (!fits(build(next, false, false))) break; shown = next; } const exchange = fits(build(shown, true, false)); const sector = exchange && fits(build(shown, true, true)); return build(shown, exchange, sector); } const loadFields = () => fetchScreenFields(); function ScreenDetail({ row, width, height, }: { row: ScreenRow; width: number; height: number; }) { return ( {NUMERIC_FIELDS.map((field) => { const metric = row.metrics[field]; const stamp = metricDate(metric); return ( ); })} ); } export function EquityScreenerPane(props: PaneProps) { const session = useResearchCloudSession(); const [seed] = usePaneSettingValue( "definition", undefined, ); let initial: ScreenDefinition; try { initial = seed ? parseScreenDefinition(JSON.parse(seed)) : DEFAULT_SCREEN; } catch (error) { return ( ); } return ( ); } function EquityScreenView({ width, height, focused, initial, }: PaneProps & { initial: ScreenDefinition }) { const colors = useThemeColors(), dialog = useDialog(), host = useRendererHost(); const { notify, createPaneFromTemplate } = usePluginAppActions(); const session = useResearchCloudSession(), access = usePlanAccess(); const [definition, setDefinition] = usePluginPaneState("definition", initial); const [metric, setMetric] = usePaneSettingValue( "metric", "marketCap", ); const [mode, setMode] = usePluginPaneState("mode", "results"); const [selectedId, setSelectedId] = usePluginPaneState( "selected", null, ); const [openId, setOpenId] = usePluginPaneState("open", null); const [selectedCriterion, setSelectedCriterion] = usePluginPaneState( "criterion", "0", ); const [editing, setEditing] = useState(null); const [savedSelectedId, setSavedSelectedId] = usePluginPaneState< string | null >("savedSelected", null); const [currentSavedId, setCurrentSavedId] = usePluginPaneState( "currentSaved", null, ); const [saving, setSaving] = useState(false), [exporting, setExporting] = useState(false); const [saveFocus, setSaveFocus] = useState("name"); const [saveForm, setSaveForm] = useState(false), [name, setName] = useState(""); const [actionError, setActionError] = useState(null); const fields = useAsyncResource(loadFields); const savedLoader = useCallback( () => fetchSavedScreens(), [session.requestKey], ); const saved = useAsyncResource( access.emailVerified && (mode === "saved" || saveForm) ? savedLoader : null, ); const results = useScreenResults(definition, session.requestKey); const data = results.data; const snapshotRows = data?.rows ?? []; // Price, change, volume and market cap stream for the rows on screen; the // screen itself (membership, order, percentiles) stays the snapshot's. const liveStreaming = useLiveStreamingSetting(); const [visibleRange, setVisibleRange] = useState({ start: 0, end: INITIAL_STREAM_ROWS }); const streamTargets = useMemo(() => { const onScreen = snapshotRows.slice(Math.max(0, visibleRange.start - STREAM_OVERSCAN), visibleRange.end + STREAM_OVERSCAN); const selectedRow = snapshotRows.find((row) => screenRowId(row) === selectedId); const streamed = selectedRow && !onScreen.includes(selectedRow) ? [...onScreen, selectedRow] : onScreen; const selectedTarget = selectedRow ? selectedRow.symbol : null; return buildScreenerQuoteTargets(streamed, selectedTarget); }, [selectedId, snapshotRows, visibleRange]); const { entries: liveEntries } = useLiveQuoteEntries(streamTargets, { freshnessScopeKey: `equity-screener:${JSON.stringify(definition)}`, liveStreaming, }); const rows = useMemo(() => overlayLiveScreenRows(snapshotRows, liveEntries), [liveEntries, snapshotRows]); const opened = rows.find((row) => screenRowId(row) === openId); const selected = opened ?? rows.find((row) => screenRowId(row) === selectedId) ?? rows[0]; const currentSaved = saved.data?.find((row) => row.id === currentSavedId); const selectedSaved = saved.data?.find((row) => row.id === savedSelectedId) ?? saved.data?.[0]; useEffect(() => { if ( openId && data && !opened && data.nextCursor && !results.loading && !results.loadingMore ) void results.loadMore(); }, [ openId, data, opened, results.loading, results.loadingMore, results.loadMore, ]); const tableScroll = useRef(null); const queryControl = useRef(null); const loadMore = useTableLoadMore( tableScroll, !opened && !results.loading && !results.loadingMore && !!data?.nextCursor, results.loadMore, ); const metricFields = fields.data?.fields.filter((field) => field.kind === "number") ?? []; const columns = resultColumns(width, definition, metric); const apply = (next: ScreenDefinition) => { try { setDefinition(parseScreenDefinition(next)); setOpenId(null); setActionError(null); } catch (error) { setActionError(errorText(error)); } }; const switchMode = (next: string) => { setMode(next); setEditing(null); setSaveForm(false); setActionError(null); }; const research = () => { if (selected) createPaneFromTemplate("new-ticker-detail-pane", { symbol: selected.symbol, listing: { name: selected.name ?? selected.symbol, exchange: selected.exchange, currency: selected.currency ?? "", type: "STK", }, }); }; const startSave = () => { if (!access.emailVerified) { switchMode("saved"); return; } setName(currentSaved?.name ?? ""); setSaveForm(true); setSaveFocus("name"); setActionError(null); }; const save = async (copy = false) => { if (saving || !name.trim()) return; setSaving(true); setActionError(null); const requestSession = session.requestKey; try { const entry = validateSavedScreen( currentSaved && !copy ? await screenerApi.update( currentSaved.id, currentSaved.revision, name.trim(), definition, ) : await screenerApi.create( name.trim(), definition, ), ); if ( requestSession !== JSON.stringify([ apiClient.getCurrentUser()?.id ?? null, access.emailVerified, ]) ) return; setCurrentSavedId(entry.id); setSavedSelectedId(entry.id); setSaveForm(false); setMode("saved"); await saved.reload(); notify({ body: `Saved ${entry.name}`, type: "success" }); } catch (error) { setActionError(errorText(error)); } finally { setSaving(false); } }; const removeSaved = async () => { const entry = selectedSaved; if (!entry || saving) return; const confirmed = await dialog .prompt({ content: (context: PromptContext) => ( ), }) .catch(() => false); if (!confirmed) return; setSaving(true); try { await screenerApi.remove(entry.id, entry.revision); await saved.reload(); if (currentSavedId === entry.id) setCurrentSavedId(null); notify({ body: "Screen deleted", type: "success" }); } catch (error) { setActionError(errorText(error)); } finally { setSaving(false); } }; const exportAll = async () => { if (!data?.snapshot || exporting) return; setExporting(true); setActionError(null); try { if (!host.saveTextFile) throw new Error("File export is unavailable in this renderer."); const file = await screenerApi.export( data.definition, data.snapshot.id, ); if (file.snapshotId !== data.snapshot.id || typeof file.csv !== "string") throw new Error("Export snapshot did not match the displayed screen."); const location = await host.saveTextFile({ name: file.filename, text: file.csv, mimeType: "text/csv", }); notify({ body: `Exported ${location}`, type: "success" }); } catch (error) { setActionError(errorText(error)); } finally { setExporting(false); } }; useAutoRefresh(results.updatedAt, results.load); const openQueryControl = () => queryControl.current?.open(); const hints = saveForm || editing !== null ? [] : mode === "criteria" ? [ { id: "add", key: "a", label: "dd criterion", onPress: () => setEditing(-1), disabled: !fields.data || definition.criteria.length >= 20, }, ...(fields.data ? [{ id: "currency", key: "c", label: "urrency", onPress: openQueryControl }] : []), { id: "remove", key: "d", label: "elete criterion", onPress: () => apply({ ...definition, criteria: definition.criteria.filter( (_, index) => index !== Number(selectedCriterion), ), }), disabled: !definition.criteria.length, }, ] : mode === "saved" ? [ { id: "delete", key: "d", label: "elete", onPress: () => { void removeSaved(); }, disabled: !selectedSaved || saving, }, ] : [ // The ranking metric's menu; the query bar hides while a row is open. ...(data && !opened ? [{ id: "metric", key: "m", label: "etric", onPress: openQueryControl }] : []), { id: "save", key: "s", label: "ave screen", onPress: startSave }, { id: "export", key: "x", label: "port all", title: "Export All", onPress: () => { void exportAll(); }, disabled: !data?.snapshot || exporting, }, { id: "research", key: "o", label: "pen research", onPress: research, disabled: !selected, }, ]; usePaneFooter( "equity-screener", () => ({ info: [ ...(results.loading || results.loadingMore ? [ { id: "loading", parts: [ { text: results.loadingMore ? "loading more" : "loading screen", tone: "muted" as const, }, ], }, ] : []), ...(data?.snapshot ? [ { id: "asof", parts: [ { text: `${data.universe.matched.toLocaleString("en-US")} of ${data.universe.covered.toLocaleString("en-US")} covered`, }, { text: `· ${data.definition.currency ?? "native currencies"} · snapshot ${data.snapshot.assembledAt.slice(0, 16).replace("T", " ")} UTC`, tone: "muted" as const, }, ], }, ] : []), ...(saving || exporting ? [ { id: "busy", parts: [ { text: saving ? "saving" : "exporting", tone: "muted" as const, }, ], }, ] : []), ], hints, }), [ results.loading, results.loadingMore, data, saving, exporting, hints, ], ); usePaneNoticeFooter({ registrationId: "equity-screener:notices", focused, notices: [ ...(data?.warnings ?? []), ...(opened?.warnings ?? []), ...[ results.error, fields.error, mode === "saved" ? saved.error : null, actionError, ].filter((value): value is string => !!value), ], }); useShortcut( (event) => { if (!saveForm) return; const ring = [ "name", "save", ...(currentSaved ? ["copy"] : []), "cancel", ]; if (event.name === "tab") { event.preventDefault(); event.stopPropagation(); setSaveFocus((current) => ring[(ring.indexOf(current) + (event.shift ? -1 : 1) + ring.length) % ring.length]!); } else if (event.name === "escape") { event.preventDefault(); event.stopPropagation(); setSaveForm(false); } else if ( ["enter", "return"].includes(event.name ?? "") && !event.targetEditable ) { event.preventDefault(); event.stopPropagation(); if (saveFocus === "cancel") setSaveForm(false); else void save(saveFocus === "copy"); } }, { enabled: focused && saveForm, allowEditable: true, phase: "before", scope: "equity-screen-save", }, ); useShortcut((event) => { if ( !focused || event.targetEditable || event.ctrl || event.meta || editing !== null ) return; if (saveForm) { if (event.name === "escape") { event.preventDefault(); setSaveForm(false); } return; } if (event.name === "r") { event.preventDefault(); void results.load(); if (mode === "saved") void saved.reload(); return; } // Hint keys are single unmodified letters. const hint = event.shift || event.alt || event.super ? undefined : hints.find( (hint) => hint.key === event.name && !hint.disabled, ); if (hint) { event.preventDefault(); hint.onPress(); } }); const tabsInHeader = usePaneHeaderTabs({ tabs: TABS, activeValue: mode, onSelect: switchMode, focused: focused && !saveForm && editing === null, }); const bodyHeight = Math.max(5, height - 1 - (tabsInHeader ? 0 : 1)); const saveContent = !access.emailVerified ? ( ) : ( { void save(); }} focused={focused && saveFocus === "name"} onMouseDown={() => setSaveFocus("name")} width={Math.min(45, width - 4)} /> {actionError ? ( {actionError} ) : null}