import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type {
CloudExecutiveRowPayload,
CloudProxyStatementPayload,
} from "../../../api-client";
import {
DataTableView,
EmptyState, PaneStatusBody, Prose, QueryBar, SectionHeading,
Tabs,
usePaneFooter,
usePaneHeaderTabs,
usePaneNoticeFooter,
type DataTableCell,
type DataTableColumn,
type PaneFooterSegment,
type PaneHint,
type QueryBarFilter,
} from "../../../components";
import { useShortcut } from "../../../react/input";
import { useAsyncResource } from "../../../react/async-resource";
import { usePaneStateValue } from "../../../state/app/context";
import { colors, getChartIndicatorColor } from "../../../theme/colors";
import {
Box,
ScrollBox,
Text,
TextAttributes,
useRendererHost,
useUiCapabilities,
useUiHost,
type ScrollBoxRenderable,
} from "../../../ui";
import { isPlainKey } from "../../../utils/keyboard";
import { useBoundTicker } from "../shared/ticker-request";
import { discardProxyData, loadProxyStatement, loadProxyStatements } from "./data";
import {
equityShare,
formatChange,
formatFiled,
formatPay,
formatRatio,
} from "./format";
export const EXECUTIVES_PANE_ID = "executives";
const MAX_PROSE_WIDTH = 100;
function retainedDataNotice(subject: string, error: string, fetchedAt: number | null): string {
const retrieved = new Date(fetchedAt ?? Number.NaN);
const age = Number.isFinite(retrieved.getTime())
? `retrieved ${retrieved.toISOString()}` : "with unavailable retrieval time";
return `${subject} refresh failed: ${error}. Retained data ${age}.`;
}
/** A figure and what it is, value first so the column of numbers is what the eye reads. */
function FigureLine({
value,
label,
note,
width,
valueWidth,
}: {
value: string;
label: string;
note?: string;
width: number;
valueWidth: number;
}) {
const text = [label, note].filter(Boolean).join(", ");
return (
);
}
const PAY_PARTS: Array<{ key: keyof CloudExecutiveRowPayload; label: string }> =
[
{ key: "salary", label: "Salary" },
{ key: "bonus", label: "Bonus" },
{ key: "stockAwards", label: "Stock" },
{ key: "optionAwards", label: "Options" },
{ key: "nonEquityIncentive", label: "Incentive" },
{ key: "pensionAndDeferred", label: "Pension" },
{ key: "allOther", label: "Other" },
];
/**
* How the chief executive's pay was made up, as one bar. Salary is usually
* a sliver and equity most of it; seeing that beats the table. The terminal
* draws block runs with at least one cell per piece so a small one still
* shows; the desktop draws real elements so a piece can be thinner than a cell.
*/
function PayMixBar({
row,
width,
}: {
row: CloudExecutiveRowPayload;
width: number;
}) {
const isDesktopWeb = useUiHost().kind === "desktop-web";
// Colours follow the pieces that are drawn, so the common salary and stock
// pair never lands on two neighbouring shades of the palette.
const parts = PAY_PARTS.map((part) => ({
...part,
value: (row[part.key] as number | null) ?? 0,
})).filter((part) => part.value > 0).map((part, index) => ({ ...part, color: getChartIndicatorColor(index) }));
const sum = parts.reduce((total, part) => total + part.value, 0);
if (sum <= 0) return null;
const barWidth = Math.max(10, Math.min(width, 60));
let cells = parts.map((part) =>
Math.max(1, Math.round((part.value / sum) * barWidth)),
);
// Rounding and the one-cell floor can overshoot; trim the largest piece.
while (cells.reduce((a, b) => a + b, 0) > barWidth) {
const largest = cells.indexOf(Math.max(...cells));
cells[largest] = cells[largest]! - 1;
}
cells = cells.map((count) => Math.max(1, count));
return (
{isDesktopWeb ? (
{parts.map((part) => (
))}
) : (
{parts.map((part, index) => (
{"█".repeat(cells[index]!)}
))}
)}
{parts.map((part) => (
{isDesktopWeb ? (
) : ■ }
{part.label}
{Math.round((part.value / sum) * 100)}%
))}
);
}
type OfficerColumn = DataTableColumn & { id: "name" | "title" | "equity" | "total" };
const OFFICER_COLUMNS: OfficerColumn[] = [
{ id: "name", label: "NAME", width: 18, align: "left" },
{ id: "title", label: "TITLE", width: 16, align: "left", flexGrow: 1 },
{ id: "equity", label: "EQ%", width: 4, align: "right" },
{ id: "total", label: "TOTAL", width: 8, align: "right" },
];
function renderOfficerCell(row: CloudExecutiveRowPayload, column: OfficerColumn): DataTableCell {
switch (column.id) {
case "name": return { text: row.name, color: colors.textBright };
case "title": return { text: row.title.replace(/\s+/g, " ").trim(), color: colors.textDim };
case "equity": return { text: equityShare(row), color: colors.textDim };
case "total": return { text: formatPay(row.total), color: colors.text, attributes: TextAttributes.BOLD };
}
}
function ExecutiveRows({
rows,
width,
}: {
rows: CloudExecutiveRowPayload[];
width: number;
}) {
if (width < 52) {
return
{rows.map((row) => )}
;
}
// The name column fits the longest name so the title starts after a kit gutter.
const nameWidth = Math.min(24, Math.max(10, ...rows.map((row) => row.name.length)));
const columns = OFFICER_COLUMNS.map((column) => column.id === "name" ? { ...column, width: nameWidth } : column);
const height = rows.length + 1;
return (
columns={columns}
items={rows}
selection={{ kind: "none" }}
rootWidth={width}
rootHeight={height}
// The scroll body owns the gutter, so the table does not add its own.
horizontalPadding={0}
virtualize={false}
sortColumnId={null}
sortDirection="asc"
getItemKey={(row, index) => `${row.name}-${row.total}-${index}`}
renderCell={renderOfficerCell}
emptyStateTitle="No named executive officers."
/>
);
}
/**
* What a key figure is about, so a filer's "Say on Pay Approval, 2025 Annual
* Meeting" is recognised as the "prior say-on-pay support" computed here.
*/
function figureTopic(label: string): string {
const letters = label.toLowerCase().replace(/[^a-z]/g, "");
if (letters.includes("sayonpay")) return "sayonpay";
if (letters.includes("payratio") || letters.includes("ceotomedian")
|| (letters.includes("median") && letters.includes("ratio"))) return "payratio";
// The median's pay is its own figure; the pay ratio line carries it only as a note.
if (letters.includes("medianemployee")) return "medianpay";
return letters;
}
function figuresOf(statement: CloudProxyStatementPayload) {
const figures: Array<{ value: string; label: string; note?: string }> = [];
const ceo = statement.ceo;
if (ceo?.total != null && Number.isFinite(ceo.total)) {
const change = formatChange(ceo.total, ceo.priorYearTotal);
figures.push({
value: formatPay(ceo.total),
label: `${ceo.name} total pay`,
note: change
? `${change} vs prior year`
: (statement.fiscalYearLabel ?? undefined),
});
}
if (statement.payRatio !== null) {
figures.push({
value: formatRatio(statement.payRatio),
label: "CEO to median employee",
note: statement.medianEmployeePay
? `median ${formatPay(statement.medianEmployeePay)}`
: undefined,
});
}
if (ceo) {
const share = equityShare(ceo);
if (share) figures.push({ value: share, label: "CEO pay in equity" });
}
if (statement.sayOnPayPriorSupport !== null) {
figures.push({
value: `${Math.round(statement.sayOnPayPriorSupport)}%`,
label: "prior say-on-pay support",
});
}
const seen = new Set(figures.map((figure) => figureTopic(figure.label)));
if (statement.payRatio !== null && statement.medianEmployeePay) seen.add("medianpay");
for (const figure of statement.keyFigures ?? []) {
if (figures.length >= 8) break;
const topic = figureTopic(figure.label);
if (seen.has(topic)) continue;
seen.add(topic);
figures.push({
value: figure.value,
label: figure.label,
note: figure.note || undefined,
});
}
return figures;
}
export function ExecutivesPane({
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;
if (!ticker) return ;
return ;
}
export function ExecutivesResearchTab(props: { focused: boolean; width: number; height: number }) {
return ;
}
function ExecutiveResearch({ ticker, focused, width, nested }: { ticker: string; focused: boolean; width: number; nested: boolean }) {
const nativePaneChrome = useUiCapabilities().nativePaneChrome === true;
const rendererHost = useRendererHost();
const scrollRef = useRef(null);
const [selectedYear, setYear] = usePaneStateValue("proxyYear", null);
const loadYears = useCallback((force: boolean) => loadProxyStatements(ticker, { force }), [ticker]);
const list = useAsyncResource(loadYears, { clearOnError: discardProxyData });
const years = useMemo(() => list.data?.data?.proxies ?? [], [list.data]);
const year = years.some(entry => entry.proxyYear === selectedYear)
? selectedYear : years[0]?.proxyYear ?? null;
const loadStatement = useCallback((force: boolean) => loadProxyStatement(ticker, year!, { force }), [ticker, year]);
const detail = useAsyncResource(year === null ? null : loadStatement, { clearOnError: discardProxyData });
const statement = detail.data?.data ?? null;
const loading = list.loading || detail.loading;
const listError = list.error ?? list.data?.refreshError;
const statementError = detail.error ?? detail.data?.refreshError;
usePaneNoticeFooter({
registrationId: `${EXECUTIVES_PANE_ID}:data-notices`,
notices: [
list.data?.data && listError
? retainedDataNotice("Proxy list", listError, list.data.fetchedAt) : null,
detail.data?.data && statementError
? retainedDataNotice(`${year} proxy`, statementError, detail.data.fetchedAt) : null,
].filter((notice): notice is string => notice !== null),
focused,
});
const refresh = useCallback(() => {
void list.reload();
if (year !== null) void detail.reload();
}, [list.reload, detail.reload, year]);
useEffect(() => {
const scrollBox = scrollRef.current;
if (scrollBox) scrollBox.scrollTop = 0;
}, [year]);
const openFiling = useCallback(() => {
if (statement?.docUrl) void rendererHost.openExternal(statement.docUrl);
}, [rendererHost, statement]);
const nextYear = useCallback(() => {
if (years.length < 2) return;
const index = years.findIndex((entry) => entry.proxyYear === year);
setYear(years[(index + 1) % years.length]!.proxyYear);
}, [setYear, 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, "o")) {
event.preventDefault();
openFiling();
} else if (isPlainKey(event, "r")) {
refresh();
} else if (isPlainKey(event, "j", "down")) {
// One line per press; marked handled so the pane scroll keys, which
// page this statement, do not scroll it again.
event.preventDefault();
scrollBy(1);
} else if (isPlainKey(event, "k", "up")) {
event.preventDefault();
scrollBy(-1);
}
},
{ enabled: focused, scope: "executives" },
);
usePaneFooter(EXECUTIVES_PANE_ID, () => {
const info: PaneFooterSegment[] = [];
if (loading)
info.push({ id: "loading", parts: [{ text: "loading", tone: "muted" }] });
if (statement) {
info.push({
id: "filed",
parts: [
{ text: `filed ${formatFiled(statement.filedAt)}`, tone: "muted" },
],
});
}
// `r` refreshes every pane, so it gets no hint here.
const hints: PaneHint[] = statement ? [{ 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 (years.length > 1) hints.push({ id: "year", key: "y", label: "ear", onPress: nextYear });
return { info, hints };
}, [loading, statement, openFiling, years.length, nextYear]);
const figures = useMemo(
() => (statement ? figuresOf(statement) : []),
[statement],
);
const yearTabs = useMemo(() => years.map((entry) => ({
label: `${entry.proxyYear} proxy`,
value: String(entry.proxyYear),
})), [years]);
const selectYear = useCallback((value: string) => setYear(Number(value)), [setYear]);
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 = useMemo(() => yearStrip && nativePaneChrome ? [{
id: "year",
label: "Proxy",
inline: years.length <= 4,
value: year === null ? "" : String(year),
options: years.map((entry) => ({ label: String(entry.proxyYear), value: String(entry.proxyYear) })),
onChange: selectYear,
}] : [], [nativePaneChrome, selectYear, year, yearStrip, years]);
// The year is named by the tabs or the filter when there is a choice; a
// single proxy says which one it is here.
const meta = statement ? [
years.length > 1 ? null : `${statement.proxyYear} proxy`,
statement.fiscalYearLabel ? `pay for ${statement.fiscalYearLabel.replace(/^Fiscal/, "fiscal")}` : null,
statement.meetingDate ? `meeting ${formatFiled(statement.meetingDate)}` : null,
].filter(Boolean).join(" · ") : "";
const bodyWidth = Math.max(12, width - 2);
const proseWidth = Math.min(bodyWidth, MAX_PROSE_WIDTH);
const valueWidth = Math.min(
14,
Math.max(4, ...figures.map((figure) => figure.value.length)),
);
if (list.loading && !list.data?.data) {
return ;
}
if (!list.error && years.length === 0) {
return ;
}
if (list.error && !list.data?.data) {
return ;
}
return (
{yearStrip && !nativePaneChrome && (
)}
{(yearFilters.length > 0 || meta) && }
{statement ? (
{figures.length > 0 && (
{figures.map((figure) => (
))}
)}
{statement.ceo && (
0 ? 1 : 0}
title={`HOW ${statement.ceo.name.split(" ").pop()?.toUpperCase() ?? "THE CEO"} WAS PAID`}
/>
)}
{statement.namedExecutives.length > 0 && (
)}
{statement.highlights && (
{statement.highlights.split("\n").map((point) => (
))}
)}
) : (
)}
);
}