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

@@ -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
};
}