feat(financials): add compact surface UI and graphing states

This commit is contained in:
2026-03-12 15:25:21 -04:00
parent c274f4d55b
commit 33ce48f53c
13 changed files with 1941 additions and 197 deletions

View File

@@ -22,6 +22,9 @@ import {
RefreshCcw,
Search
} from 'lucide-react';
import { NormalizationSummary } from '@/components/financials/normalization-summary';
import { StatementMatrix } from '@/components/financials/statement-matrix';
import { StatementRowInspector } from '@/components/financials/statement-row-inspector';
import { AppShell } from '@/components/shell/app-shell';
import {
FinancialControlBar,
@@ -42,10 +45,16 @@ import {
type NumberScaleUnit
} from '@/lib/format';
import { buildGraphingHref } from '@/lib/graphing/catalog';
import {
buildStatementTree,
resolveStatementSelection,
type StatementInspectorSelection
} from '@/lib/financials/statement-view-model';
import { queryKeys } from '@/lib/query/keys';
import { companyFinancialStatementsQueryOptions } from '@/lib/query/options';
import type {
CompanyFinancialStatementsResponse,
DetailFinancialRow,
DerivedFinancialRow,
FinancialCadence,
FinancialDisplayMode,
@@ -54,6 +63,8 @@ import type {
RatioRow,
StandardizedFinancialRow,
StructuredKpiRow,
SurfaceDetailMap,
SurfaceFinancialRow,
TaxonomyStatementRow,
TrendSeries
} from '@/lib/types';
@@ -63,7 +74,8 @@ type LoadOptions = {
append?: boolean;
};
type DisplayRow = TaxonomyStatementRow | StandardizedFinancialRow | RatioRow | StructuredKpiRow;
type FlatDisplayRow = TaxonomyStatementRow | StandardizedFinancialRow | RatioRow | StructuredKpiRow;
type StatementMatrixRow = SurfaceFinancialRow | DetailFinancialRow;
const FINANCIAL_VALUE_SCALE_OPTIONS: Array<{ value: NumberScaleUnit; label: string }> = [
{ value: 'thousands', label: 'Thousands (K)' },
@@ -106,19 +118,23 @@ function formatLongDate(value: string) {
return format(parsed, 'MMM dd, yyyy');
}
function isTaxonomyRow(row: DisplayRow): row is TaxonomyStatementRow {
function isTaxonomyRow(row: FlatDisplayRow): row is TaxonomyStatementRow {
return 'localName' in row;
}
function isDerivedRow(row: DisplayRow): row is DerivedFinancialRow {
function isDerivedRow(row: FlatDisplayRow): row is DerivedFinancialRow {
return 'formulaKey' in row;
}
function isKpiRow(row: DisplayRow): row is StructuredKpiRow {
function isKpiRow(row: FlatDisplayRow): row is StructuredKpiRow {
return 'provenanceType' in row;
}
function rowValue(row: DisplayRow, periodId: string) {
function rowValue(row: FlatDisplayRow, periodId: string) {
return row.values[periodId] ?? null;
}
function statementRowValue(row: StatementMatrixRow, periodId: string) {
return row.values[periodId] ?? null;
}
@@ -181,10 +197,10 @@ function chartTickFormatter(
}
function buildDisplayValue(input: {
row: DisplayRow;
row: FlatDisplayRow;
periodId: string;
previousPeriodId: string | null;
commonSizeRow: DisplayRow | null;
commonSizeRow: FlatDisplayRow | null;
displayMode: FinancialDisplayMode;
showPercentChange: boolean;
showCommonSize: boolean;
@@ -245,7 +261,78 @@ function buildDisplayValue(input: {
});
}
function groupRows(rows: DisplayRow[], categories: CompanyFinancialStatementsResponse['categories']) {
function unitFromDetailRow(row: DetailFinancialRow): FinancialUnit {
const normalizedUnit = row.unit?.toLowerCase() ?? '';
if (normalizedUnit.includes('shares')) {
return 'shares';
}
if (normalizedUnit === 'pure' || normalizedUnit.includes('ratio')) {
return 'ratio';
}
if (normalizedUnit.includes('percent')) {
return 'percent';
}
return 'currency';
}
function buildStatementTreeDisplayValue(input: {
row: StatementMatrixRow;
periodId: string;
previousPeriodId: string | null;
commonSizeRow: SurfaceFinancialRow | null;
showPercentChange: boolean;
showCommonSize: boolean;
scale: NumberScaleUnit;
surfaceKind: Extract<FinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>;
}) {
const current = statementRowValue(input.row, input.periodId);
if (input.showPercentChange && input.previousPeriodId) {
const previous = statementRowValue(input.row, input.previousPeriodId);
if (current === null || previous === null || previous === 0) {
return '—';
}
return formatMetricValue({
value: (current - previous) / previous,
unit: 'percent',
scale: input.scale,
rowKey: input.row.key,
surfaceKind: input.surfaceKind,
isPercentChange: true
});
}
if (input.showCommonSize) {
const denominator = input.commonSizeRow ? statementRowValue(input.commonSizeRow, input.periodId) : null;
if (current === null || denominator === null || denominator === 0) {
return '—';
}
return formatMetricValue({
value: current / denominator,
unit: 'percent',
scale: input.scale,
rowKey: input.row.key,
surfaceKind: input.surfaceKind,
isCommonSize: true
});
}
return formatMetricValue({
value: current,
unit: 'conceptKey' in input.row ? unitFromDetailRow(input.row) : input.row.unit,
scale: input.scale,
rowKey: input.row.key,
surfaceKind: input.surfaceKind
});
}
function groupRows(rows: FlatDisplayRow[], categories: CompanyFinancialStatementsResponse['categories']) {
if (categories.length === 0) {
return [{ label: null, rows }];
}
@@ -258,6 +345,42 @@ function groupRows(rows: DisplayRow[], categories: CompanyFinancialStatementsRes
.filter((group) => group.rows.length > 0);
}
function mergeDetailMaps(base: SurfaceDetailMap | null, next: SurfaceDetailMap | null) {
if (!base) {
return next;
}
if (!next) {
return base;
}
const merged: SurfaceDetailMap = structuredClone(base);
for (const [surfaceKey, detailRows] of Object.entries(next)) {
const existingRows = merged[surfaceKey] ?? [];
const rowMap = new Map(existingRows.map((row) => [row.key, row]));
for (const detailRow of detailRows) {
const existing = rowMap.get(detailRow.key);
if (!existing) {
rowMap.set(detailRow.key, structuredClone(detailRow));
continue;
}
existing.values = {
...existing.values,
...detailRow.values
};
existing.sourceFactIds = [...new Set([...existing.sourceFactIds, ...detailRow.sourceFactIds])];
existing.dimensionsSummary = [...new Set([...existing.dimensionsSummary, ...detailRow.dimensionsSummary])];
}
merged[surfaceKey] = [...rowMap.values()];
}
return merged;
}
function mergeFinancialPages(
base: CompanyFinancialStatementsResponse | null,
next: CompanyFinancialStatementsResponse
@@ -297,6 +420,7 @@ function mergeFinancialPages(
standardized: mergeRows([...base.statementRows.standardized, ...next.statementRows.standardized])
}
: next.statementRows,
statementDetails: mergeDetailMaps(base.statementDetails, next.statementDetails),
ratioRows: next.ratioRows && base.ratioRows ? mergeRows([...base.ratioRows, ...next.ratioRows]) : next.ratioRows,
kpiRows: next.kpiRows && base.kpiRows ? mergeRows([...base.kpiRows, ...next.kpiRows]) : next.kpiRows,
trendSeries: next.trendSeries,
@@ -348,7 +472,9 @@ function FinancialsPageContent() {
const [showPercentChange, setShowPercentChange] = useState(false);
const [showCommonSize, setShowCommonSize] = useState(false);
const [financials, setFinancials] = useState<CompanyFinancialStatementsResponse | null>(null);
const [selectedRowKey, setSelectedRowKey] = useState<string | null>(null);
const [selectedFlatRowKey, setSelectedFlatRowKey] = useState<string | null>(null);
const [selectedRowRef, setSelectedRowRef] = useState<StatementInspectorSelection | null>(null);
const [expandedRowKeys, setExpandedRowKeys] = useState<Set<string>>(() => new Set());
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [syncingFinancials, setSyncingFinancials] = useState(false);
@@ -367,6 +493,9 @@ function FinancialsPageContent() {
setTickerInput(normalized);
setTicker(normalized);
setSelectedFlatRowKey(null);
setSelectedRowRef(null);
setExpandedRowKeys(new Set());
}, [searchParams]);
useEffect(() => {
@@ -389,7 +518,8 @@ function FinancialsPageContent() {
const loadFinancials = useCallback(async (symbol: string, options?: LoadOptions) => {
const normalizedTicker = symbol.trim().toUpperCase();
const nextCursor = options?.cursor ?? null;
const includeDimensions = selectedRowKey !== null && (surfaceKind === 'segments_kpis' || surfaceKind === 'income_statement' || surfaceKind === 'balance_sheet' || surfaceKind === 'cash_flow_statement');
const includeDimensions = (selectedFlatRowKey !== null || selectedRowRef !== null)
&& (surfaceKind === 'segments_kpis' || surfaceKind === 'income_statement' || surfaceKind === 'balance_sheet' || surfaceKind === 'cash_flow_statement');
if (!options?.append) {
setLoading(true);
@@ -420,7 +550,7 @@ function FinancialsPageContent() {
setLoading(false);
setLoadingMore(false);
}
}, [cadence, queryClient, selectedRowKey, surfaceKind]);
}, [cadence, queryClient, selectedFlatRowKey, selectedRowRef, surfaceKind]);
const syncFinancials = useCallback(async () => {
const targetTicker = (financials?.company.ticker ?? ticker).trim().toUpperCase();
@@ -455,7 +585,9 @@ function FinancialsPageContent() {
.sort((left, right) => Date.parse(left.periodEnd ?? left.filingDate) - Date.parse(right.periodEnd ?? right.filingDate));
}, [financials?.periods]);
const activeRows = useMemo<DisplayRow[]>(() => {
const isTreeStatementMode = displayMode === 'standardized' && isStatementSurfaceKind(surfaceKind);
const activeRows = useMemo<FlatDisplayRow[]>(() => {
if (!financials) {
return [];
}
@@ -477,35 +609,81 @@ function FinancialsPageContent() {
}, [displayMode, financials, surfaceKind]);
const filteredRows = useMemo(() => {
if (isTreeStatementMode) {
return activeRows;
}
const normalizedSearch = rowSearch.trim().toLowerCase();
if (!normalizedSearch) {
return activeRows;
}
return activeRows.filter((row) => row.label.toLowerCase().includes(normalizedSearch));
}, [activeRows, rowSearch]);
}, [activeRows, isTreeStatementMode, rowSearch]);
const groupedRows = useMemo(() => {
return groupRows(filteredRows, financials?.categories ?? []);
}, [filteredRows, financials?.categories]);
const selectedRow = useMemo(() => {
if (!selectedRowKey) {
const treeModel = useMemo(() => {
if (!isTreeStatementMode || !financials || !isStatementSurfaceKind(surfaceKind)) {
return null;
}
return activeRows.find((row) => row.key === selectedRowKey) ?? null;
}, [activeRows, selectedRowKey]);
return buildStatementTree({
surfaceKind,
rows: financials.statementRows?.standardized ?? [],
statementDetails: financials.statementDetails,
categories: financials.categories,
searchQuery: rowSearch,
expandedRowKeys
});
}, [expandedRowKeys, financials, isTreeStatementMode, rowSearch, surfaceKind]);
const selectedRow = useMemo(() => {
if (!selectedFlatRowKey) {
return null;
}
return activeRows.find((row) => row.key === selectedFlatRowKey) ?? null;
}, [activeRows, selectedFlatRowKey]);
const selectedStatementRow = useMemo(() => {
if (!isTreeStatementMode || !financials || !isStatementSurfaceKind(surfaceKind)) {
return null;
}
return resolveStatementSelection({
surfaceKind,
rows: financials.statementRows?.standardized ?? [],
statementDetails: financials.statementDetails,
selection: selectedRowRef
});
}, [financials, isTreeStatementMode, selectedRowRef, surfaceKind]);
const dimensionRows = useMemo(() => {
if (!selectedRow || !financials?.dimensionBreakdown) {
if (!financials?.dimensionBreakdown) {
return [];
}
if (selectedStatementRow?.kind === 'surface') {
return financials.dimensionBreakdown[selectedStatementRow.row.key] ?? [];
}
if (selectedStatementRow?.kind === 'detail') {
return financials.dimensionBreakdown[selectedStatementRow.row.key]
?? financials.dimensionBreakdown[selectedStatementRow.row.parentSurfaceKey]
?? [];
}
if (!selectedRow) {
return [];
}
return financials.dimensionBreakdown[selectedRow.key] ?? [];
}, [financials?.dimensionBreakdown, selectedRow]);
}, [financials?.dimensionBreakdown, selectedRow, selectedStatementRow]);
const commonSizeRow = useMemo<DisplayRow | null>(() => {
const commonSizeRow = useMemo<SurfaceFinancialRow | null>(() => {
if (displayMode === 'faithful' || !financials?.statementRows) {
return null;
}
@@ -539,7 +717,9 @@ function FinancialsPageContent() {
options: SURFACE_OPTIONS,
onChange: (value) => {
setSurfaceKind(value as FinancialSurfaceKind);
setSelectedRowKey(null);
setSelectedFlatRowKey(null);
setSelectedRowRef(null);
setExpandedRowKeys(new Set());
}
},
{
@@ -549,7 +729,9 @@ function FinancialsPageContent() {
options: CADENCE_OPTIONS,
onChange: (value) => {
setCadence(value as FinancialCadence);
setSelectedRowKey(null);
setSelectedFlatRowKey(null);
setSelectedRowRef(null);
setExpandedRowKeys(new Set());
}
}
];
@@ -562,7 +744,9 @@ function FinancialsPageContent() {
options: DISPLAY_MODE_OPTIONS,
onChange: (value) => {
setDisplayMode(value as FinancialDisplayMode);
setSelectedRowKey(null);
setSelectedFlatRowKey(null);
setSelectedRowRef(null);
setExpandedRowKeys(new Set());
}
});
}
@@ -626,6 +810,56 @@ function FinancialsPageContent() {
return actions;
}, [displayMode, financials?.nextCursor, loadFinancials, loadingMore, selectedRow, showCommonSize, showPercentChange, surfaceKind, ticker]);
const toggleExpandedRow = useCallback((key: string) => {
setExpandedRowKeys((current) => {
const next = new Set(current);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
}, []);
const rowResultCountLabel = useMemo(() => {
if (treeModel) {
return `${treeModel.visibleNodeCount} of ${treeModel.totalNodeCount} visible rows`;
}
return `${filteredRows.length} of ${activeRows.length} rows`;
}, [activeRows.length, filteredRows.length, treeModel]);
const valueScaleLabel = FINANCIAL_VALUE_SCALE_OPTIONS.find((option) => option.value === valueScale)?.label ?? valueScale;
const renderStatementTreeCellValue = useCallback((row: StatementMatrixRow, periodId: string, previousPeriodId: string | null) => {
if (!isStatementSurfaceKind(surfaceKind)) {
return '—';
}
return buildStatementTreeDisplayValue({
row,
periodId,
previousPeriodId,
commonSizeRow,
showPercentChange,
showCommonSize,
scale: valueScale,
surfaceKind
});
}, [commonSizeRow, showCommonSize, showPercentChange, surfaceKind, valueScale]);
const renderDimensionValue = useCallback((value: number | null, rowKey: string, unit: FinancialUnit) => {
return formatMetricValue({
value,
unit,
scale: valueScale,
rowKey,
surfaceKind
});
}, [surfaceKind, valueScale]);
if (isPending || !isAuthenticated) {
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading financial terminal...</div>;
}
@@ -668,6 +902,9 @@ function FinancialsPageContent() {
if (!normalized) {
return;
}
setSelectedFlatRowKey(null);
setSelectedRowRef(null);
setExpandedRowKeys(new Set());
setTicker(normalized);
}}
>
@@ -726,7 +963,7 @@ function FinancialsPageContent() {
className="w-full sm:max-w-sm"
/>
<span className="text-sm text-[color:var(--terminal-muted)]">
{filteredRows.length} of {activeRows.length} rows
{rowResultCountLabel}
</span>
</div>
</Panel>
@@ -788,6 +1025,10 @@ function FinancialsPageContent() {
)}
</Panel>
{financials && isTreeStatementMode ? (
<NormalizationSummary normalization={financials.normalization} />
) : null}
<Panel title="Surface Matrix" subtitle="Standardized statements, ratios, and KPIs render in one shared matrix." variant="surface">
{loading ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Loading financial matrix...</p>
@@ -796,158 +1037,87 @@ function FinancialsPageContent() {
<p className="text-sm text-[color:var(--terminal-bright)]">This surface is not yet available in v1.</p>
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">Adjusted and custom metrics are API-visible placeholders only. No edits or derived rows are available yet.</p>
</div>
) : periods.length === 0 || filteredRows.length === 0 ? (
) : periods.length === 0 || (isTreeStatementMode ? (treeModel?.visibleNodeCount ?? 0) === 0 : filteredRows.length === 0) ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No rows available for the selected filters yet.</p>
) : (
<div className="space-y-3">
{isStatementSurfaceKind(surfaceKind) ? (
<p className="text-xs uppercase tracking-[0.24em] text-[color:var(--terminal-muted)]">
USD · {FINANCIAL_VALUE_SCALE_OPTIONS.find((option) => option.value === valueScale)?.label ?? valueScale}
USD · {valueScaleLabel}
</p>
) : null}
<div className="data-table-wrap">
<table className="data-table min-w-[980px]">
<thead>
<tr>
<th className="sticky left-0 z-10 bg-[color:var(--panel)]">Metric</th>
{periods.map((period) => (
<th key={period.id}>
<div className="flex flex-col gap-1">
<span>{formatLongDate(period.periodEnd ?? period.filingDate)}</span>
<span className="text-[11px] normal-case tracking-normal text-[color:var(--terminal-muted)]">{period.filingType} · {period.periodLabel}</span>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{groupedRows.map((group) => (
<Fragment key={group.label ?? 'ungrouped'}>
{group.label ? (
<tr className="bg-[color:var(--panel-soft)]">
<td colSpan={periods.length + 1} className="font-semibold text-[color:var(--terminal-bright)]">{group.label}</td>
</tr>
) : null}
{group.rows.map((row) => (
<tr
key={row.key}
className={selectedRowKey === row.key ? 'bg-[color:var(--panel-soft)]' : undefined}
onClick={() => setSelectedRowKey(row.key)}
>
<td className="sticky left-0 z-10 bg-[color:var(--panel)]">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span style={{ paddingLeft: `${isTaxonomyRow(row) ? Math.min(row.depth, 10) * 12 : 0}px` }}>{row.label}</span>
{'hasDimensions' in row && row.hasDimensions ? <ChevronDown className="size-3 text-[color:var(--accent)]" /> : null}
</div>
{isDerivedRow(row) && row.formulaKey ? (
<span className="text-xs text-[color:var(--terminal-muted)]">Formula: {row.formulaKey}</span>
) : null}
{isKpiRow(row) ? (
<span className="text-xs text-[color:var(--terminal-muted)]">Provenance: {row.provenanceType}</span>
) : null}
</div>
</td>
{periods.map((period, index) => (
<td key={`${row.key}-${period.id}`}>
{buildDisplayValue({
row,
periodId: period.id,
previousPeriodId: index > 0 ? periods[index - 1]?.id ?? null : null,
commonSizeRow,
displayMode,
showPercentChange,
showCommonSize,
scale: valueScale,
surfaceKind
})}
</td>
))}
</tr>
))}
</Fragment>
))}
</tbody>
</table>
</div>
</div>
)}
</Panel>
<Panel title="Row Details" subtitle="Inspect provenance, formulas, and dimensional evidence for the selected row." variant="surface">
{!selectedRow ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Select a row to inspect details.</p>
) : (
<div className="space-y-4 text-sm">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
<p className="text-[color:var(--terminal-muted)]">Label</p>
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.label}</p>
</div>
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
<p className="text-[color:var(--terminal-muted)]">Key</p>
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.key}</p>
</div>
</div>
{isTaxonomyRow(selectedRow) ? (
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
<p className="text-[color:var(--terminal-muted)]">Taxonomy Concept</p>
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.qname}</p>
</div>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
<p className="text-[color:var(--terminal-muted)]">Category</p>
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.category}</p>
</div>
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
<p className="text-[color:var(--terminal-muted)]">Unit</p>
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.unit}</p>
</div>
</div>
)}
{isDerivedRow(selectedRow) ? (
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
<p className="text-[color:var(--terminal-muted)]">Source Row Keys</p>
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.sourceRowKeys.join(', ') || 'n/a'}</p>
<p className="mt-2 text-[color:var(--terminal-muted)]">Source Concepts</p>
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.sourceConcepts.join(', ') || 'n/a'}</p>
<p className="mt-2 text-[color:var(--terminal-muted)]">Source Fact IDs</p>
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.sourceFactIds.join(', ') || 'n/a'}</p>
</div>
) : null}
{!selectedRow || !('hasDimensions' in selectedRow) || !selectedRow.hasDimensions ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No dimensional drill-down is available for this row.</p>
) : dimensionRows.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No dimensional facts were returned for the selected row.</p>
{isTreeStatementMode && treeModel ? (
<StatementMatrix
periods={periods}
sections={treeModel.sections}
selectedRowRef={selectedRowRef}
onToggleRow={toggleExpandedRow}
onSelectRow={setSelectedRowRef}
renderCellValue={renderStatementTreeCellValue}
periodLabelFormatter={formatLongDate}
/>
) : (
<div className="data-table-wrap">
<table className="data-table min-w-[760px]">
<table className="data-table min-w-[980px]">
<thead>
<tr>
<th>Period</th>
<th>Axis</th>
<th>Member</th>
<th>Value</th>
<th className="sticky left-0 z-10 bg-[color:var(--panel)]">Metric</th>
{periods.map((period) => (
<th key={period.id}>
<div className="flex flex-col gap-1">
<span>{formatLongDate(period.periodEnd ?? period.filingDate)}</span>
<span className="text-[11px] normal-case tracking-normal text-[color:var(--terminal-muted)]">{period.filingType} · {period.periodLabel}</span>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{dimensionRows.map((row, index) => (
<tr key={`${row.rowKey}-${row.periodId}-${row.axis}-${row.member}-${index}`}>
<td>{periods.find((period) => period.id === row.periodId)?.periodLabel ?? row.periodId}</td>
<td>{row.axis}</td>
<td>{row.member}</td>
<td>{formatMetricValue({
value: row.value,
unit: isTaxonomyRow(selectedRow) ? 'currency' : selectedRow.unit,
scale: valueScale,
rowKey: selectedRow.key,
surfaceKind
})}</td>
</tr>
{groupedRows.map((group) => (
<Fragment key={group.label ?? 'ungrouped'}>
{group.label ? (
<tr className="bg-[color:var(--panel-soft)]">
<td colSpan={periods.length + 1} className="font-semibold text-[color:var(--terminal-bright)]">{group.label}</td>
</tr>
) : null}
{group.rows.map((row) => (
<tr
key={row.key}
className={selectedFlatRowKey === row.key ? 'bg-[color:var(--panel-soft)]' : undefined}
onClick={() => setSelectedFlatRowKey(row.key)}
>
<td className="sticky left-0 z-10 bg-[color:var(--panel)]">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span style={{ paddingLeft: `${isTaxonomyRow(row) ? Math.min(row.depth, 10) * 12 : 0}px` }}>{row.label}</span>
{'hasDimensions' in row && row.hasDimensions ? <ChevronDown className="size-3 text-[color:var(--accent)]" /> : null}
</div>
{isDerivedRow(row) && row.formulaKey ? (
<span className="text-xs text-[color:var(--terminal-muted)]">Formula: {row.formulaKey}</span>
) : null}
{isKpiRow(row) ? (
<span className="text-xs text-[color:var(--terminal-muted)]">Provenance: {row.provenanceType}</span>
) : null}
</div>
</td>
{periods.map((period, index) => (
<td key={`${row.key}-${period.id}`}>
{buildDisplayValue({
row,
periodId: period.id,
previousPeriodId: index > 0 ? periods[index - 1]?.id ?? null : null,
commonSizeRow,
displayMode,
showPercentChange,
showCommonSize,
scale: valueScale,
surfaceKind
})}
</td>
))}
</tr>
))}
</Fragment>
))}
</tbody>
</table>
@@ -957,6 +1127,100 @@ function FinancialsPageContent() {
)}
</Panel>
{isTreeStatementMode && isStatementSurfaceKind(surfaceKind) ? (
<StatementRowInspector
selection={selectedStatementRow}
dimensionRows={dimensionRows}
periods={periods}
surfaceKind={surfaceKind}
renderValue={renderStatementTreeCellValue}
renderDimensionValue={renderDimensionValue}
/>
) : (
<Panel title="Row Details" subtitle="Inspect provenance, formulas, and dimensional evidence for the selected row." variant="surface">
{!selectedRow ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Select a row to inspect details.</p>
) : (
<div className="space-y-4 text-sm">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
<p className="text-[color:var(--terminal-muted)]">Label</p>
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.label}</p>
</div>
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
<p className="text-[color:var(--terminal-muted)]">Key</p>
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.key}</p>
</div>
</div>
{isTaxonomyRow(selectedRow) ? (
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
<p className="text-[color:var(--terminal-muted)]">Taxonomy Concept</p>
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.qname}</p>
</div>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
<p className="text-[color:var(--terminal-muted)]">Category</p>
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.category}</p>
</div>
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
<p className="text-[color:var(--terminal-muted)]">Unit</p>
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.unit}</p>
</div>
</div>
)}
{isDerivedRow(selectedRow) ? (
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
<p className="text-[color:var(--terminal-muted)]">Source Row Keys</p>
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.sourceRowKeys.join(', ') || 'n/a'}</p>
<p className="mt-2 text-[color:var(--terminal-muted)]">Source Concepts</p>
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.sourceConcepts.join(', ') || 'n/a'}</p>
<p className="mt-2 text-[color:var(--terminal-muted)]">Source Fact IDs</p>
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.sourceFactIds.join(', ') || 'n/a'}</p>
</div>
) : null}
{!selectedRow || !('hasDimensions' in selectedRow) || !selectedRow.hasDimensions ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No dimensional drill-down is available for this row.</p>
) : dimensionRows.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No dimensional facts were returned for the selected row.</p>
) : (
<div className="data-table-wrap">
<table className="data-table min-w-[760px]">
<thead>
<tr>
<th>Period</th>
<th>Axis</th>
<th>Member</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{dimensionRows.map((row, index) => (
<tr key={`${row.rowKey}-${row.periodId}-${row.axis}-${row.member}-${index}`}>
<td>{periods.find((period) => period.id === row.periodId)?.periodLabel ?? row.periodId}</td>
<td>{row.axis}</td>
<td>{row.member}</td>
<td>{formatMetricValue({
value: row.value,
unit: isTaxonomyRow(selectedRow) ? 'currency' : selectedRow.unit,
scale: valueScale,
rowKey: selectedRow.key,
surfaceKind
})}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</Panel>
)}
{(surfaceKind === 'income_statement' || surfaceKind === 'balance_sheet' || surfaceKind === 'cash_flow_statement') && financials ? (
<Panel title="Metric Validation" subtitle="Validation remains limited to statement-derived taxonomy metrics in v1." variant="surface">
<div className="mb-3 flex items-center gap-2 text-sm text-[color:var(--terminal-muted)]">

View File

@@ -361,6 +361,30 @@ function GraphingPageContent() {
surface: graphState.surface,
metric: graphState.metric
}), [graphState.metric, graphState.surface, results]);
const partialCoverageMessage = useMemo(() => {
const notMeaningfulCount = comparison.companies.filter((company) => company.status === 'not_meaningful').length;
const missingCount = comparison.companies.filter((company) => company.status === 'no_metric_data').length;
const errorCount = comparison.companies.filter((company) => company.status === 'error').length;
const parts: string[] = [];
if (notMeaningfulCount > 0) {
parts.push(`${notMeaningfulCount} ${notMeaningfulCount === 1 ? 'company marks' : 'companies mark'} this metric as not meaningful for the selected pack`);
}
if (missingCount > 0) {
parts.push(`${missingCount} ${missingCount === 1 ? 'company has' : 'companies have'} no metric data`);
}
if (errorCount > 0) {
parts.push(`${errorCount} ${errorCount === 1 ? 'company failed' : 'companies failed'} to load`);
}
if (parts.length === 0) {
return 'Partial coverage detected. Some companies are missing values for this metric.';
}
return `Partial coverage detected. ${parts.join('. ')}.`;
}, [comparison.companies]);
const hasCurrencyScale = selectedMetric?.unit === 'currency';
@@ -527,7 +551,7 @@ function GraphingPageContent() {
<>
{comparison.hasPartialData ? (
<div className="mb-4 rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-3 text-sm text-[color:var(--terminal-muted)]">
Partial coverage detected. Some companies are missing values for this metric or failed to load, but the remaining series still render.
{partialCoverageMessage}
</div>
) : null}
<div className="mb-4 flex flex-wrap gap-2">
@@ -613,6 +637,7 @@ function GraphingPageContent() {
<thead>
<tr>
<th>Company</th>
<th>Pack</th>
<th>Latest</th>
<th>Prior</th>
<th>Change</th>
@@ -637,6 +662,7 @@ function GraphingPageContent() {
<span className="text-xs text-[color:var(--terminal-muted)]">{row.companyName}</span>
</div>
</td>
<td>{row.fiscalPack ?? 'n/a'}</td>
<td>{selectedMetric ? formatMetricValue(row.latestValue, selectedMetric.unit, graphState.scale) : 'n/a'}</td>
<td>{selectedMetric ? formatMetricValue(row.priorValue, selectedMetric.unit, graphState.scale) : 'n/a'}</td>
<td className={cn(row.changeValue !== null && row.changeValue >= 0 ? 'text-[color:var(--accent)]' : row.changeValue !== null ? 'text-[#ffb5b5]' : 'text-[color:var(--terminal-muted)]')}>
@@ -647,6 +673,8 @@ function GraphingPageContent() {
<td>
{row.status === 'ready' ? (
<span className="text-[color:var(--accent)]">Ready</span>
) : row.status === 'not_meaningful' ? (
<span className="text-[color:var(--terminal-muted)]">Not meaningful for this pack</span>
) : row.status === 'no_metric_data' ? (
<span className="text-[color:var(--terminal-muted)]">No metric data</span>
) : (

View File

@@ -0,0 +1,58 @@
'use client';
import { AlertTriangle } from 'lucide-react';
import { Panel } from '@/components/ui/panel';
import type { NormalizationMetadata } from '@/lib/types';
import { cn } from '@/lib/utils';
type NormalizationSummaryProps = {
normalization: NormalizationMetadata;
};
function SummaryCard(props: {
label: string;
value: string;
tone?: 'default' | 'warning';
}) {
return (
<div
className={cn(
'data-surface px-3 py-3',
props.tone === 'warning' && 'border-[#7f6250] bg-[linear-gradient(180deg,rgba(80,58,41,0.92),rgba(38,27,21,0.78))]'
)}
>
<p className="panel-heading text-[10px] uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{props.label}</p>
<p className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">{props.value}</p>
</div>
);
}
export function NormalizationSummary({ normalization }: NormalizationSummaryProps) {
const hasMaterialUnmapped = normalization.materialUnmappedRowCount > 0;
return (
<Panel
title="Normalization Summary"
subtitle="Pack, parser, and residual mapping health for the compact statement surface."
variant="surface"
>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<SummaryCard label="Pack" value={normalization.fiscalPack ?? 'unknown'} />
<SummaryCard label="Regime" value={normalization.regime} />
<SummaryCard label="Parser" value={`fiscal-xbrl ${normalization.parserVersion}`} />
<SummaryCard label="Unmapped Rows" value={String(normalization.unmappedRowCount)} />
<SummaryCard
label="Material Unmapped"
value={String(normalization.materialUnmappedRowCount)}
tone={hasMaterialUnmapped ? 'warning' : 'default'}
/>
</div>
{hasMaterialUnmapped ? (
<div className="mt-3 flex items-start gap-2 rounded-xl border border-[#7f6250] bg-[rgba(91,66,46,0.18)] px-3 py-3 text-sm text-[#f5d5c0]">
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
<p>Material unmapped rows were detected for this filing set. Use the inspector and detail rows before relying on cross-company comparisons.</p>
</div>
) : null}
</Panel>
);
}

View File

@@ -0,0 +1,208 @@
'use client';
import { Fragment } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import type { FinancialStatementPeriod, SurfaceFinancialRow, DetailFinancialRow } from '@/lib/types';
import { cn } from '@/lib/utils';
import type {
StatementInspectorSelection,
StatementTreeNode,
StatementTreeSection
} from '@/lib/financials/statement-view-model';
type MatrixRow = SurfaceFinancialRow | DetailFinancialRow;
type StatementMatrixProps = {
periods: FinancialStatementPeriod[];
sections: StatementTreeSection[];
selectedRowRef: StatementInspectorSelection | null;
onToggleRow: (key: string) => void;
onSelectRow: (selection: StatementInspectorSelection) => void;
renderCellValue: (row: MatrixRow, periodId: string, previousPeriodId: string | null) => string;
periodLabelFormatter: (value: string) => string;
};
function isSurfaceNode(node: StatementTreeNode): node is Extract<StatementTreeNode, { kind: 'surface' }> {
return node.kind === 'surface';
}
function rowSelected(
node: StatementTreeNode,
selectedRowRef: StatementInspectorSelection | null
) {
if (!selectedRowRef) {
return false;
}
if (node.kind === 'surface') {
return selectedRowRef.kind === 'surface' && selectedRowRef.key === node.row.key;
}
return selectedRowRef.kind === 'detail'
&& selectedRowRef.key === node.row.key
&& selectedRowRef.parentKey === node.parentSurfaceKey;
}
function surfaceBadges(node: Extract<StatementTreeNode, { kind: 'surface' }>) {
const badges: Array<{ label: string; tone: 'default' | 'warning' | 'muted' }> = [];
if (node.row.resolutionMethod === 'formula_derived') {
badges.push({ label: 'Formula', tone: node.row.confidence === 'low' ? 'warning' : 'default' });
}
if (node.row.resolutionMethod === 'not_meaningful') {
badges.push({ label: 'N/M', tone: 'muted' });
}
if (node.row.confidence === 'low') {
badges.push({ label: 'Low confidence', tone: 'warning' });
}
const detailCount = node.row.detailCount ?? node.directDetailCount;
if (detailCount > 0) {
badges.push({ label: `${detailCount} details`, tone: 'default' });
}
return badges;
}
function badgeClass(tone: 'default' | 'warning' | 'muted') {
if (tone === 'warning') {
return 'border-[#84614f] bg-[rgba(112,76,54,0.22)] text-[#ffd7bf]';
}
if (tone === 'muted') {
return 'border-[color:var(--line-weak)] bg-[rgba(80,85,92,0.16)] text-[color:var(--terminal-muted)]';
}
return 'border-[color:var(--line-weak)] bg-[rgba(88,102,122,0.16)] text-[color:var(--terminal-bright)]';
}
function renderNodes(props: StatementMatrixProps & { nodes: StatementTreeNode[] }) {
return props.nodes.map((node) => {
const isSelected = rowSelected(node, props.selectedRowRef);
const labelIndent = node.kind === 'detail' ? node.level * 18 + 18 : node.level * 18;
const canToggle = isSurfaceNode(node) && node.expandable;
const nextSelection: StatementInspectorSelection = node.kind === 'surface'
? { kind: 'surface', key: node.row.key }
: { kind: 'detail', key: node.row.key, parentKey: node.parentSurfaceKey };
return (
<Fragment key={node.id}>
<tr className={cn(isSelected && 'bg-[color:rgba(70,77,87,0.48)]')}>
<td className="sticky left-0 z-10 bg-[color:var(--panel)]">
<div className="flex min-w-[260px] items-start gap-2" style={{ paddingLeft: `${labelIndent}px` }}>
{canToggle ? (
<button
type="button"
aria-label={`${node.expanded ? 'Collapse' : 'Expand'} ${node.row.label} details`}
aria-expanded={node.expanded}
aria-controls={`statement-children-${node.id}`}
className="mt-0.5 inline-flex size-11 shrink-0 items-center justify-center rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] text-[color:var(--terminal-bright)] transition hover:border-[color:var(--line-strong)]"
onClick={(event) => {
event.stopPropagation();
props.onToggleRow(node.row.key);
}}
>
{node.expanded ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
</button>
) : (
<span className="inline-flex size-11 shrink-0 items-center justify-center text-[color:var(--terminal-muted)]" aria-hidden="true">
{node.kind === 'detail' ? '·' : ''}
</span>
)}
<button
type="button"
className="flex min-w-0 flex-1 flex-col items-start gap-1 py-2 text-left"
onClick={() => props.onSelectRow(nextSelection)}
>
<span className={cn(
'text-sm text-[color:var(--terminal-bright)]',
node.kind === 'detail' && 'text-[13px] text-[color:var(--terminal-soft)]',
node.kind === 'surface' && node.level > 0 && 'text-[color:var(--terminal-soft)]'
)}>
{node.row.label}
</span>
{node.kind === 'detail' ? (
<span className="text-xs text-[color:var(--terminal-muted)]">
{node.row.localName}
{node.row.residualFlag ? ' · residual' : ''}
</span>
) : (
<div className="flex flex-wrap gap-1">
{surfaceBadges(node).map((badge) => (
<span
key={`${node.row.key}-${badge.label}`}
className={cn(
'rounded-full border px-2 py-0.5 text-[10px] uppercase tracking-[0.14em]',
badgeClass(badge.tone)
)}
>
{badge.label}
</span>
))}
</div>
)}
</button>
</div>
</td>
{props.periods.map((period, index) => (
<td key={`${node.id}-${period.id}`}>
{props.renderCellValue(node.row, period.id, index > 0 ? props.periods[index - 1]?.id ?? null : null)}
</td>
))}
</tr>
{isSurfaceNode(node) && node.expanded ? (
<>
<tr id={`statement-children-${node.id}`} className="sr-only">
<td colSpan={props.periods.length + 1}>Expanded children for {node.row.label}</td>
</tr>
{renderNodes({
...props,
nodes: node.children
})}
</>
) : null}
</Fragment>
);
});
}
export function StatementMatrix(props: StatementMatrixProps) {
return (
<div className="data-table-wrap">
<table className="data-table min-w-[1040px]">
<thead>
<tr>
<th className="sticky left-0 z-10 bg-[color:var(--panel)]">Metric</th>
{props.periods.map((period) => (
<th key={period.id}>
<div className="flex flex-col gap-1">
<span>{props.periodLabelFormatter(period.periodEnd ?? period.filingDate)}</span>
<span className="text-[11px] normal-case tracking-normal text-[color:var(--terminal-muted)]">{period.filingType} · {period.periodLabel}</span>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{props.sections.map((section) => (
<Fragment key={section.key}>
{section.label ? (
<tr className="bg-[color:var(--panel-soft)]">
<td colSpan={props.periods.length + 1} className="font-semibold text-[color:var(--terminal-bright)]">
{section.label}
</td>
</tr>
) : null}
{renderNodes({
...props,
nodes: section.nodes
})}
</Fragment>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,174 @@
'use client';
import { Panel } from '@/components/ui/panel';
import type {
DetailFinancialRow,
DimensionBreakdownRow,
FinancialStatementPeriod,
FinancialSurfaceKind,
SurfaceFinancialRow
} from '@/lib/types';
import type { ResolvedStatementSelection } from '@/lib/financials/statement-view-model';
type StatementRowInspectorProps = {
selection: ResolvedStatementSelection | null;
dimensionRows: DimensionBreakdownRow[];
periods: FinancialStatementPeriod[];
surfaceKind: Extract<FinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>;
renderValue: (row: SurfaceFinancialRow | DetailFinancialRow, periodId: string, previousPeriodId: string | null) => string;
renderDimensionValue: (value: number | null, rowKey: string, unit: SurfaceFinancialRow['unit']) => string;
};
function InspectorCard(props: {
label: string;
value: string;
}) {
return (
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
<p className="text-[color:var(--terminal-muted)]">{props.label}</p>
<p className="font-semibold text-[color:var(--terminal-bright)]">{props.value}</p>
</div>
);
}
function renderList(values: string[]) {
return values.length > 0 ? values.join(', ') : 'n/a';
}
export function StatementRowInspector(props: StatementRowInspectorProps) {
const selection = props.selection;
return (
<Panel
title="Row Details"
subtitle="Inspect compact-surface resolution, raw drill-down rows, and dimensional evidence."
variant="surface"
>
{!selection ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Select a compact surface row or raw detail row to inspect details.</p>
) : selection.kind === 'surface' ? (
<div className="space-y-4 text-sm">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
<InspectorCard label="Label" value={selection.row.label} />
<InspectorCard label="Key" value={selection.row.key} />
<InspectorCard label="Resolution" value={selection.row.resolutionMethod ?? 'direct'} />
<InspectorCard label="Confidence" value={selection.row.confidence ?? 'high'} />
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<InspectorCard label="Source Row Keys" value={renderList(selection.row.sourceRowKeys)} />
<InspectorCard label="Source Concepts" value={renderList(selection.row.sourceConcepts)} />
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<InspectorCard label="Source Fact IDs" value={selection.row.sourceFactIds.length > 0 ? selection.row.sourceFactIds.join(', ') : 'n/a'} />
<InspectorCard label="Warning Codes" value={renderList(selection.row.warningCodes ?? [])} />
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<InspectorCard label="Child Surface Rows" value={selection.childSurfaceRows.length > 0 ? selection.childSurfaceRows.map((row) => row.label).join(', ') : 'None'} />
<InspectorCard label="Raw Detail Rows" value={String(selection.detailRows.length)} />
</div>
{selection.detailRows.length > 0 ? (
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-3">
<p className="text-[color:var(--terminal-muted)]">Raw Detail Labels</p>
<div className="mt-2 flex flex-wrap gap-2">
{selection.detailRows.map((row) => (
<span
key={`${selection.row.key}-${row.key}`}
className="rounded-full border border-[color:var(--line-weak)] bg-[rgba(88,102,122,0.16)] px-3 py-1 text-xs text-[color:var(--terminal-bright)]"
>
{row.label}
</span>
))}
</div>
</div>
) : null}
{selection.row.hasDimensions ? (
props.dimensionRows.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No dimensional facts were returned for the selected compact row.</p>
) : (
<div className="data-table-wrap">
<table className="data-table min-w-[760px]">
<thead>
<tr>
<th>Period</th>
<th>Axis</th>
<th>Member</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{props.dimensionRows.map((row, index) => (
<tr key={`${selection.row.key}-${row.periodId}-${row.axis}-${row.member}-${index}`}>
<td>{props.periods.find((period) => period.id === row.periodId)?.periodLabel ?? row.periodId}</td>
<td>{row.axis}</td>
<td>{row.member}</td>
<td>{props.renderDimensionValue(row.value, selection.row.key, selection.row.unit)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
) : (
<p className="text-sm text-[color:var(--terminal-muted)]">No dimensional drill-down is available for this compact surface row.</p>
)}
</div>
) : (
<div className="space-y-4 text-sm">
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
<InspectorCard label="Label" value={selection.row.label} />
<InspectorCard label="Key" value={selection.row.key} />
<InspectorCard label="Parent Surface" value={selection.parentSurfaceRow?.label ?? selection.row.parentSurfaceKey} />
<InspectorCard label="Residual" value={selection.row.residualFlag ? 'Yes' : 'No'} />
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<InspectorCard label="Concept Key" value={selection.row.conceptKey} />
<InspectorCard label="QName" value={selection.row.qname} />
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<InspectorCard label="Local Name" value={selection.row.localName} />
<InspectorCard label="Source Fact IDs" value={selection.row.sourceFactIds.length > 0 ? selection.row.sourceFactIds.join(', ') : 'n/a'} />
</div>
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
<p className="text-[color:var(--terminal-muted)]">Dimensions Summary</p>
<p className="font-semibold text-[color:var(--terminal-bright)]">{renderList(selection.row.dimensionsSummary)}</p>
</div>
{props.dimensionRows.length === 0 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">No dimensional facts were returned for the selected raw detail row.</p>
) : (
<div className="data-table-wrap">
<table className="data-table min-w-[760px]">
<thead>
<tr>
<th>Period</th>
<th>Axis</th>
<th>Member</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{props.dimensionRows.map((row, index) => (
<tr key={`${selection.row.parentSurfaceKey}-${selection.row.key}-${row.periodId}-${index}`}>
<td>{props.periods.find((period) => period.id === row.periodId)?.periodLabel ?? row.periodId}</td>
<td>{row.axis}</td>
<td>{row.member}</td>
<td>{props.renderDimensionValue(row.value, selection.row.key, props.surfaceKind === 'balance_sheet' ? 'currency' : 'currency')}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</Panel>
);
}

346
e2e/financials.spec.ts Normal file
View File

@@ -0,0 +1,346 @@
import { expect, test, type Page, type TestInfo } from '@playwright/test';
const PASSWORD = 'Sup3rSecure!123';
function toSlug(value: string) {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48);
}
async function signUp(page: Page, testInfo: TestInfo) {
const email = `playwright-financials-${testInfo.workerIndex}-${toSlug(testInfo.title)}-${Date.now()}@example.com`;
await page.goto('/auth/signup');
await page.locator('input[autocomplete="name"]').fill('Playwright Financials User');
await page.locator('input[autocomplete="email"]').fill(email);
await page.locator('input[autocomplete="new-password"]').first().fill(PASSWORD);
await page.locator('input[autocomplete="new-password"]').nth(1).fill(PASSWORD);
await page.getByRole('button', { name: 'Create account' }).click();
await expect(page.getByRole('heading', { name: 'Command Center' })).toBeVisible({ timeout: 30_000 });
}
function buildFinancialsPayload(ticker: 'MSFT' | 'JPM') {
const isBank = ticker === 'JPM';
const prefix = ticker.toLowerCase();
return {
financials: {
company: {
ticker,
companyName: isBank ? 'JPMorgan Chase & Co.' : 'Microsoft Corporation',
cik: null
},
surfaceKind: 'income_statement',
cadence: 'annual',
displayModes: ['standardized', 'faithful'],
defaultDisplayMode: 'standardized',
periods: [
{
id: `${prefix}-fy24`,
filingId: 1,
accessionNumber: `0000-${prefix}-1`,
filingDate: '2025-02-01',
periodStart: '2024-01-01',
periodEnd: '2024-12-31',
filingType: '10-K',
periodLabel: 'FY 2024'
},
{
id: `${prefix}-fy25`,
filingId: 2,
accessionNumber: `0000-${prefix}-2`,
filingDate: '2026-02-01',
periodStart: '2025-01-01',
periodEnd: '2025-12-31',
filingType: '10-K',
periodLabel: 'FY 2025'
}
],
statementRows: {
faithful: [],
standardized: [
{
key: 'revenue',
label: 'Revenue',
category: 'revenue',
order: 10,
unit: 'currency',
values: { [`${prefix}-fy24`]: 245_000, [`${prefix}-fy25`]: 262_000 },
sourceConcepts: ['revenue'],
sourceRowKeys: ['revenue'],
sourceFactIds: [1],
formulaKey: null,
hasDimensions: false,
resolvedSourceRowKeys: { [`${prefix}-fy24`]: 'revenue', [`${prefix}-fy25`]: 'revenue' },
statement: 'income',
resolutionMethod: 'direct',
confidence: 'high',
warningCodes: []
},
{
key: 'gross_profit',
label: 'Gross Profit',
category: 'profit',
order: 20,
unit: 'currency',
values: { [`${prefix}-fy24`]: isBank ? null : 171_000, [`${prefix}-fy25`]: isBank ? null : 185_000 },
sourceConcepts: ['gross_profit'],
sourceRowKeys: ['gross_profit'],
sourceFactIds: [2],
formulaKey: isBank ? null : 'revenue_less_cost_of_revenue',
hasDimensions: false,
resolvedSourceRowKeys: { [`${prefix}-fy24`]: 'gross_profit', [`${prefix}-fy25`]: 'gross_profit' },
statement: 'income',
resolutionMethod: isBank ? 'not_meaningful' : 'formula_derived',
confidence: isBank ? 'high' : 'medium',
warningCodes: isBank ? ['gross_profit_not_meaningful_bank_pack'] : ['formula_resolved']
},
{
key: 'operating_expenses',
label: 'Operating Expenses',
category: 'opex',
order: 30,
unit: 'currency',
values: { [`${prefix}-fy24`]: isBank ? 121_000 : 76_000, [`${prefix}-fy25`]: isBank ? 126_000 : 82_000 },
sourceConcepts: ['operating_expenses'],
sourceRowKeys: ['operating_expenses'],
sourceFactIds: [3],
formulaKey: null,
hasDimensions: false,
resolvedSourceRowKeys: { [`${prefix}-fy24`]: 'operating_expenses', [`${prefix}-fy25`]: 'operating_expenses' },
statement: 'income',
detailCount: 3,
resolutionMethod: 'direct',
confidence: 'high',
warningCodes: []
},
{
key: 'selling_general_and_administrative',
label: 'SG&A',
category: 'opex',
order: 40,
unit: 'currency',
values: { [`${prefix}-fy24`]: isBank ? null : 44_000, [`${prefix}-fy25`]: isBank ? null : 47_500 },
sourceConcepts: ['selling_general_and_administrative'],
sourceRowKeys: ['selling_general_and_administrative'],
sourceFactIds: [4],
formulaKey: null,
hasDimensions: false,
resolvedSourceRowKeys: { [`${prefix}-fy24`]: 'selling_general_and_administrative', [`${prefix}-fy25`]: 'selling_general_and_administrative' },
statement: 'income',
resolutionMethod: isBank ? 'not_meaningful' : 'direct',
confidence: 'high',
warningCodes: isBank ? ['expense_breakdown_not_meaningful_bank_pack'] : []
},
{
key: 'research_and_development',
label: 'Research Expense',
category: 'opex',
order: 50,
unit: 'currency',
values: { [`${prefix}-fy24`]: isBank ? null : 26_000, [`${prefix}-fy25`]: isBank ? null : 28_000 },
sourceConcepts: ['research_and_development'],
sourceRowKeys: ['research_and_development'],
sourceFactIds: [5],
formulaKey: null,
hasDimensions: false,
resolvedSourceRowKeys: { [`${prefix}-fy24`]: 'research_and_development', [`${prefix}-fy25`]: 'research_and_development' },
statement: 'income',
resolutionMethod: isBank ? 'not_meaningful' : 'direct',
confidence: 'high',
warningCodes: isBank ? ['expense_breakdown_not_meaningful_bank_pack'] : []
},
{
key: 'other_operating_expense',
label: 'Other Expense',
category: 'opex',
order: 60,
unit: 'currency',
values: { [`${prefix}-fy24`]: isBank ? null : 6_000, [`${prefix}-fy25`]: isBank ? null : 6_500 },
sourceConcepts: ['other_operating_expense'],
sourceRowKeys: ['other_operating_expense'],
sourceFactIds: [6],
formulaKey: isBank ? null : 'operating_expenses_residual',
hasDimensions: false,
resolvedSourceRowKeys: { [`${prefix}-fy24`]: 'other_operating_expense', [`${prefix}-fy25`]: 'other_operating_expense' },
statement: 'income',
resolutionMethod: isBank ? 'not_meaningful' : 'formula_derived',
confidence: isBank ? 'high' : 'medium',
warningCodes: isBank ? ['expense_breakdown_not_meaningful_bank_pack'] : ['formula_resolved']
},
{
key: 'operating_income',
label: 'Operating Income',
category: 'profit',
order: 70,
unit: 'currency',
values: { [`${prefix}-fy24`]: isBank ? 124_000 : 95_000, [`${prefix}-fy25`]: isBank ? 131_000 : 103_000 },
sourceConcepts: ['operating_income'],
sourceRowKeys: ['operating_income'],
sourceFactIds: [7],
formulaKey: null,
hasDimensions: false,
resolvedSourceRowKeys: { [`${prefix}-fy24`]: 'operating_income', [`${prefix}-fy25`]: 'operating_income' },
statement: 'income',
resolutionMethod: 'direct',
confidence: 'high',
warningCodes: []
}
]
},
statementDetails: isBank ? null : {
selling_general_and_administrative: [
{
key: 'corporate_sga',
parentSurfaceKey: 'selling_general_and_administrative',
label: 'Corporate SG&A',
conceptKey: 'corporate_sga',
qname: 'us-gaap:CorporateSga',
namespaceUri: 'http://fasb.org/us-gaap/2024',
localName: 'CorporateSga',
unit: 'USD',
values: { [`${prefix}-fy24`]: 44_000, [`${prefix}-fy25`]: 47_500 },
sourceFactIds: [104],
isExtension: false,
dimensionsSummary: [],
residualFlag: false
}
],
research_and_development: [
{
key: 'product_rnd',
parentSurfaceKey: 'research_and_development',
label: 'Product R&D',
conceptKey: 'product_rnd',
qname: 'us-gaap:ProductResearchAndDevelopment',
namespaceUri: 'http://fasb.org/us-gaap/2024',
localName: 'ProductResearchAndDevelopment',
unit: 'USD',
values: { [`${prefix}-fy24`]: 26_000, [`${prefix}-fy25`]: 28_000 },
sourceFactIds: [105],
isExtension: false,
dimensionsSummary: [],
residualFlag: false
}
],
other_operating_expense: [
{
key: 'other_opex_residual',
parentSurfaceKey: 'other_operating_expense',
label: 'Other Operating Expense Residual',
conceptKey: 'other_opex_residual',
qname: 'us-gaap:OtherOperatingExpense',
namespaceUri: 'http://fasb.org/us-gaap/2024',
localName: 'OtherOperatingExpense',
unit: 'USD',
values: { [`${prefix}-fy24`]: 6_000, [`${prefix}-fy25`]: 6_500 },
sourceFactIds: [106],
isExtension: false,
dimensionsSummary: [],
residualFlag: false
}
]
},
ratioRows: [],
kpiRows: null,
trendSeries: [
{
key: 'revenue',
label: 'Revenue',
category: 'revenue',
unit: 'currency',
values: { [`${prefix}-fy24`]: 245_000, [`${prefix}-fy25`]: 262_000 }
}
],
categories: [
{ key: 'revenue', label: 'Revenue', count: 1 },
{ key: 'profit', label: 'Profit', count: 3 },
{ key: 'opex', label: 'Operating Expenses', count: 4 }
],
availability: {
adjusted: false,
customMetrics: false
},
nextCursor: null,
facts: null,
coverage: {
filings: 2,
rows: 8,
dimensions: 0,
facts: 0
},
dataSourceStatus: {
enabled: true,
hydratedFilings: 2,
partialFilings: 0,
failedFilings: 0,
pendingFilings: 0,
queuedSync: false
},
metrics: {
taxonomy: null,
validation: null
},
normalization: {
regime: 'us-gaap',
fiscalPack: isBank ? 'bank_lender' : 'core',
parserVersion: '0.1.0',
unmappedRowCount: 0,
materialUnmappedRowCount: 0
},
dimensionBreakdown: null
}
};
}
async function mockFinancials(page: Page) {
await page.route('**/api/financials/company**', async (route) => {
const url = new URL(route.request().url());
const ticker = (url.searchParams.get('ticker') ?? 'MSFT').toUpperCase() as 'MSFT' | 'JPM';
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(buildFinancialsPayload(ticker))
});
});
}
test('renders the standardized operating expense tree and inspector details', async ({ page }, testInfo) => {
await signUp(page, testInfo);
await mockFinancials(page);
await page.goto('/financials?ticker=MSFT');
await expect(page.getByText('Normalization Summary')).toBeVisible();
await expect(page.getByRole('button', { name: 'Expand Operating Expenses details' })).toBeVisible();
await page.getByRole('button', { name: 'Expand Operating Expenses details' }).click();
await expect(page.getByRole('button', { name: /^SG&A/ })).toBeVisible();
await expect(page.getByRole('button', { name: /^Research Expense/ })).toBeVisible();
await expect(page.getByRole('button', { name: /^Other Expense/ })).toBeVisible();
await page.getByRole('button', { name: /^SG&A/ }).click();
await expect(page.getByText('Row Details')).toBeVisible();
await expect(page.getByText('selling_general_and_administrative', { exact: true }).first()).toBeVisible();
await expect(page.getByText('Corporate SG&A')).toBeVisible();
});
test('shows not meaningful expense breakdown rows for bank pack filings', async ({ page }, testInfo) => {
await signUp(page, testInfo);
await mockFinancials(page);
await page.goto('/financials?ticker=JPM');
await page.getByRole('button', { name: 'Expand Operating Expenses details' }).click();
const sgaButton = page.getByRole('button', { name: /^SG&A/ });
await expect(sgaButton).toBeVisible();
await expect(sgaButton).toContainText('N/M');
await sgaButton.click();
await expect(page.getByText('not_meaningful', { exact: true }).first()).toBeVisible();
await expect(page.getByText('expense_breakdown_not_meaningful_bank_pack')).toBeVisible();
});

View File

@@ -29,6 +29,12 @@ function createFinancialsPayload(input: {
cadence: 'annual' | 'quarterly' | 'ltm';
surface: string;
}) {
const fiscalPack = input.ticker === 'JPM'
? 'bank_lender'
: input.ticker === 'BLK'
? 'broker_asset_manager'
: 'core';
return {
financials: {
company: {
@@ -83,7 +89,50 @@ function createFinancialsPayload(input: {
resolvedSourceRowKeys: {
[`${input.ticker}-p1`]: 'revenue',
[`${input.ticker}-p2`]: 'revenue'
}
},
resolutionMethod: 'direct'
},
{
key: 'gross_profit',
label: 'Gross Profit',
category: 'profit',
order: 15,
unit: 'currency',
values: {
[`${input.ticker}-p1`]: input.ticker === 'JPM' ? null : input.ticker === 'AAPL' ? 138 : 112,
[`${input.ticker}-p2`]: input.ticker === 'JPM' ? null : input.ticker === 'AAPL' ? 156 : 128
},
sourceConcepts: ['gross_profit'],
sourceRowKeys: ['gross_profit'],
sourceFactIds: [11],
formulaKey: input.ticker === 'JPM' ? null : 'revenue_less_cost_of_revenue',
hasDimensions: false,
resolvedSourceRowKeys: {
[`${input.ticker}-p1`]: 'gross_profit',
[`${input.ticker}-p2`]: 'gross_profit'
},
resolutionMethod: input.ticker === 'JPM' ? 'not_meaningful' : 'formula_derived'
},
{
key: 'other_operating_expense',
label: 'Other Expense',
category: 'opex',
order: 16,
unit: 'currency',
values: {
[`${input.ticker}-p1`]: input.ticker === 'BLK' ? null : input.ticker === 'AAPL' ? 12 : 8,
[`${input.ticker}-p2`]: input.ticker === 'BLK' ? null : input.ticker === 'AAPL' ? 14 : 10
},
sourceConcepts: ['other_operating_expense'],
sourceRowKeys: ['other_operating_expense'],
sourceFactIds: [12],
formulaKey: input.ticker === 'BLK' ? null : 'operating_expenses_residual',
hasDimensions: false,
resolvedSourceRowKeys: {
[`${input.ticker}-p1`]: 'other_operating_expense',
[`${input.ticker}-p2`]: 'other_operating_expense'
},
resolutionMethod: input.ticker === 'BLK' ? 'not_meaningful' : 'formula_derived'
},
{
key: 'total_assets',
@@ -103,7 +152,8 @@ function createFinancialsPayload(input: {
resolvedSourceRowKeys: {
[`${input.ticker}-p1`]: 'total_assets',
[`${input.ticker}-p2`]: 'total_assets'
}
},
resolutionMethod: 'direct'
},
{
key: 'free_cash_flow',
@@ -123,10 +173,12 @@ function createFinancialsPayload(input: {
resolvedSourceRowKeys: {
[`${input.ticker}-p1`]: 'free_cash_flow',
[`${input.ticker}-p2`]: 'free_cash_flow'
}
},
resolutionMethod: 'direct'
}
]
},
statementDetails: null,
ratioRows: [
{
key: 'gross_margin',
@@ -177,6 +229,13 @@ function createFinancialsPayload(input: {
taxonomy: null,
validation: null
},
normalization: {
regime: 'unknown',
fiscalPack,
parserVersion: '0.0.0',
unmappedRowCount: 0,
materialUnmappedRowCount: 0
},
dimensionBreakdown: null
}
};
@@ -204,6 +263,10 @@ async function mockGraphingFinancials(page: Page) {
? 'NVIDIA Corporation'
: ticker === 'AMD'
? 'Advanced Micro Devices, Inc.'
: ticker === 'BLK'
? 'BlackRock, Inc.'
: ticker === 'JPM'
? 'JPMorgan Chase & Co.'
: 'Microsoft Corporation';
await route.fulfill({
@@ -227,7 +290,7 @@ test('supports graphing compare controls and partial failures', async ({ page },
await expect(page).toHaveURL(/tickers=MSFT%2CAAPL%2CNVDA/);
await expect(page.getByRole('heading', { name: 'Graphing' })).toBeVisible();
await expect(page.getByText('Microsoft Corporation')).toBeVisible();
await expect(page.getByText('Microsoft Corporation').first()).toBeVisible();
await page.getByRole('button', { name: 'Graph surface Balance Sheet' }).click();
await expect(page).toHaveURL(/surface=balance_sheet/);
@@ -245,11 +308,25 @@ test('supports graphing compare controls and partial failures', async ({ page },
await page.getByLabel('Compare tickers').fill('MSFT, NVDA, AMD');
await page.getByRole('button', { name: 'Update Compare Set' }).click();
await expect(page).toHaveURL(/tickers=MSFT%2CNVDA%2CAMD/);
await expect(page.getByText('Advanced Micro Devices, Inc.')).toBeVisible();
await expect(page.getByText('Advanced Micro Devices, Inc.').first()).toBeVisible();
await page.goto('/graphing?tickers=MSFT,BAD&surface=income_statement&metric=revenue&cadence=annual&chart=line&scale=millions');
await expect(page.getByText('Partial coverage detected.')).toBeVisible();
await expect(page.getByRole('cell', { name: /BAD/ })).toBeVisible();
await expect(page.getByText('Ticker not found')).toBeVisible();
await expect(page.getByText('Microsoft Corporation')).toBeVisible();
await expect(page.getByText('Microsoft Corporation').first()).toBeVisible();
});
test('distinguishes not meaningful metrics from missing data in the latest values table', async ({ page }, testInfo) => {
await signUp(page, testInfo);
await mockGraphingFinancials(page);
await page.goto('/graphing?tickers=MSFT,BLK&surface=income_statement&metric=other_operating_expense&cadence=annual&chart=line&scale=millions');
await expect(page.getByRole('combobox', { name: 'Metric selector' })).toHaveValue('other_operating_expense');
await expect(page.getByRole('cell', { name: 'broker_asset_manager' })).toBeVisible();
await expect(page.getByText('Not meaningful for this pack')).toBeVisible();
await page.goto('/graphing?tickers=JPM,MSFT&surface=income_statement&metric=gross_profit&cadence=annual&chart=line&scale=millions');
await expect(page.getByText('not meaningful for the selected pack', { exact: false })).toBeVisible();
await expect(page.getByRole('cell', { name: 'bank_lender' })).toBeVisible();
await expect(page.getByText('Ready')).toBeVisible();
});

View File

@@ -42,26 +42,28 @@ export const INCOME_STATEMENT_METRIC_DEFINITIONS: StatementMetricDefinition[] =
{ key: 'cost_of_revenue', label: 'Cost of Revenue', category: 'expense', order: 20, unit: 'currency', localNames: ['CostOfRevenue', 'CostOfGoodsSold', 'CostOfSales', 'CostOfProductsSold', 'CostOfServices'], labelIncludes: ['cost of revenue', 'cost of sales', 'cost of goods sold'] },
{ key: 'gross_profit', label: 'Gross Profit', category: 'profit', order: 30, unit: 'currency', localNames: ['GrossProfit'] },
{ key: 'gross_margin', label: 'Gross Margin', category: 'margin', order: 40, unit: 'percent', labelIncludes: ['gross margin'] },
{ key: 'research_and_development', label: 'Research & Development', category: 'opex', order: 50, unit: 'currency', localNames: ['ResearchAndDevelopmentExpense'], labelIncludes: ['research and development'] },
{ key: 'sales_and_marketing', label: 'Sales & Marketing', category: 'opex', order: 60, unit: 'currency', localNames: ['SellingAndMarketingExpense'], labelIncludes: ['sales and marketing', 'selling and marketing'] },
{ key: 'general_and_administrative', label: 'General & Administrative', category: 'opex', order: 70, unit: 'currency', localNames: ['GeneralAndAdministrativeExpense'], labelIncludes: ['general and administrative'] },
{ key: 'selling_general_and_administrative', label: 'Selling, General & Administrative', category: 'opex', order: 80, unit: 'currency', localNames: ['SellingGeneralAndAdministrativeExpense'], labelIncludes: ['selling, general', 'selling general'] },
{ key: 'operating_expenses', label: 'Operating Expenses', category: 'opex', order: 50, unit: 'currency', localNames: ['OperatingExpenses'], labelIncludes: ['operating expenses'] },
{ key: 'selling_general_and_administrative', label: 'Selling, General & Administrative', category: 'opex', order: 60, unit: 'currency', localNames: ['SellingGeneralAndAdministrativeExpense'], labelIncludes: ['selling, general', 'selling general'] },
{ key: 'research_and_development', label: 'Research Expense', category: 'opex', order: 70, unit: 'currency', localNames: ['ResearchAndDevelopmentExpense'], labelIncludes: ['research and development', 'research expense'] },
{ key: 'other_operating_expense', label: 'Other Expense', category: 'opex', order: 80, unit: 'currency', localNames: ['OtherOperatingExpense'], labelIncludes: ['other operating expense', 'other expense'] },
{ key: 'operating_income', label: 'Operating Income', category: 'profit', order: 90, unit: 'currency', localNames: ['OperatingIncomeLoss', 'IncomeLossFromOperations'], labelIncludes: ['operating income'] },
{ key: 'operating_margin', label: 'Operating Margin', category: 'margin', order: 100, unit: 'percent', labelIncludes: ['operating margin'] },
{ key: 'interest_income', label: 'Interest Income', category: 'non_operating', order: 110, unit: 'currency', localNames: ['InterestIncomeExpenseNonoperatingNet', 'InterestIncomeOther', 'InvestmentIncomeInterest'], labelIncludes: ['interest income'] },
{ key: 'interest_expense', label: 'Interest Expense', category: 'non_operating', order: 120, unit: 'currency', localNames: ['InterestExpense', 'InterestAndDebtExpense'], labelIncludes: ['interest expense'] },
{ key: 'other_non_operating_income', label: 'Other Non-Operating Income', category: 'non_operating', order: 130, unit: 'currency', localNames: ['OtherNonoperatingIncomeExpense', 'NonoperatingIncomeExpense'], labelIncludes: ['other non-operating', 'non-operating income'] },
{ key: 'pretax_income', label: 'Pretax Income', category: 'profit', order: 140, unit: 'currency', localNames: ['IncomeBeforeTaxExpenseBenefit', 'PretaxIncome'], labelIncludes: ['income before taxes', 'pretax income'] },
{ key: 'income_tax_expense', label: 'Income Tax Expense', category: 'tax', order: 150, unit: 'currency', localNames: ['IncomeTaxExpenseBenefit'], labelIncludes: ['income tax'] },
{ key: 'effective_tax_rate', label: 'Effective Tax Rate', category: 'tax', order: 160, unit: 'percent', labelIncludes: ['effective tax rate'] },
{ key: 'net_income', label: 'Net Income', category: 'profit', order: 170, unit: 'currency', localNames: ['NetIncomeLoss', 'ProfitLoss'], labelIncludes: ['net income'] },
{ key: 'diluted_eps', label: 'Diluted EPS', category: 'per_share', order: 180, unit: 'currency', localNames: ['EarningsPerShareDiluted', 'DilutedEarningsPerShare'], labelIncludes: ['diluted eps', 'diluted earnings per share'] },
{ key: 'basic_eps', label: 'Basic EPS', category: 'per_share', order: 190, unit: 'currency', localNames: ['EarningsPerShareBasic', 'BasicEarningsPerShare'], labelIncludes: ['basic eps', 'basic earnings per share'] },
{ key: 'diluted_shares', label: 'Diluted Shares', category: 'shares', order: 200, unit: 'shares', localNames: ['WeightedAverageNumberOfDilutedSharesOutstanding', 'WeightedAverageNumberOfShareOutstandingDiluted'], labelIncludes: ['diluted shares', 'weighted average diluted'] },
{ key: 'basic_shares', label: 'Basic Shares', category: 'shares', order: 210, unit: 'shares', localNames: ['WeightedAverageNumberOfSharesOutstandingBasic', 'WeightedAverageNumberOfShareOutstandingBasicAndDiluted'], labelIncludes: ['basic shares', 'weighted average basic'] },
{ key: 'depreciation_and_amortization', label: 'Depreciation & Amortization', category: 'non_cash', order: 220, unit: 'currency', localNames: ['DepreciationDepletionAndAmortization', 'DepreciationAmortizationAndAccretionNet', 'DepreciationAndAmortization'], labelIncludes: ['depreciation', 'amortization'] },
{ key: 'ebitda', label: 'EBITDA', category: 'profit', order: 230, unit: 'currency', localNames: ['OperatingIncomeLoss'], labelIncludes: ['ebitda'] },
{ key: 'stock_based_compensation', label: 'Stock-Based Compensation', category: 'non_cash', order: 240, unit: 'currency', localNames: ['ShareBasedCompensation', 'AllocatedShareBasedCompensationExpense'], labelIncludes: ['stock-based compensation', 'share-based compensation'] }
{ key: 'sales_and_marketing', label: 'Sales & Marketing', category: 'opex', order: 100, unit: 'currency', localNames: ['SellingAndMarketingExpense'], labelIncludes: ['sales and marketing', 'selling and marketing'] },
{ key: 'general_and_administrative', label: 'General & Administrative', category: 'opex', order: 110, unit: 'currency', localNames: ['GeneralAndAdministrativeExpense'], labelIncludes: ['general and administrative'] },
{ key: 'operating_margin', label: 'Operating Margin', category: 'margin', order: 120, unit: 'percent', labelIncludes: ['operating margin'] },
{ key: 'interest_income', label: 'Interest Income', category: 'non_operating', order: 130, unit: 'currency', localNames: ['InterestIncomeExpenseNonoperatingNet', 'InterestIncomeOther', 'InvestmentIncomeInterest'], labelIncludes: ['interest income'] },
{ key: 'interest_expense', label: 'Interest Expense', category: 'non_operating', order: 140, unit: 'currency', localNames: ['InterestExpense', 'InterestAndDebtExpense'], labelIncludes: ['interest expense'] },
{ key: 'other_non_operating_income', label: 'Other Non-Operating Income', category: 'non_operating', order: 150, unit: 'currency', localNames: ['OtherNonoperatingIncomeExpense', 'NonoperatingIncomeExpense'], labelIncludes: ['other non-operating', 'non-operating income'] },
{ key: 'pretax_income', label: 'Pretax Income', category: 'profit', order: 160, unit: 'currency', localNames: ['IncomeBeforeTaxExpenseBenefit', 'PretaxIncome'], labelIncludes: ['income before taxes', 'pretax income'] },
{ key: 'income_tax_expense', label: 'Income Tax Expense', category: 'tax', order: 170, unit: 'currency', localNames: ['IncomeTaxExpenseBenefit'], labelIncludes: ['income tax'] },
{ key: 'effective_tax_rate', label: 'Effective Tax Rate', category: 'tax', order: 180, unit: 'percent', labelIncludes: ['effective tax rate'] },
{ key: 'net_income', label: 'Net Income', category: 'profit', order: 190, unit: 'currency', localNames: ['NetIncomeLoss', 'ProfitLoss'], labelIncludes: ['net income'] },
{ key: 'diluted_eps', label: 'Diluted EPS', category: 'per_share', order: 200, unit: 'currency', localNames: ['EarningsPerShareDiluted', 'DilutedEarningsPerShare'], labelIncludes: ['diluted eps', 'diluted earnings per share'] },
{ key: 'basic_eps', label: 'Basic EPS', category: 'per_share', order: 210, unit: 'currency', localNames: ['EarningsPerShareBasic', 'BasicEarningsPerShare'], labelIncludes: ['basic eps', 'basic earnings per share'] },
{ key: 'diluted_shares', label: 'Diluted Shares', category: 'shares', order: 220, unit: 'shares', localNames: ['WeightedAverageNumberOfDilutedSharesOutstanding', 'WeightedAverageNumberOfShareOutstandingDiluted'], labelIncludes: ['diluted shares', 'weighted average diluted'] },
{ key: 'basic_shares', label: 'Basic Shares', category: 'shares', order: 230, unit: 'shares', localNames: ['WeightedAverageNumberOfSharesOutstandingBasic', 'WeightedAverageNumberOfShareOutstandingBasicAndDiluted'], labelIncludes: ['basic shares', 'weighted average basic'] },
{ key: 'depreciation_and_amortization', label: 'Depreciation & Amortization', category: 'non_cash', order: 240, unit: 'currency', localNames: ['DepreciationDepletionAndAmortization', 'DepreciationAmortizationAndAccretionNet', 'DepreciationAndAmortization'], labelIncludes: ['depreciation', 'amortization'] },
{ key: 'ebitda', label: 'EBITDA', category: 'profit', order: 250, unit: 'currency', localNames: ['OperatingIncomeLoss'], labelIncludes: ['ebitda'] },
{ key: 'stock_based_compensation', label: 'Stock-Based Compensation', category: 'non_cash', order: 260, unit: 'currency', localNames: ['ShareBasedCompensation', 'AllocatedShareBasedCompensationExpense'], labelIncludes: ['stock-based compensation', 'share-based compensation'] }
] as const satisfies StatementMetricDefinition[];
export const BALANCE_SHEET_METRIC_DEFINITIONS: StatementMetricDefinition[] = [

View File

@@ -0,0 +1,213 @@
import { describe, expect, it } from 'bun:test';
import {
buildStatementTree,
resolveStatementSelection
} from '@/lib/financials/statement-view-model';
import type { DetailFinancialRow, SurfaceFinancialRow } from '@/lib/types';
function createSurfaceRow(input: Partial<SurfaceFinancialRow> & Pick<SurfaceFinancialRow, 'key' | 'label' | 'values'>): SurfaceFinancialRow {
return {
key: input.key,
label: input.label,
category: input.category ?? 'revenue',
order: input.order ?? 10,
unit: input.unit ?? 'currency',
values: input.values,
sourceConcepts: input.sourceConcepts ?? [input.key],
sourceRowKeys: input.sourceRowKeys ?? [input.key],
sourceFactIds: input.sourceFactIds ?? [1],
formulaKey: input.formulaKey ?? null,
hasDimensions: input.hasDimensions ?? false,
resolvedSourceRowKeys: input.resolvedSourceRowKeys ?? Object.fromEntries(Object.keys(input.values).map((periodId) => [periodId, input.key])),
statement: input.statement ?? 'income',
detailCount: input.detailCount,
resolutionMethod: input.resolutionMethod,
confidence: input.confidence,
warningCodes: input.warningCodes
};
}
function createDetailRow(input: Partial<DetailFinancialRow> & Pick<DetailFinancialRow, 'key' | 'label' | 'parentSurfaceKey' | 'values'>): DetailFinancialRow {
return {
key: input.key,
parentSurfaceKey: input.parentSurfaceKey,
label: input.label,
conceptKey: input.conceptKey ?? input.key,
qname: input.qname ?? `us-gaap:${input.key}`,
namespaceUri: input.namespaceUri ?? 'http://fasb.org/us-gaap/2024',
localName: input.localName ?? input.key,
unit: input.unit ?? 'USD',
values: input.values,
sourceFactIds: input.sourceFactIds ?? [100],
isExtension: input.isExtension ?? false,
dimensionsSummary: input.dimensionsSummary ?? [],
residualFlag: input.residualFlag ?? false
};
}
describe('statement view model', () => {
const categories = [{ key: 'opex', label: 'Operating Expenses', count: 4 }];
it('builds a root-only tree when there are no configured children or details', () => {
const model = buildStatementTree({
surfaceKind: 'income_statement',
rows: [
createSurfaceRow({ key: 'revenue', label: 'Revenue', category: 'revenue', values: { p1: 100 } })
],
statementDetails: null,
categories: [],
searchQuery: '',
expandedRowKeys: new Set()
});
expect(model.sections).toHaveLength(1);
expect(model.sections[0]?.nodes[0]).toMatchObject({
kind: 'surface',
row: { key: 'revenue' },
expandable: false
});
});
it('nests the operating expense child surfaces under the parent row', () => {
const model = buildStatementTree({
surfaceKind: 'income_statement',
rows: [
createSurfaceRow({ key: 'operating_expenses', label: 'Operating Expenses', category: 'opex', order: 10, values: { p1: 40 } }),
createSurfaceRow({ key: 'selling_general_and_administrative', label: 'SG&A', category: 'opex', order: 20, values: { p1: 20 } }),
createSurfaceRow({ key: 'research_and_development', label: 'Research Expense', category: 'opex', order: 30, values: { p1: 12 } }),
createSurfaceRow({ key: 'other_operating_expense', label: 'Other Expense', category: 'opex', order: 40, values: { p1: 8 } })
],
statementDetails: null,
categories,
searchQuery: '',
expandedRowKeys: new Set(['operating_expenses'])
});
const parent = model.sections[0]?.nodes[0];
expect(parent?.kind).toBe('surface');
const childKeys = parent?.kind === 'surface'
? parent.children.map((node) => node.row.key)
: [];
expect(childKeys).toEqual([
'selling_general_and_administrative',
'research_and_development',
'other_operating_expense'
]);
});
it('nests raw detail rows under the matching child surface row', () => {
const model = buildStatementTree({
surfaceKind: 'income_statement',
rows: [
createSurfaceRow({ key: 'operating_expenses', label: 'Operating Expenses', category: 'opex', order: 10, values: { p1: 40 } }),
createSurfaceRow({ key: 'selling_general_and_administrative', label: 'SG&A', category: 'opex', order: 20, values: { p1: 20 } })
],
statementDetails: {
selling_general_and_administrative: [
createDetailRow({ key: 'corporate_sga', label: 'Corporate SG&A', parentSurfaceKey: 'selling_general_and_administrative', values: { p1: 20 } })
]
},
categories,
searchQuery: '',
expandedRowKeys: new Set(['operating_expenses', 'selling_general_and_administrative'])
});
const child = model.sections[0]?.nodes[0];
expect(child?.kind).toBe('surface');
const sgaNode = child?.kind === 'surface' ? child.children[0] : null;
expect(sgaNode?.kind).toBe('surface');
const detailNode = sgaNode?.kind === 'surface' ? sgaNode.children[0] : null;
expect(detailNode).toMatchObject({
kind: 'detail',
row: { key: 'corporate_sga' }
});
});
it('auto-expands the parent chain when search matches a child surface or detail row', () => {
const rows = [
createSurfaceRow({ key: 'operating_expenses', label: 'Operating Expenses', category: 'opex', order: 10, values: { p1: 40 } }),
createSurfaceRow({ key: 'research_and_development', label: 'Research Expense', category: 'opex', order: 20, values: { p1: 12 } })
];
const childSearch = buildStatementTree({
surfaceKind: 'income_statement',
rows,
statementDetails: null,
categories,
searchQuery: 'research',
expandedRowKeys: new Set()
});
expect(childSearch.autoExpandedKeys.has('operating_expenses')).toBe(true);
expect(childSearch.sections[0]?.nodes[0]?.kind === 'surface' && childSearch.sections[0]?.nodes[0].expanded).toBe(true);
const detailSearch = buildStatementTree({
surfaceKind: 'income_statement',
rows,
statementDetails: {
research_and_development: [
createDetailRow({ key: 'ai_lab_expense', label: 'AI Lab Expense', parentSurfaceKey: 'research_and_development', values: { p1: 12 } })
]
},
categories,
searchQuery: 'ai lab',
expandedRowKeys: new Set()
});
const parent = detailSearch.sections[0]?.nodes[0];
const child = parent?.kind === 'surface' ? parent.children[0] : null;
expect(parent?.kind === 'surface' && parent.expanded).toBe(true);
expect(child?.kind === 'surface' && child.expanded).toBe(true);
});
it('keeps not meaningful rows visible and resolves selections for surface and detail nodes', () => {
const rows = [
createSurfaceRow({
key: 'gross_profit',
label: 'Gross Profit',
category: 'profit',
values: { p1: null },
resolutionMethod: 'not_meaningful',
warningCodes: ['gross_profit_not_meaningful_bank_pack']
})
];
const details = {
gross_profit: [
createDetailRow({ key: 'gp_unmapped', label: 'Gross Profit residual', parentSurfaceKey: 'gross_profit', values: { p1: null }, residualFlag: true })
]
};
const model = buildStatementTree({
surfaceKind: 'income_statement',
rows,
statementDetails: details,
categories: [],
searchQuery: '',
expandedRowKeys: new Set(['gross_profit'])
});
expect(model.sections[0]?.nodes[0]).toMatchObject({
kind: 'surface',
row: { key: 'gross_profit', resolutionMethod: 'not_meaningful' }
});
const surfaceSelection = resolveStatementSelection({
surfaceKind: 'income_statement',
rows,
statementDetails: details,
selection: { kind: 'surface', key: 'gross_profit' }
});
expect(surfaceSelection?.kind).toBe('surface');
const detailSelection = resolveStatementSelection({
surfaceKind: 'income_statement',
rows,
statementDetails: details,
selection: { kind: 'detail', key: 'gp_unmapped', parentKey: 'gross_profit' }
});
expect(detailSelection).toMatchObject({
kind: 'detail',
row: { key: 'gp_unmapped', residualFlag: true }
});
});
});

View File

@@ -0,0 +1,314 @@
import type {
DetailFinancialRow,
FinancialCategory,
FinancialSurfaceKind,
SurfaceDetailMap,
SurfaceFinancialRow
} from '@/lib/types';
const SURFACE_CHILDREN: Partial<Record<Extract<FinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>, Record<string, string[]>>> = {
income_statement: {
operating_expenses: [
'selling_general_and_administrative',
'research_and_development',
'other_operating_expense'
]
}
};
export type StatementInspectorSelection = {
kind: 'surface' | 'detail';
key: string;
parentKey?: string;
};
export type StatementTreeDetailNode = {
kind: 'detail';
id: string;
level: number;
row: DetailFinancialRow;
parentSurfaceKey: string;
matchesSearch: boolean;
};
export type StatementTreeSurfaceNode = {
kind: 'surface';
id: string;
level: number;
row: SurfaceFinancialRow;
childSurfaceKeys: string[];
directDetailCount: number;
children: StatementTreeNode[];
expandable: boolean;
expanded: boolean;
autoExpanded: boolean;
matchesSearch: boolean;
};
export type StatementTreeNode = StatementTreeSurfaceNode | StatementTreeDetailNode;
export type StatementTreeSection = {
key: string;
label: string | null;
nodes: StatementTreeNode[];
};
export type StatementTreeModel = {
sections: StatementTreeSection[];
autoExpandedKeys: Set<string>;
visibleNodeCount: number;
totalNodeCount: number;
};
export type ResolvedStatementSelection =
| {
kind: 'surface';
row: SurfaceFinancialRow;
childSurfaceRows: SurfaceFinancialRow[];
detailRows: DetailFinancialRow[];
}
| {
kind: 'detail';
row: DetailFinancialRow;
parentSurfaceRow: SurfaceFinancialRow | null;
};
type Categories = Array<{
key: FinancialCategory;
label: string;
count: number;
}>;
function surfaceConfigForKind(surfaceKind: Extract<FinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>) {
return SURFACE_CHILDREN[surfaceKind] ?? {};
}
function detailNodeId(parentKey: string, row: DetailFinancialRow) {
return `detail:${parentKey}:${row.key}`;
}
function normalize(value: string) {
return value.trim().toLowerCase();
}
function searchTextForSurface(row: SurfaceFinancialRow) {
return [
row.label,
row.key,
...row.sourceConcepts,
...row.sourceRowKeys,
...(row.warningCodes ?? [])
]
.join(' ')
.toLowerCase();
}
function searchTextForDetail(row: DetailFinancialRow) {
return [
row.label,
row.key,
row.parentSurfaceKey,
row.conceptKey,
row.qname,
row.localName,
...row.dimensionsSummary
]
.join(' ')
.toLowerCase();
}
function sortSurfaceRows(left: SurfaceFinancialRow, right: SurfaceFinancialRow) {
if (left.order !== right.order) {
return left.order - right.order;
}
return left.label.localeCompare(right.label);
}
function sortDetailRows(left: DetailFinancialRow, right: DetailFinancialRow) {
return left.label.localeCompare(right.label);
}
function countNodes(nodes: StatementTreeNode[]) {
let count = 0;
for (const node of nodes) {
count += 1;
if (node.kind === 'surface') {
count += countNodes(node.children);
}
}
return count;
}
export function buildStatementTree(input: {
surfaceKind: Extract<FinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>;
rows: SurfaceFinancialRow[];
statementDetails: SurfaceDetailMap | null;
categories: Categories;
searchQuery: string;
expandedRowKeys: Set<string>;
}): StatementTreeModel {
const config = surfaceConfigForKind(input.surfaceKind);
const rowByKey = new Map(input.rows.map((row) => [row.key, row]));
const childKeySet = new Set<string>();
for (const children of Object.values(config)) {
for (const childKey of children) {
if (rowByKey.has(childKey)) {
childKeySet.add(childKey);
}
}
}
const normalizedSearch = normalize(input.searchQuery);
const autoExpandedKeys = new Set<string>();
const buildSurfaceNode = (row: SurfaceFinancialRow, level: number): StatementTreeSurfaceNode | null => {
const childSurfaceRows = (config[row.key] ?? [])
.map((key) => rowByKey.get(key))
.filter((candidate): candidate is SurfaceFinancialRow => Boolean(candidate))
.sort(sortSurfaceRows);
const detailRows = [...(input.statementDetails?.[row.key] ?? [])].sort(sortDetailRows);
const childSurfaceNodes = childSurfaceRows
.map((childRow) => buildSurfaceNode(childRow, level + 1))
.filter((node): node is StatementTreeSurfaceNode => Boolean(node));
const detailNodes = detailRows
.filter((detail) => normalizedSearch.length === 0 || searchTextForDetail(detail).includes(normalizedSearch))
.map((detail) => ({
kind: 'detail',
id: detailNodeId(row.key, detail),
level: level + 1,
row: detail,
parentSurfaceKey: row.key,
matchesSearch: normalizedSearch.length > 0 && searchTextForDetail(detail).includes(normalizedSearch)
}) satisfies StatementTreeDetailNode);
const children = [...childSurfaceNodes, ...detailNodes];
const matchesSearch = normalizedSearch.length > 0 && searchTextForSurface(row).includes(normalizedSearch);
const hasMatchingDescendant = normalizedSearch.length > 0 && children.length > 0;
if (normalizedSearch.length > 0 && !matchesSearch && !hasMatchingDescendant) {
return null;
}
const childSurfaceKeys = childSurfaceRows.map((candidate) => candidate.key);
const directDetailCount = detailRows.length;
const autoExpanded = normalizedSearch.length > 0 && !matchesSearch && children.length > 0;
const expanded = children.length > 0 && (input.expandedRowKeys.has(row.key) || autoExpanded);
if (autoExpanded) {
autoExpandedKeys.add(row.key);
}
return {
kind: 'surface',
id: row.key,
level,
row,
childSurfaceKeys,
directDetailCount,
children,
expandable: children.length > 0,
expanded,
autoExpanded,
matchesSearch
};
};
const rootNodes = input.rows
.filter((row) => !childKeySet.has(row.key))
.sort(sortSurfaceRows)
.map((row) => buildSurfaceNode(row, 0))
.filter((node): node is StatementTreeSurfaceNode => Boolean(node));
if (input.categories.length === 0) {
return {
sections: [{ key: 'ungrouped', label: null, nodes: rootNodes }],
autoExpandedKeys,
visibleNodeCount: countNodes(rootNodes),
totalNodeCount: input.rows.length + Object.values(input.statementDetails ?? {}).reduce((sum, rows) => sum + rows.length, 0)
};
}
const sections: StatementTreeSection[] = [];
const categoriesByKey = new Map(input.categories.map((category) => [category.key, category.label]));
for (const category of input.categories) {
const nodes = rootNodes.filter((node) => node.row.category === category.key);
if (nodes.length > 0) {
sections.push({
key: category.key,
label: category.label,
nodes
});
}
}
const uncategorized = rootNodes.filter((node) => !categoriesByKey.has(node.row.category));
if (uncategorized.length > 0) {
sections.push({
key: 'uncategorized',
label: null,
nodes: uncategorized
});
}
return {
sections,
autoExpandedKeys,
visibleNodeCount: sections.reduce((sum, section) => sum + countNodes(section.nodes), 0),
totalNodeCount: input.rows.length + Object.values(input.statementDetails ?? {}).reduce((sum, rows) => sum + rows.length, 0)
};
}
export function resolveStatementSelection(input: {
surfaceKind: Extract<FinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>;
rows: SurfaceFinancialRow[];
statementDetails: SurfaceDetailMap | null;
selection: StatementInspectorSelection | null;
}): ResolvedStatementSelection | null {
const selection = input.selection;
if (!selection) {
return null;
}
const rowByKey = new Map(input.rows.map((row) => [row.key, row]));
const config = surfaceConfigForKind(input.surfaceKind);
if (selection.kind === 'surface') {
const row = rowByKey.get(selection.key);
if (!row) {
return null;
}
const childSurfaceRows = (config[row.key] ?? [])
.map((key) => rowByKey.get(key))
.filter((candidate): candidate is SurfaceFinancialRow => Boolean(candidate))
.sort(sortSurfaceRows);
return {
kind: 'surface',
row,
childSurfaceRows,
detailRows: [...(input.statementDetails?.[row.key] ?? [])].sort(sortDetailRows)
};
}
const parentSurfaceKey = selection.parentKey ?? null;
const detailRows = parentSurfaceKey
? input.statementDetails?.[parentSurfaceKey] ?? []
: Object.values(input.statementDetails ?? {}).flat();
const row = detailRows.find((candidate) => candidate.key === selection.key) ?? null;
if (!row) {
return null;
}
return {
kind: 'detail',
row,
parentSurfaceRow: rowByKey.get(row.parentSurfaceKey) ?? null
};
}

View File

@@ -36,6 +36,15 @@ describe('graphing catalog', () => {
expect(quarterlyMetricKeys).toContain('gross_margin');
});
it('includes other operating expense in the income statement metric catalog', () => {
const metricKeys = metricsForSurfaceAndCadence('income_statement', 'annual').map((metric) => metric.key);
expect(metricKeys).toContain('operating_expenses');
expect(metricKeys).toContain('selling_general_and_administrative');
expect(metricKeys).toContain('research_and_development');
expect(metricKeys).toContain('other_operating_expense');
});
it('replaces invalid metrics after surface and cadence normalization', () => {
const state = parseGraphingParams(new URLSearchParams('surface=ratios&cadence=quarterly&metric=5y_revenue_cagr&tickers=msft,aapl'));

View File

@@ -4,7 +4,7 @@ import type {
CompanyFinancialStatementsResponse,
FinancialStatementPeriod,
RatioRow,
StandardizedFinancialRow
SurfaceFinancialRow
} from '@/lib/types';
function createPeriod(input: {
@@ -26,7 +26,7 @@ function createPeriod(input: {
} satisfies FinancialStatementPeriod;
}
function createStatementRow(key: string, values: Record<string, number | null>, unit: StandardizedFinancialRow['unit'] = 'currency') {
function createStatementRow(key: string, values: Record<string, number | null>, unit: SurfaceFinancialRow['unit'] = 'currency') {
return {
key,
label: key,
@@ -39,8 +39,9 @@ function createStatementRow(key: string, values: Record<string, number | null>,
sourceFactIds: [1],
formulaKey: null,
hasDimensions: false,
resolvedSourceRowKeys: Object.fromEntries(Object.keys(values).map((periodId) => [periodId, key]))
} satisfies StandardizedFinancialRow;
resolvedSourceRowKeys: Object.fromEntries(Object.keys(values).map((periodId) => [periodId, key])),
statement: 'income'
} satisfies SurfaceFinancialRow;
}
function createRatioRow(key: string, values: Record<string, number | null>, unit: RatioRow['unit'] = 'percent') {
@@ -54,8 +55,9 @@ function createFinancials(input: {
ticker: string;
companyName: string;
periods: FinancialStatementPeriod[];
statementRows?: StandardizedFinancialRow[];
statementRows?: SurfaceFinancialRow[];
ratioRows?: RatioRow[];
fiscalPack?: string | null;
}) {
return {
company: {
@@ -72,6 +74,7 @@ function createFinancials(input: {
faithful: [],
standardized: input.statementRows ?? []
},
statementDetails: null,
ratioRows: input.ratioRows ?? [],
kpiRows: null,
trendSeries: [],
@@ -100,6 +103,13 @@ function createFinancials(input: {
taxonomy: null,
validation: null
},
normalization: {
regime: 'unknown',
fiscalPack: input.fiscalPack ?? null,
parserVersion: '0.0.0',
unmappedRowCount: 0,
materialUnmappedRowCount: 0
},
dimensionBreakdown: null
} satisfies CompanyFinancialStatementsResponse;
}
@@ -194,6 +204,37 @@ describe('graphing series', () => {
expect(data.hasAnyData).toBe(false);
});
it('marks not meaningful standardized rows separately from missing metric data', () => {
const data = buildGraphingComparisonData({
surface: 'income_statement',
metric: 'gross_profit',
results: [
{
ticker: 'JPM',
financials: createFinancials({
ticker: 'JPM',
companyName: 'JPMorgan Chase & Co.',
fiscalPack: 'bank_lender',
periods: [createPeriod({ id: 'jpm-fy', filingId: 1, filingDate: '2026-02-13', periodEnd: '2025-12-31', filingType: '10-K' })],
statementRows: [{
...createStatementRow('gross_profit', { 'jpm-fy': null }),
resolutionMethod: 'not_meaningful'
}]
})
}
]
});
expect(data.latestRows[0]).toMatchObject({
ticker: 'JPM',
fiscalPack: 'bank_lender',
status: 'not_meaningful',
errorMessage: 'Not meaningful for this pack.'
});
expect(data.hasAnyData).toBe(false);
expect(data.hasPartialData).toBe(true);
});
it('derives latest and prior values for the summary table', () => {
const data = buildGraphingComparisonData({
surface: 'income_statement',
@@ -216,6 +257,7 @@ describe('graphing series', () => {
expect(data.latestRows[0]).toMatchObject({
ticker: 'AMD',
fiscalPack: null,
latestValue: 70,
priorValue: 50,
changeValue: 20,

View File

@@ -27,7 +27,8 @@ export type GraphingSeriesPoint = {
export type GraphingCompanySeries = {
ticker: string;
companyName: string;
status: 'ready' | 'error' | 'no_metric_data';
fiscalPack: string | null;
status: 'ready' | 'error' | 'no_metric_data' | 'not_meaningful';
errorMessage: string | null;
unit: FinancialUnit | null;
points: GraphingSeriesPoint[];
@@ -38,6 +39,7 @@ export type GraphingCompanySeries = {
export type GraphingLatestValueRow = {
ticker: string;
companyName: string;
fiscalPack: string | null;
status: GraphingCompanySeries['status'];
errorMessage: string | null;
latestValue: number | null;
@@ -86,6 +88,7 @@ function extractCompanySeries(
return {
ticker: result.ticker,
companyName: result.ticker,
fiscalPack: null,
status: 'error',
errorMessage: result.error ?? 'Unable to load financial history',
unit: null,
@@ -97,6 +100,10 @@ function extractCompanySeries(
const metricRow = extractMetricRow(result.financials, surface, metric);
const periods = [...result.financials.periods].sort(sortPeriods);
const notMeaningful = surface !== 'ratios'
&& metricRow
&& 'resolutionMethod' in metricRow
&& metricRow.resolutionMethod === 'not_meaningful';
const points = periods.map((period) => ({
periodId: period.id,
dateKey: period.periodEnd ?? period.filingDate,
@@ -113,8 +120,9 @@ function extractCompanySeries(
return {
ticker: result.financials.company.ticker,
companyName: result.financials.company.companyName,
status: latestPoint ? 'ready' : 'no_metric_data',
errorMessage: latestPoint ? null : 'No data available for the selected metric.',
fiscalPack: result.financials.normalization.fiscalPack,
status: notMeaningful ? 'not_meaningful' : latestPoint ? 'ready' : 'no_metric_data',
errorMessage: notMeaningful ? 'Not meaningful for this pack.' : latestPoint ? null : 'No data available for the selected metric.',
unit: metricRow?.unit ?? null,
points,
latestPoint,
@@ -159,6 +167,7 @@ export function buildGraphingComparisonData(input: {
const latestRows = companies.map((company) => ({
ticker: company.ticker,
companyName: company.companyName,
fiscalPack: company.fiscalPack,
status: company.status,
errorMessage: company.errorMessage,
latestValue: company.latestPoint?.value ?? null,