import { useCallback, useMemo, type ReactNode } from "react";
import { listingIdentity } from "../shared/ticker-request";
import { Box, ScrollBox, Text, useUiCapabilities } from "../../../ui";
import {
useAsyncResource,
useAutoRefresh,
usePaneSettingValue,
usePluginPaneState,
useShortcut,
useUpdatedAgo,
} from "../../../public/react";
import {
CompositeChart,
DataTableView,
EmptyState,
KeyValueRow,
PageStackView,
PaneStatusBody,
StatGrid,
statGridRows,
Tabs,
usePaneHeaderTabs,
usePaneNoticeFooter,
usePaneStatusLinkFooter,
type DataTableCell,
type StatItem,
} from "../../../components";
import { colors } from "../../../theme/colors";
import type {
DebtFact,
DebtHistoryPoint,
DebtMetric,
} from "../../../api-client/debt-maturities";
import { ApiRequestError } from "../../../api-client/errors";
import { staticSeries } from "../../../components/chart/static/series";
import type { PaneProps } from "../../../types/plugin";
import { isPlainKey } from "../../../utils/keyboard";
import { SignInWall } from "../cloud/auth-actions";
import {
isCloudSessionRequired,
useResearchCloudSession,
} from "../shared/research-cloud-session";
import { cachedDebtMaturities, loadDebtMaturities } from "./client";
import {
BUCKET_AXIS_TICKS,
BUCKET_COLUMNS,
HISTORY_COLUMNS,
bucketCursor,
bucketPoints,
bucketShare,
debtAmount,
debtFilingUrl,
debtMetricCaption,
debtMetricValue,
debtNotices,
debtPercent,
recentDebtHistory,
sortedBuckets,
sortedDebtHistory,
type BucketColumn,
type BucketColumnId,
type DebtBucket,
type DebtLatest,
type DebtSort,
type HistoryColumn,
type HistoryColumnId,
} from "./model";
import { usePaneTickerIdentity } from "../../../state/hooks/pane-ticker";
const PANELS = [{ id: "main" }];
const TABS = [
{ value: "maturities", label: "Maturities" },
{ value: "history", label: "History" },
{ value: "filing", label: "Filing" },
];
const BUCKET_AXIS = { ticks: BUCKET_AXIS_TICKS, formatCursor: bucketCursor };
const clearDenied = (error: unknown) =>
error instanceof ApiRequestError && [401, 403].includes(error.status ?? 0);
const amountAxis = (value: number) => debtAmount(value);
function MetricRow({ label, metric }: { label: string; metric: DebtMetric }) {
return (
);
}
/** A detail sheet: the terminal sizes it in rows, the desktop fills to the footer. */
function DetailScroll({ width, height, children }: { width: number; height: number; children: ReactNode }) {
const { nativePaneChrome } = useUiCapabilities();
return (
{children}
);
}
function FactDetail({
fact,
width,
height,
}: {
fact: DebtFact | null;
width: number;
height: number;
}) {
if (!fact)
return (
);
return (
);
}
function FilingDetail({
latest,
width,
height,
}: {
latest: DebtLatest;
width: number;
height: number;
}) {
const expense = latest.interestExpenseFact,
evidence = latest.borrowingCostEvidence;
return (
{expense?.start ? (
) : null}
fact.tag).join(" + ") ??
"Not established"
}
/>
{evidence ? (
) : null}
);
}
function HistoryDetail({
point,
width,
height,
}: {
point: DebtHistoryPoint;
width: number;
height: number;
}) {
return (
);
}
export function DebtMaturitiesPane({ width, height, focused }: PaneProps) {
const { nativePaneChrome } = useUiCapabilities();
const { ticker } = usePaneTickerIdentity();
const symbol = listingIdentity(ticker?.metadata.ticker)?.symbol ?? null;
const session = useResearchCloudSession();
const loader = useCallback(
(force: boolean) => loadDebtMaturities(symbol!, force),
[symbol, session.requestKey],
);
const resource = useAsyncResource(symbol ? loader : null, {
initialData: () => (symbol ? cachedDebtMaturities(symbol) : null),
clearOnError: clearDenied,
});
const [initialTab] = usePaneSettingValue("tab", "maturities");
const [savedTab, setTab] = usePluginPaneState("debt:tab", initialTab);
const tab = TABS.some((entry) => entry.value === savedTab)
? savedTab
: "maturities";
const [bucketSort, setBucketSort] = usePluginPaneState<
DebtSort
>("debt:bucketSort", { column: "label", direction: "asc" });
const [historySort, setHistorySort] = usePluginPaneState<
DebtSort
>("debt:historySort", { column: "asOf", direction: "desc" });
const [selectedBucket, setSelectedBucket] = usePluginPaneState(
"debt:selectedBucket",
null,
);
const [openBucket, setOpenBucket] = usePluginPaneState(
"debt:openBucket",
null,
);
const [selectedHistory, setSelectedHistory] = usePluginPaneState<
string | null
>("debt:selectedHistory", null);
const [openHistory, setOpenHistory] = usePluginPaneState(
"debt:openHistory",
null,
);
const data = resource.data?.payload,
latest = data?.latest;
const bucketKey = (bucket: DebtBucket) =>
`${symbol}:${latest?.accession}:${latest?.asOf}:${bucket.id}`;
const historyKey = (point: DebtHistoryPoint) =>
`${symbol}:${point.accession}:${point.asOf}`;
const buckets = useMemo(
() => (latest ? sortedBuckets(latest, bucketSort) : []),
[latest, bucketSort],
);
const history = useMemo(
() => (data ? sortedDebtHistory(data, historySort) : []),
[data, historySort],
);
const openBucketRow = buckets.find((row) => bucketKey(row) === openBucket);
const openHistoryRow = history.find((row) => historyKey(row) === openHistory);
const selectedHistoryRow =
openHistoryRow ??
history.find((row) => historyKey(row) === selectedHistory) ??
history[0];
const series = useMemo(
() => [
staticSeries(latest ? bucketPoints(latest) : [], {
id: "debt-principal",
label: `Principal (${latest?.currency ?? ""})`,
color: colors.warning,
style: "columns",
}),
],
[latest],
);
const historySeries = useMemo(
() => [
staticSeries(
data
? recentDebtHistory(data).map((row) => ({
date: new Date(row.asOf),
observedAt: new Date(row.asOf),
value: row.totalPrincipal,
}))
: [],
{
id: "debt-history",
label: `Principal (${latest?.currency ?? ""})`,
color: colors.warning,
style: "columns",
calendarSpaced: true,
},
),
],
[data, latest],
);
const updatedAgo = useUpdatedAgo(resource.updatedAt);
// An open bucket or filing covers its tab; the tab keys wait until it closes.
const detailOpen =
tab === "maturities" ? !!openBucketRow : tab === "history" && !!openHistoryRow;
const tabsFocused = focused && !detailOpen;
const tabsInHeader = usePaneHeaderTabs(
latest
? { tabs: TABS, activeValue: tab, onSelect: setTab, focused: tabsFocused }
: null,
);
const tabRows = tabsInHeader ? 0 : 1;
// The desktop footer is chrome outside the body; the terminal gives it a row.
const bodyHeight = Math.max(3, height - tabRows - (nativePaneChrome ? 0 : 1));
// The as-of date is said once; a figure from another date carries its own.
const statItems = useMemo(() => {
if (!latest) return [];
const metric = (id: string, label: string, value: DebtMetric): StatItem => ({
id, label, value: debtMetricValue(value),
detail: `${value.percentile.value === null ? "--" : value.percentile.value.toFixed(0)} pctl 10Y${value.asOf === latest.asOf ? "" : ` ยท ${value.asOf}`}`,
});
return [
metric("principal", "Principal total", latest.totalPrincipal),
metric("next12", "Due next 12 months", latest.next12MonthsShare),
metric("next3", "Due next 3 years", latest.next3YearsShare),
{ id: "as-of", label: "As of", value: latest.asOf },
];
}, [latest]);
const statRows = statGridRows(statItems, width);
// The desktop fills to the footer with flex; the terminal keeps fixed rows.
const fill = (rows: number) => nativePaneChrome
? { flexGrow: 1, flexShrink: 1, flexBasis: 0, minHeight: 0 }
: { height: rows, flexShrink: 0 };
// The terminal sizes the table to its rows and gives the chart the rest; the
// desktop gives the chart a share and lets the table fill to the footer.
const tableHeight = Math.min(9, Math.max(4, bodyHeight - 3));
const chartHeight = nativePaneChrome
? Math.max(0, Math.floor((bodyHeight - statRows) * 0.55))
: Math.max(0, bodyHeight - tableHeight - statRows);
const historyTableHeight = Math.min(
history.length + 3,
Math.max(4, Math.floor(bodyHeight * 0.6)),
);
const historyChartHeight = nativePaneChrome
? Math.max(0, Math.floor((bodyHeight - 1) * 0.5))
: Math.max(0, bodyHeight - historyTableHeight - 1);
useAutoRefresh(resource.updatedAt, resource.load);
useShortcut((event) => {
if (focused && isPlainKey(event, "r")) {
event.preventDefault();
void resource.reload();
}
});
usePaneNoticeFooter({
registrationId: "debt-maturities:notices",
focused,
notices: [
...(data ? debtNotices(data) : []),
...(resource.data?.refreshError ? [resource.data.refreshError] : []),
],
});
usePaneStatusLinkFooter({
registrationId: "debt-maturities",
focused,
loading: resource.loading,
error: resource.error,
url:
tab === "history" && selectedHistoryRow && data?.cik
? debtFilingUrl(data.cik, selectedHistoryRow.accession)
: (latest?.filingUrl ?? data?.source.url ?? null),
showOpenHint: true,
info: data
? [
...(updatedAgo
? [
{
id: "updated",
parts: [{ text: updatedAgo, tone: "muted" as const }],
},
]
: []),
...(resource.data?.stale
? [
{
id: "stale",
parts: [{ text: "stale", tone: "warning" as const }],
},
]
: []),
]
: [],
});
const renderBucket = (
row: DebtBucket,
column: BucketColumn,
_index: number,
state: { selected: boolean },
): DataTableCell => ({
text:
column.id === "label"
? row.label
: column.id === "value"
? debtAmount(row.value)
: debtPercent(bucketShare(row, latest!)),
color: state.selected
? colors.selectedText
: row.value === null
? colors.textMuted
: column.id === "value"
? colors.warning
: colors.text,
});
const renderHistory = (
row: DebtHistoryPoint,
column: HistoryColumn,
_index: number,
state: { selected: boolean },
): DataTableCell => ({
text:
column.id === "asOf" || column.id === "filed"
? row[column.id]
: column.id === "next12MonthsShare" || column.id === "next3YearsShare"
? debtPercent(row[column.id])
: debtAmount(row[column.id]),
color: state.selected
? colors.selectedText
: row[column.id] === null
? colors.textMuted
: colors.text,
});
if (!symbol)
return (
);
if (!data && isCloudSessionRequired(resource.error))
return (
);
return (
{latest ? (
<>
{!tabsInHeader && (
)}
{tab === "filing" ? (
) : tab === "maturities" ? (
setOpenBucket(null)}
detailTitle={openBucketRow?.label}
detailContent={
openBucketRow ? (
) : null
}
rootContent={
{chartHeight >= 4 ? (
) : null}
emptyStateTitle="No maturity buckets available."
focused={focused && !openBucketRow}
rootWidth={width}
rootHeight={tableHeight}
columns={BUCKET_COLUMNS}
items={buckets}
getItemKey={bucketKey}
renderCell={renderBucket}
freezeFirstColumn
resetScrollKey={symbol}
selection={{
kind: "id",
selectedId:
selectedBucket ??
(buckets[0] ? bucketKey(buckets[0]) : null),
getId: bucketKey,
onChange: setSelectedBucket,
}}
onActivate={(row) => setOpenBucket(bucketKey(row))}
sortColumnId={bucketSort.column}
sortDirection={bucketSort.direction}
onHeaderClick={(column) =>
setBucketSort((current) => ({
column: column as BucketColumnId,
direction:
current.column === column &&
current.direction === "desc"
? "asc"
: "desc",
}))
}
/>
}
/>
) : (
setOpenHistory(null)}
detailTitle={openHistoryRow?.asOf}
detailContent={
openHistoryRow ? (
) : null
}
rootContent={
{/* What the bars are and the window they span, as the chart's unit line. */}
{`Principal total (${latest.currency})`}
{`${latest.totalPrincipal.percentile.windowStart} to ${latest.asOf}`}
{historyChartHeight >= 4 ? (
) : null}
emptyStateTitle="No comparable filing history."
focused={focused && !openHistoryRow}
rootWidth={width}
rootHeight={historyTableHeight}
columns={HISTORY_COLUMNS}
items={history}
getItemKey={historyKey}
renderCell={renderHistory}
freezeFirstColumn
resetScrollKey={symbol}
selection={{
kind: "id",
selectedId:
selectedHistory ??
(history[0] ? historyKey(history[0]) : null),
getId: historyKey,
onChange: setSelectedHistory,
}}
onActivate={(row) => setOpenHistory(historyKey(row))}
sortColumnId={historySort.column}
sortDirection={historySort.direction}
onHeaderClick={(column) =>
setHistorySort((current) => ({
column: column as HistoryColumnId,
direction:
current.column === column &&
current.direction === "desc"
? "asc"
: "desc",
}))
}
/>
}
/>
)}
>
) : null}
);
}