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

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

View File

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

View File

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

View File

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