import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type {
CloudRiskNotePayload,
} from "../../../api-client";
import {
EmptyState, PaneStatusBody, Prose, QueryBar, SectionHeading, Spinner, StatGrid,
Tabs,
usePaneFooter,
usePaneHeaderTabs,
type PaneFooterSegment,
type PaneHint,
type QueryBarFilter,
} from "../../../components";
import { useShortcut } from "../../../react/input";
import { useAsyncResource } from "../../../react/async-resource";
import { colors } from "../../../theme/colors";
import {
Box,
ScrollBox,
Text,
useRendererHost,
useUiCapabilities,
type ScrollBoxRenderable,
} from "../../../ui";
import { isPlainKey } from "../../../utils/keyboard";
import { usePluginPaneState } from "../../runtime";
import { useBoundTicker } from "../shared/ticker-request";
import { discardRiskData, loadRiskReport, loadRiskReports } from "./data";
export const RISK_FACTORS_PANE_ID = "risk-factors";
const MAX_PROSE_WIDTH = 100;
function noteFor(
notes: CloudRiskNotePayload[],
index: number,
): string | undefined {
return notes.find((note) => note.index === index)?.text;
}
/** A risk heading, its group, and the model's line under it. */
function RiskLine({
tag,
group,
heading,
note,
width,
}: {
tag: string;
group: string | null;
heading: string;
note?: string;
width: number;
}) {
return (
{tag}
{group ? {group} : null}
{note ? (
) : null}
);
}
export function RiskFactorsResearchTab(props: { focused: boolean; width: number; height: number }) {
return ;
}
export function RiskFactorsPane({
focused,
width,
nested = false,
}: {
focused: boolean;
width: number;
height: number;
/** Inside Ticker Research, whose own tab strip keeps h/l and the arrows. */
nested?: boolean;
}) {
const { symbol } = useBoundTicker();
const ticker = symbol ? symbol.toUpperCase() : null;
const nativePaneChrome = useUiCapabilities().nativePaneChrome === true;
const rendererHost = useRendererHost();
// The year is remembered with the pane; the ticker it was chosen on is the
// one the pane opened with, so a restored year applies to it.
const [selectedYear, setSelectedYear] = usePluginPaneState("filingYear", null);
const [selectedTicker, setSelectedTicker] = useState(ticker);
const listLoader = useCallback((force: boolean) => loadRiskReports(ticker!, { force }), [ticker]);
const list = useAsyncResource(ticker ? listLoader : null, { clearOnError: discardRiskData });
const years = useMemo(() => [...(list.data?.reports ?? [])].sort((a, b) => b.reportYear - a.reportYear), [list.data]);
// Null follows the newest discovered filing; an explicit choice stays on that year.
const year = selectedTicker === ticker && selectedYear !== null
? selectedYear : years[0]?.reportYear ?? null;
const reportLoader = useCallback((force: boolean) => loadRiskReport(ticker!, year!, { force }), [ticker, year]);
const detail = useAsyncResource(ticker && year !== null ? reportLoader : null, { clearOnError: discardRiskData });
const report = detail.data;
const listError = list.error ?? list.data?.refreshError ?? null;
const reportError = detail.error ?? report?.refreshError ?? null;
const refresh = useCallback(() => {
void list.reload();
if (year !== null) void detail.reload();
}, [list.reload, detail.reload, year]);
const scrollRef = useRef(null);
useEffect(() => {
const scrollBox = scrollRef.current;
if (scrollBox) scrollBox.scrollTop = 0;
}, [ticker, year]);
const openFiling = useCallback(() => {
if (report?.docUrl) void rendererHost.openExternal(report.docUrl);
}, [rendererHost, report]);
const nextYear = useCallback(() => {
if (years.length < 2) return;
const index = years.findIndex((entry) => entry.reportYear === year);
setSelectedTicker(ticker);
setSelectedYear(years[(index + 1) % years.length]!.reportYear);
}, [setSelectedYear, ticker, year, years]);
const scrollBy = useCallback((delta: number) => {
const scrollBox = scrollRef.current;
if (!scrollBox?.viewport) return;
const max = Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height);
scrollBox.scrollTop = Math.max(
0,
Math.min(max, scrollBox.scrollTop + delta),
);
}, []);
useShortcut(
(event) => {
if (isPlainKey(event, "r")) refresh();
else if (isPlainKey(event, "o")) {
event.preventDefault();
openFiling();
}
// One line per press; marked handled so the pane scroll keys, which
// page this report, do not scroll it again.
else if (isPlainKey(event, "j", "down")) {
event.preventDefault();
scrollBy(1);
} else if (isPlainKey(event, "k", "up")) {
event.preventDefault();
scrollBy(-1);
}
},
{ enabled: focused, scope: "risk-factors" },
);
usePaneFooter(RISK_FACTORS_PANE_ID, () => {
const info: PaneFooterSegment[] = [];
if (list.loading || detail.loading)
info.push({ id: "loading", parts: [{ text: "loading", tone: "muted" }] });
if (listError) info.push({ id: "list-error", parts: [{ text: `Report list: ${listError}`, tone: "warning" }] });
if (reportError) info.push({ id: "report-error", parts: [{ text: `${year} report: ${reportError}`, tone: "warning" }] });
if ((listError || list.data?.stale) && list.data) {
info.push({ id: "list-stale", parts: [{ text: `List cached ${new Date(list.data.fetchedAt).toISOString()}`, tone: "muted" }] });
}
if ((reportError || report?.stale) && report) {
info.push({ id: "report-stale", parts: [{ text: `Report cached ${new Date(report.fetchedAt).toISOString()}`, tone: "muted" }] });
}
const hints: PaneHint[] = report?.docUrl
? [{ id: "open", key: "o", label: "pen filing", onPress: openFiling }]
: [];
// The year strip answers h/l only where it is the pane's own strip; `y`
// steps it everywhere, including under Ticker Research's strip.
if (ticker && years.length > 1) hints.push({ id: "year", key: "y", label: "ear", onPress: nextYear });
return { info, hints };
}, [list.loading, detail.loading, listError, reportError, list.data, report, year, openFiling, ticker, years.length, nextYear]);
const bodyWidth = Math.max(12, width - 2);
const proseWidth = Math.min(bodyWidth, MAX_PROSE_WIDTH);
const yearTabs = useMemo(() => years.map((entry) => ({
label: `${entry.reportYear} 10-K`,
value: String(entry.reportYear),
})), [years]);
const selectYear = (value: string) => { setSelectedTicker(ticker); setSelectedYear(Number(value)); };
const tabsInHeader = usePaneHeaderTabs(years.length > 1 ? {
tabs: yearTabs,
activeValue: year === null ? "" : String(year),
onSelect: selectYear,
focused,
} : null);
// Nested in Ticker Research the years stay in the body: the terminal keeps
// its tab row, the desktop picks the year from the query bar.
const yearStrip = years.length > 1 && !tabsInHeader;
const yearFilters: QueryBarFilter[] = yearStrip && nativePaneChrome ? [{
id: "year",
label: "10-K",
inline: years.length <= 4,
value: year === null ? "" : String(year),
options: years.map((entry) => ({ label: String(entry.reportYear), value: String(entry.reportYear) })),
onChange: selectYear,
}] : [];
if (!ticker) return ;
if (!list.data && list.loading) return ;
if (!list.data && listError && year === null) return ;
if (year === null) return ;
const diff = report?.diff ?? null;
// A choice of years names the year in the tabs or the filter; a single
// report says which one it is here.
const meta = report
? [
years.length > 1 ? null : `${report.reportYear} 10-K`,
`Filed ${report.filedAt?.slice(0, 10) || "unavailable"}`,
`updated ${report.updatedAt?.slice(0, 10) || "unavailable"}`,
diff ? null : "no prior year on file",
].filter(Boolean).join(" · ")
: "";
return (
{yearStrip && !nativePaneChrome && (
)}
{(yearFilters.length > 0 || meta) && }
{report && diff ? (
) : null}
{report ? (
{report.overview ? (
{report.overview.split("\n").map((point) => (
))}
) : null}
{diff ? (
{diff.added.length === 0 &&
diff.removed.length === 0 &&
diff.reworded.length === 0 ? (
) : null}
{diff.added.map((index) => (
))}
{diff.removed.map((risk, index) => (
))}
{diff.reworded.map((item) => (
))}
) : report.notes.top.length > 0 ? (
{report.notes.top.map((note) => (
))}
) : null}
{report.risks.map((risk, index) => (
))}
) : reportError ? (
) : (
)}
);
}