feat(financials): add compact surface UI and graphing states
This commit is contained in:
@@ -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[] = [
|
||||
|
||||
213
lib/financials/statement-view-model.test.ts
Normal file
213
lib/financials/statement-view-model.test.ts
Normal 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 }
|
||||
});
|
||||
});
|
||||
});
|
||||
314
lib/financials/statement-view-model.ts
Normal file
314
lib/financials/statement-view-model.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user