From 33ce48f53cb3de73ed2efc9a15d776393d7256fa Mon Sep 17 00:00:00 2001 From: francy51 Date: Thu, 12 Mar 2026 15:25:21 -0400 Subject: [PATCH] feat(financials): add compact surface UI and graphing states --- app/financials/page.tsx | 588 +++++++++++++----- app/graphing/page.tsx | 30 +- .../financials/normalization-summary.tsx | 58 ++ components/financials/statement-matrix.tsx | 208 +++++++ .../financials/statement-row-inspector.tsx | 174 ++++++ e2e/financials.spec.ts | 346 +++++++++++ e2e/graphing.spec.ts | 91 ++- lib/financial-metrics.ts | 40 +- lib/financials/statement-view-model.test.ts | 213 +++++++ lib/financials/statement-view-model.ts | 314 ++++++++++ lib/graphing/catalog.test.ts | 9 + lib/graphing/series.test.ts | 52 +- lib/graphing/series.ts | 15 +- 13 files changed, 1941 insertions(+), 197 deletions(-) create mode 100644 components/financials/normalization-summary.tsx create mode 100644 components/financials/statement-matrix.tsx create mode 100644 components/financials/statement-row-inspector.tsx create mode 100644 e2e/financials.spec.ts create mode 100644 lib/financials/statement-view-model.test.ts create mode 100644 lib/financials/statement-view-model.ts diff --git a/app/financials/page.tsx b/app/financials/page.tsx index ecf14be..68e83d6 100644 --- a/app/financials/page.tsx +++ b/app/financials/page.tsx @@ -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; +}) { + 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(null); - const [selectedRowKey, setSelectedRowKey] = useState(null); + const [selectedFlatRowKey, setSelectedFlatRowKey] = useState(null); + const [selectedRowRef, setSelectedRowRef] = useState(null); + const [expandedRowKeys, setExpandedRowKeys] = useState>(() => 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(() => { + const isTreeStatementMode = displayMode === 'standardized' && isStatementSurfaceKind(surfaceKind); + + const activeRows = useMemo(() => { 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(() => { + const commonSizeRow = useMemo(() => { 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
Loading financial terminal...
; } @@ -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" /> - {filteredRows.length} of {activeRows.length} rows + {rowResultCountLabel} @@ -788,6 +1025,10 @@ function FinancialsPageContent() { )} + {financials && isTreeStatementMode ? ( + + ) : null} + {loading ? (

Loading financial matrix...

@@ -796,158 +1037,87 @@ function FinancialsPageContent() {

This surface is not yet available in v1.

Adjusted and custom metrics are API-visible placeholders only. No edits or derived rows are available yet.

- ) : periods.length === 0 || filteredRows.length === 0 ? ( + ) : periods.length === 0 || (isTreeStatementMode ? (treeModel?.visibleNodeCount ?? 0) === 0 : filteredRows.length === 0) ? (

No rows available for the selected filters yet.

) : (
{isStatementSurfaceKind(surfaceKind) ? (

- USD · {FINANCIAL_VALUE_SCALE_OPTIONS.find((option) => option.value === valueScale)?.label ?? valueScale} + USD · {valueScaleLabel}

) : null} -
- - - - - {periods.map((period) => ( - - ))} - - - - {groupedRows.map((group) => ( - - {group.label ? ( - - - - ) : null} - {group.rows.map((row) => ( - setSelectedRowKey(row.key)} - > - - {periods.map((period, index) => ( - - ))} - - ))} - - ))} - -
Metric -
- {formatLongDate(period.periodEnd ?? period.filingDate)} - {period.filingType} · {period.periodLabel} -
-
{group.label}
-
-
- {row.label} - {'hasDimensions' in row && row.hasDimensions ? : null} -
- {isDerivedRow(row) && row.formulaKey ? ( - Formula: {row.formulaKey} - ) : null} - {isKpiRow(row) ? ( - Provenance: {row.provenanceType} - ) : null} -
-
- {buildDisplayValue({ - row, - periodId: period.id, - previousPeriodId: index > 0 ? periods[index - 1]?.id ?? null : null, - commonSizeRow, - displayMode, - showPercentChange, - showCommonSize, - scale: valueScale, - surfaceKind - })} -
-
-
- )} -
- - - {!selectedRow ? ( -

Select a row to inspect details.

- ) : ( -
-
-
-

Label

-

{selectedRow.label}

-
-
-

Key

-

{selectedRow.key}

-
-
- - {isTaxonomyRow(selectedRow) ? ( -
-

Taxonomy Concept

-

{selectedRow.qname}

-
- ) : ( -
-
-

Category

-

{selectedRow.category}

-
-
-

Unit

-

{selectedRow.unit}

-
-
- )} - - {isDerivedRow(selectedRow) ? ( -
-

Source Row Keys

-

{selectedRow.sourceRowKeys.join(', ') || 'n/a'}

-

Source Concepts

-

{selectedRow.sourceConcepts.join(', ') || 'n/a'}

-

Source Fact IDs

-

{selectedRow.sourceFactIds.join(', ') || 'n/a'}

-
- ) : null} - - {!selectedRow || !('hasDimensions' in selectedRow) || !selectedRow.hasDimensions ? ( -

No dimensional drill-down is available for this row.

- ) : dimensionRows.length === 0 ? ( -

No dimensional facts were returned for the selected row.

+ {isTreeStatementMode && treeModel ? ( + ) : (
- +
- - - - + + {periods.map((period) => ( + + ))} - {dimensionRows.map((row, index) => ( - - - - - - + {groupedRows.map((group) => ( + + {group.label ? ( + + + + ) : null} + {group.rows.map((row) => ( + setSelectedFlatRowKey(row.key)} + > + + {periods.map((period, index) => ( + + ))} + + ))} + ))}
PeriodAxisMemberValueMetric +
+ {formatLongDate(period.periodEnd ?? period.filingDate)} + {period.filingType} · {period.periodLabel} +
+
{periods.find((period) => period.id === row.periodId)?.periodLabel ?? row.periodId}{row.axis}{row.member}{formatMetricValue({ - value: row.value, - unit: isTaxonomyRow(selectedRow) ? 'currency' : selectedRow.unit, - scale: valueScale, - rowKey: selectedRow.key, - surfaceKind - })}
{group.label}
+
+
+ {row.label} + {'hasDimensions' in row && row.hasDimensions ? : null} +
+ {isDerivedRow(row) && row.formulaKey ? ( + Formula: {row.formulaKey} + ) : null} + {isKpiRow(row) ? ( + Provenance: {row.provenanceType} + ) : null} +
+
+ {buildDisplayValue({ + row, + periodId: period.id, + previousPeriodId: index > 0 ? periods[index - 1]?.id ?? null : null, + commonSizeRow, + displayMode, + showPercentChange, + showCommonSize, + scale: valueScale, + surfaceKind + })} +
@@ -957,6 +1127,100 @@ function FinancialsPageContent() { )} + {isTreeStatementMode && isStatementSurfaceKind(surfaceKind) ? ( + + ) : ( + + {!selectedRow ? ( +

Select a row to inspect details.

+ ) : ( +
+
+
+

Label

+

{selectedRow.label}

+
+
+

Key

+

{selectedRow.key}

+
+
+ + {isTaxonomyRow(selectedRow) ? ( +
+

Taxonomy Concept

+

{selectedRow.qname}

+
+ ) : ( +
+
+

Category

+

{selectedRow.category}

+
+
+

Unit

+

{selectedRow.unit}

+
+
+ )} + + {isDerivedRow(selectedRow) ? ( +
+

Source Row Keys

+

{selectedRow.sourceRowKeys.join(', ') || 'n/a'}

+

Source Concepts

+

{selectedRow.sourceConcepts.join(', ') || 'n/a'}

+

Source Fact IDs

+

{selectedRow.sourceFactIds.join(', ') || 'n/a'}

+
+ ) : null} + + {!selectedRow || !('hasDimensions' in selectedRow) || !selectedRow.hasDimensions ? ( +

No dimensional drill-down is available for this row.

+ ) : dimensionRows.length === 0 ? ( +

No dimensional facts were returned for the selected row.

+ ) : ( +
+ + + + + + + + + + + {dimensionRows.map((row, index) => ( + + + + + + + ))} + +
PeriodAxisMemberValue
{periods.find((period) => period.id === row.periodId)?.periodLabel ?? row.periodId}{row.axis}{row.member}{formatMetricValue({ + value: row.value, + unit: isTaxonomyRow(selectedRow) ? 'currency' : selectedRow.unit, + scale: valueScale, + rowKey: selectedRow.key, + surfaceKind + })}
+
+ )} +
+ )} +
+ )} + {(surfaceKind === 'income_statement' || surfaceKind === 'balance_sheet' || surfaceKind === 'cash_flow_statement') && financials ? (
diff --git a/app/graphing/page.tsx b/app/graphing/page.tsx index 2a7b8bb..335cab3 100644 --- a/app/graphing/page.tsx +++ b/app/graphing/page.tsx @@ -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 ? (
- Partial coverage detected. Some companies are missing values for this metric or failed to load, but the remaining series still render. + {partialCoverageMessage}
) : null}
@@ -613,6 +637,7 @@ function GraphingPageContent() { Company + Pack Latest Prior Change @@ -637,6 +662,7 @@ function GraphingPageContent() { {row.companyName}
+ {row.fiscalPack ?? 'n/a'} {selectedMetric ? formatMetricValue(row.latestValue, selectedMetric.unit, graphState.scale) : 'n/a'} {selectedMetric ? formatMetricValue(row.priorValue, selectedMetric.unit, graphState.scale) : 'n/a'} = 0 ? 'text-[color:var(--accent)]' : row.changeValue !== null ? 'text-[#ffb5b5]' : 'text-[color:var(--terminal-muted)]')}> @@ -647,6 +673,8 @@ function GraphingPageContent() { {row.status === 'ready' ? ( Ready + ) : row.status === 'not_meaningful' ? ( + Not meaningful for this pack ) : row.status === 'no_metric_data' ? ( No metric data ) : ( diff --git a/components/financials/normalization-summary.tsx b/components/financials/normalization-summary.tsx new file mode 100644 index 0000000..4acbf5f --- /dev/null +++ b/components/financials/normalization-summary.tsx @@ -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 ( +
+

{props.label}

+

{props.value}

+
+ ); +} + +export function NormalizationSummary({ normalization }: NormalizationSummaryProps) { + const hasMaterialUnmapped = normalization.materialUnmappedRowCount > 0; + + return ( + +
+ + + + + +
+ {hasMaterialUnmapped ? ( +
+ +

Material unmapped rows were detected for this filing set. Use the inspector and detail rows before relying on cross-company comparisons.

+
+ ) : null} +
+ ); +} diff --git a/components/financials/statement-matrix.tsx b/components/financials/statement-matrix.tsx new file mode 100644 index 0000000..3d983d2 --- /dev/null +++ b/components/financials/statement-matrix.tsx @@ -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 { + 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) { + 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 ( + + + +
+ {canToggle ? ( + + ) : ( + + )} + +
+ + {props.periods.map((period, index) => ( + + {props.renderCellValue(node.row, period.id, index > 0 ? props.periods[index - 1]?.id ?? null : null)} + + ))} + + {isSurfaceNode(node) && node.expanded ? ( + <> + + Expanded children for {node.row.label} + + {renderNodes({ + ...props, + nodes: node.children + })} + + ) : null} +
+ ); + }); +} + +export function StatementMatrix(props: StatementMatrixProps) { + return ( +
+ + + + + {props.periods.map((period) => ( + + ))} + + + + {props.sections.map((section) => ( + + {section.label ? ( + + + + ) : null} + {renderNodes({ + ...props, + nodes: section.nodes + })} + + ))} + +
Metric +
+ {props.periodLabelFormatter(period.periodEnd ?? period.filingDate)} + {period.filingType} · {period.periodLabel} +
+
+ {section.label} +
+
+ ); +} diff --git a/components/financials/statement-row-inspector.tsx b/components/financials/statement-row-inspector.tsx new file mode 100644 index 0000000..82b4d6b --- /dev/null +++ b/components/financials/statement-row-inspector.tsx @@ -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; + 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 ( +
+

{props.label}

+

{props.value}

+
+ ); +} + +function renderList(values: string[]) { + return values.length > 0 ? values.join(', ') : 'n/a'; +} + +export function StatementRowInspector(props: StatementRowInspectorProps) { + const selection = props.selection; + + return ( + + {!selection ? ( +

Select a compact surface row or raw detail row to inspect details.

+ ) : selection.kind === 'surface' ? ( +
+
+ + + + +
+ +
+ + +
+ +
+ 0 ? selection.row.sourceFactIds.join(', ') : 'n/a'} /> + +
+ +
+ 0 ? selection.childSurfaceRows.map((row) => row.label).join(', ') : 'None'} /> + +
+ + {selection.detailRows.length > 0 ? ( +
+

Raw Detail Labels

+
+ {selection.detailRows.map((row) => ( + + {row.label} + + ))} +
+
+ ) : null} + + {selection.row.hasDimensions ? ( + props.dimensionRows.length === 0 ? ( +

No dimensional facts were returned for the selected compact row.

+ ) : ( +
+ + + + + + + + + + + {props.dimensionRows.map((row, index) => ( + + + + + + + ))} + +
PeriodAxisMemberValue
{props.periods.find((period) => period.id === row.periodId)?.periodLabel ?? row.periodId}{row.axis}{row.member}{props.renderDimensionValue(row.value, selection.row.key, selection.row.unit)}
+
+ ) + ) : ( +

No dimensional drill-down is available for this compact surface row.

+ )} +
+ ) : ( +
+
+ + + + +
+ +
+ + +
+ +
+ + 0 ? selection.row.sourceFactIds.join(', ') : 'n/a'} /> +
+ +
+

Dimensions Summary

+

{renderList(selection.row.dimensionsSummary)}

+
+ + {props.dimensionRows.length === 0 ? ( +

No dimensional facts were returned for the selected raw detail row.

+ ) : ( +
+ + + + + + + + + + + {props.dimensionRows.map((row, index) => ( + + + + + + + ))} + +
PeriodAxisMemberValue
{props.periods.find((period) => period.id === row.periodId)?.periodLabel ?? row.periodId}{row.axis}{row.member}{props.renderDimensionValue(row.value, selection.row.key, props.surfaceKind === 'balance_sheet' ? 'currency' : 'currency')}
+
+ )} +
+ )} +
+ ); +} diff --git a/e2e/financials.spec.ts b/e2e/financials.spec.ts new file mode 100644 index 0000000..cff1321 --- /dev/null +++ b/e2e/financials.spec.ts @@ -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(); +}); diff --git a/e2e/graphing.spec.ts b/e2e/graphing.spec.ts index 8fc965b..3890f94 100644 --- a/e2e/graphing.spec.ts +++ b/e2e/graphing.spec.ts @@ -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(); }); diff --git a/lib/financial-metrics.ts b/lib/financial-metrics.ts index a82937b..26c4ad7 100644 --- a/lib/financial-metrics.ts +++ b/lib/financial-metrics.ts @@ -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[] = [ diff --git a/lib/financials/statement-view-model.test.ts b/lib/financials/statement-view-model.test.ts new file mode 100644 index 0000000..c09a2fd --- /dev/null +++ b/lib/financials/statement-view-model.test.ts @@ -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 & Pick): 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 & Pick): 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 } + }); + }); +}); diff --git a/lib/financials/statement-view-model.ts b/lib/financials/statement-view-model.ts new file mode 100644 index 0000000..a6aa96b --- /dev/null +++ b/lib/financials/statement-view-model.ts @@ -0,0 +1,314 @@ +import type { + DetailFinancialRow, + FinancialCategory, + FinancialSurfaceKind, + SurfaceDetailMap, + SurfaceFinancialRow +} from '@/lib/types'; + +const SURFACE_CHILDREN: Partial, Record>> = { + 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; + 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) { + 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; + rows: SurfaceFinancialRow[]; + statementDetails: SurfaceDetailMap | null; + categories: Categories; + searchQuery: string; + expandedRowKeys: Set; +}): StatementTreeModel { + const config = surfaceConfigForKind(input.surfaceKind); + const rowByKey = new Map(input.rows.map((row) => [row.key, row])); + const childKeySet = new Set(); + + 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(); + + 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; + 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 + }; +} diff --git a/lib/graphing/catalog.test.ts b/lib/graphing/catalog.test.ts index 7bee8ad..279f37a 100644 --- a/lib/graphing/catalog.test.ts +++ b/lib/graphing/catalog.test.ts @@ -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')); diff --git a/lib/graphing/series.test.ts b/lib/graphing/series.test.ts index 3daf00e..435538a 100644 --- a/lib/graphing/series.test.ts +++ b/lib/graphing/series.test.ts @@ -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, unit: StandardizedFinancialRow['unit'] = 'currency') { +function createStatementRow(key: string, values: Record, unit: SurfaceFinancialRow['unit'] = 'currency') { return { key, label: key, @@ -39,8 +39,9 @@ function createStatementRow(key: string, values: Record, 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, 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, diff --git a/lib/graphing/series.ts b/lib/graphing/series.ts index a1f3631..50494f4 100644 --- a/lib/graphing/series.ts +++ b/lib/graphing/series.ts @@ -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,