From fa2de3e2596e954d08e85c335eb4002e00e5f41a Mon Sep 17 00:00:00 2001 From: francy51 Date: Mon, 9 Mar 2026 18:58:15 -0400 Subject: [PATCH] Improve financial statement value formatting --- app/financials/page.tsx | 262 +++++++++++++++++++++++++++------------- lib/format.test.ts | 62 +++++++++- lib/format.ts | 70 +++++++++++ 3 files changed, 306 insertions(+), 88 deletions(-) diff --git a/app/financials/page.tsx b/app/financials/page.tsx index d5e8f4d..ecf14be 100644 --- a/app/financials/page.tsx +++ b/app/financials/page.tsx @@ -37,6 +37,7 @@ import { useLinkPrefetch } from '@/hooks/use-link-prefetch'; import { queueFilingSync } from '@/lib/api'; import { formatCurrencyByScale, + formatFinancialStatementValue, formatPercent, type NumberScaleUnit } from '@/lib/format'; @@ -121,32 +122,62 @@ function rowValue(row: DisplayRow, periodId: string) { return row.values[periodId] ?? null; } -function formatMetricValue(value: number | null, unit: FinancialUnit, scale: NumberScaleUnit) { - if (value === null) { - return 'n/a'; +function isStatementSurfaceKind(surfaceKind: FinancialSurfaceKind) { + return surfaceKind === 'income_statement' || surfaceKind === 'balance_sheet' || surfaceKind === 'cash_flow_statement'; +} + +function formatMetricValue(input: { + value: number | null; + unit: FinancialUnit; + scale: NumberScaleUnit; + rowKey?: string | null; + surfaceKind: FinancialSurfaceKind; + isPercentChange?: boolean; + isCommonSize?: boolean; +}) { + if (input.value === null) { + return isStatementSurfaceKind(input.surfaceKind) ? '—' : 'n/a'; } - switch (unit) { + if (isStatementSurfaceKind(input.surfaceKind)) { + return formatFinancialStatementValue({ + value: input.value, + unit: input.unit, + scale: input.scale, + rowKey: input.rowKey, + surfaceKind: input.surfaceKind, + isPercentChange: input.isPercentChange, + isCommonSize: input.isCommonSize + }); + } + + switch (input.unit) { case 'currency': - return formatCurrencyByScale(value, scale); + return formatCurrencyByScale(input.value, input.scale); case 'percent': - return formatPercent(value * 100); + return formatPercent(input.value * 100); case 'shares': case 'count': - return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 2 }).format(value); + return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 2 }).format(input.value); case 'ratio': - return `${value.toFixed(2)}x`; + return `${input.value.toFixed(2)}x`; default: - return String(value); + return String(input.value); } } -function chartTickFormatter(value: number, unit: FinancialUnit, scale: NumberScaleUnit) { +function chartTickFormatter( + value: number, + unit: FinancialUnit, + scale: NumberScaleUnit, + surfaceKind: FinancialSurfaceKind, + rowKey?: string | null +) { if (!Number.isFinite(value)) { - return 'n/a'; + return isStatementSurfaceKind(surfaceKind) ? '—' : 'n/a'; } - return formatMetricValue(value, unit, scale); + return formatMetricValue({ value, unit, scale, surfaceKind, rowKey }); } function buildDisplayValue(input: { @@ -165,10 +196,17 @@ function buildDisplayValue(input: { if (input.showPercentChange && input.previousPeriodId) { const previous = rowValue(input.row, input.previousPeriodId); if (current === null || previous === null || previous === 0) { - return 'n/a'; + return isStatementSurfaceKind(input.surfaceKind) ? '—' : 'n/a'; } - return formatPercent(((current - previous) / previous) * 100); + return formatMetricValue({ + value: (current - previous) / previous, + unit: 'percent', + scale: input.scale, + rowKey: input.row.key, + surfaceKind: input.surfaceKind, + isPercentChange: true + }); } if (input.showCommonSize) { @@ -177,21 +215,34 @@ function buildDisplayValue(input: { } if (input.displayMode === 'faithful') { - return 'n/a'; + return isStatementSurfaceKind(input.surfaceKind) ? '—' : 'n/a'; } const denominator = input.commonSizeRow ? rowValue(input.commonSizeRow, input.periodId) : null; if (current === null || denominator === null || denominator === 0) { - return 'n/a'; + return isStatementSurfaceKind(input.surfaceKind) ? '—' : 'n/a'; } - return formatPercent((current / denominator) * 100); + return formatMetricValue({ + value: current / denominator, + unit: 'percent', + scale: input.scale, + rowKey: input.row.key, + surfaceKind: input.surfaceKind, + isCommonSize: true + }); } const unit = isTaxonomyRow(input.row) ? 'currency' : input.row.unit; - return formatMetricValue(current, unit, input.scale); + return formatMetricValue({ + value: current, + unit, + scale: input.scale, + rowKey: input.row.key, + surfaceKind: input.surfaceKind + }); } function groupRows(rows: DisplayRow[], categories: CompanyFinancialStatementsResponse['categories']) { @@ -694,13 +745,25 @@ function FinancialsPageContent() { chartTickFormatter(value, trendSeries[0]?.unit ?? 'currency', valueScale)} + tickFormatter={(value: number) => chartTickFormatter( + value, + trendSeries[0]?.unit ?? 'currency', + valueScale, + surfaceKind, + trendSeries[0]?.key ?? null + )} /> { const numeric = Number(value); - const unit = trendSeries.find((series) => series.key === entry.dataKey)?.unit ?? 'currency'; - return formatMetricValue(Number.isFinite(numeric) ? numeric : null, unit, valueScale); + const series = trendSeries.find((candidate) => candidate.key === entry.dataKey); + return formatMetricValue({ + value: Number.isFinite(numeric) ? numeric : null, + unit: series?.unit ?? 'currency', + scale: valueScale, + rowKey: series?.key ?? null, + surfaceKind + }); }} contentStyle={{ backgroundColor: CHART_TOOLTIP_BG, @@ -736,70 +799,77 @@ function FinancialsPageContent() { ) : periods.length === 0 || filteredRows.length === 0 ? (

No rows available for the selected filters yet.

) : ( -
- - - - - {periods.map((period) => ( - - ))} - - - - {groupedRows.map((group) => ( - - {group.label ? ( - - - - ) : null} - {group.rows.map((row) => ( - setSelectedRowKey(row.key)} - > - - {periods.map((period, index) => ( - - ))} - +
+ {isStatementSurfaceKind(surfaceKind) ? ( +

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

+ ) : null} +
+
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 - })} -
+ + + + {periods.map((period) => ( + ))} - - ))} - -
Metric +
+ {formatLongDate(period.periodEnd ?? period.filingDate)} + {period.filingType} · {period.periodLabel} +
+
+ + + + {groupedRows.map((group) => ( + + {group.label ? ( + + {group.label} + + ) : null} + {group.rows.map((row) => ( + setSelectedRowKey(row.key)} + > + +
+
+ {row.label} + {'hasDimensions' in row && row.hasDimensions ? : null} +
+ {isDerivedRow(row) && row.formulaKey ? ( + Formula: {row.formulaKey} + ) : null} + {isKpiRow(row) ? ( + Provenance: {row.provenanceType} + ) : null} +
+ + {periods.map((period, index) => ( + + {buildDisplayValue({ + row, + periodId: period.id, + previousPeriodId: index > 0 ? periods[index - 1]?.id ?? null : null, + commonSizeRow, + displayMode, + showPercentChange, + showCommonSize, + scale: valueScale, + surfaceKind + })} + + ))} + + ))} +
+ ))} + + +
)} @@ -870,7 +940,13 @@ function FinancialsPageContent() { {periods.find((period) => period.id === row.periodId)?.periodLabel ?? row.periodId} {row.axis} {row.member} - {formatMetricValue(row.value, 'currency', valueScale)} + {formatMetricValue({ + value: row.value, + unit: isTaxonomyRow(selectedRow) ? 'currency' : selectedRow.unit, + scale: valueScale, + rowKey: selectedRow.key, + surfaceKind + })} ))} @@ -905,8 +981,20 @@ function FinancialsPageContent() { {financials.metrics.validation?.checks.map((check) => ( {check.metricKey} - {formatMetricValue(check.taxonomyValue, 'currency', valueScale)} - {formatMetricValue(check.llmValue, 'currency', valueScale)} + {formatMetricValue({ + value: check.taxonomyValue, + unit: 'currency', + scale: valueScale, + rowKey: check.metricKey, + surfaceKind + })} + {formatMetricValue({ + value: check.llmValue, + unit: 'currency', + scale: valueScale, + rowKey: check.metricKey, + surfaceKind + })} {check.status} {check.evidencePages.join(', ') || 'n/a'} diff --git a/lib/format.test.ts b/lib/format.test.ts index 8326d8b..f064cd6 100644 --- a/lib/format.test.ts +++ b/lib/format.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'bun:test'; -import { formatCurrencyByScale, formatScaledNumber } from './format'; +import { + formatCurrencyByScale, + formatFinancialStatementValue, + formatScaledNumber +} from './format'; describe('formatScaledNumber', () => { it('keeps values below one thousand unscaled', () => { @@ -45,3 +49,59 @@ describe('formatCurrencyByScale', () => { expect(formatCurrencyByScale(-2_500_000, 'millions')).toBe('-$2.5M'); }); }); + +describe('formatFinancialStatementValue', () => { + it('renders null statement values as em dashes', () => { + expect(formatFinancialStatementValue({ + value: null, + unit: 'currency', + scale: 'millions', + rowKey: 'revenue', + surfaceKind: 'income_statement' + })).toBe('—'); + }); + + it('formats scaled currency values without a currency symbol', () => { + expect(formatFinancialStatementValue({ + value: 15_940_899_000, + unit: 'currency', + scale: 'millions', + rowKey: 'revenue', + surfaceKind: 'income_statement' + })).toBe('15,940.9'); + expect(formatFinancialStatementValue({ + value: 1_100_000_000, + unit: 'currency', + scale: 'millions', + rowKey: 'long_term_debt_issued', + surfaceKind: 'cash_flow_statement' + })).toBe('1,100'); + }); + + it('keeps extra precision for per-share rows', () => { + expect(formatFinancialStatementValue({ + value: 14.64, + unit: 'currency', + scale: 'millions', + rowKey: 'diluted_eps', + surfaceKind: 'income_statement' + })).toBe('14.64'); + }); + + it('formats statement percents and ratios with trimmed precision', () => { + expect(formatFinancialStatementValue({ + value: 0.233, + unit: 'percent', + scale: 'millions', + rowKey: 'effective_tax_rate', + surfaceKind: 'income_statement' + })).toBe('23.3%'); + expect(formatFinancialStatementValue({ + value: 1.5, + unit: 'ratio', + scale: 'millions', + rowKey: 'debt_to_equity', + surfaceKind: 'ratios' + })).toBe('1.5x'); + }); +}); diff --git a/lib/format.ts b/lib/format.ts index 4e71180..1f8bd37 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -1,3 +1,8 @@ +import type { + FinancialSurfaceKind, + FinancialUnit +} from './types'; + export function asNumber(value: string | number | null | undefined) { if (value === null || value === undefined) { return 0; @@ -37,6 +42,37 @@ type FormatScaledCurrencyOptions = { maximumFractionDigits?: number; }; +type FormatFinancialStatementValueInput = { + value: string | number | null | undefined; + unit: FinancialUnit; + scale: NumberScaleUnit; + rowKey?: string | null; + surfaceKind?: FinancialSurfaceKind | null; + isPercentChange?: boolean; + isCommonSize?: boolean; +}; + +function isPerShareRowKey(rowKey: string | null | undefined) { + if (!rowKey) { + return false; + } + + return rowKey.includes('eps') || rowKey.includes('per_share'); +} + +function formatPlainNumber( + value: number, + options: { + minimumFractionDigits?: number; + maximumFractionDigits?: number; + } = {} +) { + return new Intl.NumberFormat('en-US', { + minimumFractionDigits: options.minimumFractionDigits ?? 0, + maximumFractionDigits: options.maximumFractionDigits ?? 1 + }).format(value); +} + export function formatScaledNumber( value: string | number | null | undefined, options: FormatScaledNumberOptions = {} @@ -99,6 +135,40 @@ export function formatCurrencyByScale( return `${formatted}${suffix}`; } +export function formatFinancialStatementValue(input: FormatFinancialStatementValueInput) { + if (input.value === null || input.value === undefined) { + return '—'; + } + + const numeric = typeof input.value === 'number' ? input.value : Number(input.value); + if (!Number.isFinite(numeric)) { + return '—'; + } + + if (input.isPercentChange || input.isCommonSize || input.unit === 'percent') { + return `${formatPlainNumber(numeric * 100, { minimumFractionDigits: 0, maximumFractionDigits: 1 })}%`; + } + + if (input.unit === 'ratio') { + return `${formatPlainNumber(numeric, { minimumFractionDigits: 0, maximumFractionDigits: 2 })}x`; + } + + if (input.unit === 'currency' && isPerShareRowKey(input.rowKey)) { + return formatPlainNumber(numeric, { minimumFractionDigits: 0, maximumFractionDigits: 2 }); + } + + if (input.unit === 'currency') { + const scaled = numeric / NUMBER_SCALE_UNITS[input.scale].divisor; + return formatPlainNumber(scaled, { minimumFractionDigits: 0, maximumFractionDigits: 1 }); + } + + if (input.unit === 'shares' || input.unit === 'count') { + return formatPlainNumber(numeric, { minimumFractionDigits: 0, maximumFractionDigits: 0 }); + } + + return formatPlainNumber(numeric, { minimumFractionDigits: 0, maximumFractionDigits: 2 }); +} + export function formatCurrency(value: string | number | null | undefined) { return new Intl.NumberFormat('en-US', { style: 'currency',