feat(financials): add compact surface UI and graphing states
This commit is contained in:
@@ -22,6 +22,9 @@ import {
|
|||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
Search
|
Search
|
||||||
} from 'lucide-react';
|
} 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 { AppShell } from '@/components/shell/app-shell';
|
||||||
import {
|
import {
|
||||||
FinancialControlBar,
|
FinancialControlBar,
|
||||||
@@ -42,10 +45,16 @@ import {
|
|||||||
type NumberScaleUnit
|
type NumberScaleUnit
|
||||||
} from '@/lib/format';
|
} from '@/lib/format';
|
||||||
import { buildGraphingHref } from '@/lib/graphing/catalog';
|
import { buildGraphingHref } from '@/lib/graphing/catalog';
|
||||||
|
import {
|
||||||
|
buildStatementTree,
|
||||||
|
resolveStatementSelection,
|
||||||
|
type StatementInspectorSelection
|
||||||
|
} from '@/lib/financials/statement-view-model';
|
||||||
import { queryKeys } from '@/lib/query/keys';
|
import { queryKeys } from '@/lib/query/keys';
|
||||||
import { companyFinancialStatementsQueryOptions } from '@/lib/query/options';
|
import { companyFinancialStatementsQueryOptions } from '@/lib/query/options';
|
||||||
import type {
|
import type {
|
||||||
CompanyFinancialStatementsResponse,
|
CompanyFinancialStatementsResponse,
|
||||||
|
DetailFinancialRow,
|
||||||
DerivedFinancialRow,
|
DerivedFinancialRow,
|
||||||
FinancialCadence,
|
FinancialCadence,
|
||||||
FinancialDisplayMode,
|
FinancialDisplayMode,
|
||||||
@@ -54,6 +63,8 @@ import type {
|
|||||||
RatioRow,
|
RatioRow,
|
||||||
StandardizedFinancialRow,
|
StandardizedFinancialRow,
|
||||||
StructuredKpiRow,
|
StructuredKpiRow,
|
||||||
|
SurfaceDetailMap,
|
||||||
|
SurfaceFinancialRow,
|
||||||
TaxonomyStatementRow,
|
TaxonomyStatementRow,
|
||||||
TrendSeries
|
TrendSeries
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
@@ -63,7 +74,8 @@ type LoadOptions = {
|
|||||||
append?: boolean;
|
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 }> = [
|
const FINANCIAL_VALUE_SCALE_OPTIONS: Array<{ value: NumberScaleUnit; label: string }> = [
|
||||||
{ value: 'thousands', label: 'Thousands (K)' },
|
{ value: 'thousands', label: 'Thousands (K)' },
|
||||||
@@ -106,19 +118,23 @@ function formatLongDate(value: string) {
|
|||||||
return format(parsed, 'MMM dd, yyyy');
|
return format(parsed, 'MMM dd, yyyy');
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTaxonomyRow(row: DisplayRow): row is TaxonomyStatementRow {
|
function isTaxonomyRow(row: FlatDisplayRow): row is TaxonomyStatementRow {
|
||||||
return 'localName' in row;
|
return 'localName' in row;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDerivedRow(row: DisplayRow): row is DerivedFinancialRow {
|
function isDerivedRow(row: FlatDisplayRow): row is DerivedFinancialRow {
|
||||||
return 'formulaKey' in row;
|
return 'formulaKey' in row;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isKpiRow(row: DisplayRow): row is StructuredKpiRow {
|
function isKpiRow(row: FlatDisplayRow): row is StructuredKpiRow {
|
||||||
return 'provenanceType' in row;
|
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;
|
return row.values[periodId] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,10 +197,10 @@ function chartTickFormatter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildDisplayValue(input: {
|
function buildDisplayValue(input: {
|
||||||
row: DisplayRow;
|
row: FlatDisplayRow;
|
||||||
periodId: string;
|
periodId: string;
|
||||||
previousPeriodId: string | null;
|
previousPeriodId: string | null;
|
||||||
commonSizeRow: DisplayRow | null;
|
commonSizeRow: FlatDisplayRow | null;
|
||||||
displayMode: FinancialDisplayMode;
|
displayMode: FinancialDisplayMode;
|
||||||
showPercentChange: boolean;
|
showPercentChange: boolean;
|
||||||
showCommonSize: 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<FinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>;
|
||||||
|
}) {
|
||||||
|
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) {
|
if (categories.length === 0) {
|
||||||
return [{ label: null, rows }];
|
return [{ label: null, rows }];
|
||||||
}
|
}
|
||||||
@@ -258,6 +345,42 @@ function groupRows(rows: DisplayRow[], categories: CompanyFinancialStatementsRes
|
|||||||
.filter((group) => group.rows.length > 0);
|
.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(
|
function mergeFinancialPages(
|
||||||
base: CompanyFinancialStatementsResponse | null,
|
base: CompanyFinancialStatementsResponse | null,
|
||||||
next: CompanyFinancialStatementsResponse
|
next: CompanyFinancialStatementsResponse
|
||||||
@@ -297,6 +420,7 @@ function mergeFinancialPages(
|
|||||||
standardized: mergeRows([...base.statementRows.standardized, ...next.statementRows.standardized])
|
standardized: mergeRows([...base.statementRows.standardized, ...next.statementRows.standardized])
|
||||||
}
|
}
|
||||||
: next.statementRows,
|
: next.statementRows,
|
||||||
|
statementDetails: mergeDetailMaps(base.statementDetails, next.statementDetails),
|
||||||
ratioRows: next.ratioRows && base.ratioRows ? mergeRows([...base.ratioRows, ...next.ratioRows]) : next.ratioRows,
|
ratioRows: next.ratioRows && base.ratioRows ? mergeRows([...base.ratioRows, ...next.ratioRows]) : next.ratioRows,
|
||||||
kpiRows: next.kpiRows && base.kpiRows ? mergeRows([...base.kpiRows, ...next.kpiRows]) : next.kpiRows,
|
kpiRows: next.kpiRows && base.kpiRows ? mergeRows([...base.kpiRows, ...next.kpiRows]) : next.kpiRows,
|
||||||
trendSeries: next.trendSeries,
|
trendSeries: next.trendSeries,
|
||||||
@@ -348,7 +472,9 @@ function FinancialsPageContent() {
|
|||||||
const [showPercentChange, setShowPercentChange] = useState(false);
|
const [showPercentChange, setShowPercentChange] = useState(false);
|
||||||
const [showCommonSize, setShowCommonSize] = useState(false);
|
const [showCommonSize, setShowCommonSize] = useState(false);
|
||||||
const [financials, setFinancials] = useState<CompanyFinancialStatementsResponse | null>(null);
|
const [financials, setFinancials] = useState<CompanyFinancialStatementsResponse | null>(null);
|
||||||
const [selectedRowKey, setSelectedRowKey] = useState<string | null>(null);
|
const [selectedFlatRowKey, setSelectedFlatRowKey] = useState<string | null>(null);
|
||||||
|
const [selectedRowRef, setSelectedRowRef] = useState<StatementInspectorSelection | null>(null);
|
||||||
|
const [expandedRowKeys, setExpandedRowKeys] = useState<Set<string>>(() => new Set());
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [syncingFinancials, setSyncingFinancials] = useState(false);
|
const [syncingFinancials, setSyncingFinancials] = useState(false);
|
||||||
@@ -367,6 +493,9 @@ function FinancialsPageContent() {
|
|||||||
|
|
||||||
setTickerInput(normalized);
|
setTickerInput(normalized);
|
||||||
setTicker(normalized);
|
setTicker(normalized);
|
||||||
|
setSelectedFlatRowKey(null);
|
||||||
|
setSelectedRowRef(null);
|
||||||
|
setExpandedRowKeys(new Set());
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -389,7 +518,8 @@ function FinancialsPageContent() {
|
|||||||
const loadFinancials = useCallback(async (symbol: string, options?: LoadOptions) => {
|
const loadFinancials = useCallback(async (symbol: string, options?: LoadOptions) => {
|
||||||
const normalizedTicker = symbol.trim().toUpperCase();
|
const normalizedTicker = symbol.trim().toUpperCase();
|
||||||
const nextCursor = options?.cursor ?? null;
|
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) {
|
if (!options?.append) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -420,7 +550,7 @@ function FinancialsPageContent() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
setLoadingMore(false);
|
setLoadingMore(false);
|
||||||
}
|
}
|
||||||
}, [cadence, queryClient, selectedRowKey, surfaceKind]);
|
}, [cadence, queryClient, selectedFlatRowKey, selectedRowRef, surfaceKind]);
|
||||||
|
|
||||||
const syncFinancials = useCallback(async () => {
|
const syncFinancials = useCallback(async () => {
|
||||||
const targetTicker = (financials?.company.ticker ?? ticker).trim().toUpperCase();
|
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));
|
.sort((left, right) => Date.parse(left.periodEnd ?? left.filingDate) - Date.parse(right.periodEnd ?? right.filingDate));
|
||||||
}, [financials?.periods]);
|
}, [financials?.periods]);
|
||||||
|
|
||||||
const activeRows = useMemo<DisplayRow[]>(() => {
|
const isTreeStatementMode = displayMode === 'standardized' && isStatementSurfaceKind(surfaceKind);
|
||||||
|
|
||||||
|
const activeRows = useMemo<FlatDisplayRow[]>(() => {
|
||||||
if (!financials) {
|
if (!financials) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -477,35 +609,81 @@ function FinancialsPageContent() {
|
|||||||
}, [displayMode, financials, surfaceKind]);
|
}, [displayMode, financials, surfaceKind]);
|
||||||
|
|
||||||
const filteredRows = useMemo(() => {
|
const filteredRows = useMemo(() => {
|
||||||
|
if (isTreeStatementMode) {
|
||||||
|
return activeRows;
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedSearch = rowSearch.trim().toLowerCase();
|
const normalizedSearch = rowSearch.trim().toLowerCase();
|
||||||
if (!normalizedSearch) {
|
if (!normalizedSearch) {
|
||||||
return activeRows;
|
return activeRows;
|
||||||
}
|
}
|
||||||
|
|
||||||
return activeRows.filter((row) => row.label.toLowerCase().includes(normalizedSearch));
|
return activeRows.filter((row) => row.label.toLowerCase().includes(normalizedSearch));
|
||||||
}, [activeRows, rowSearch]);
|
}, [activeRows, isTreeStatementMode, rowSearch]);
|
||||||
|
|
||||||
const groupedRows = useMemo(() => {
|
const groupedRows = useMemo(() => {
|
||||||
return groupRows(filteredRows, financials?.categories ?? []);
|
return groupRows(filteredRows, financials?.categories ?? []);
|
||||||
}, [filteredRows, financials?.categories]);
|
}, [filteredRows, financials?.categories]);
|
||||||
|
|
||||||
const selectedRow = useMemo(() => {
|
const treeModel = useMemo(() => {
|
||||||
if (!selectedRowKey) {
|
if (!isTreeStatementMode || !financials || !isStatementSurfaceKind(surfaceKind)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return activeRows.find((row) => row.key === selectedRowKey) ?? null;
|
return buildStatementTree({
|
||||||
}, [activeRows, selectedRowKey]);
|
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(() => {
|
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 [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return financials.dimensionBreakdown[selectedRow.key] ?? [];
|
return financials.dimensionBreakdown[selectedRow.key] ?? [];
|
||||||
}, [financials?.dimensionBreakdown, selectedRow]);
|
}, [financials?.dimensionBreakdown, selectedRow, selectedStatementRow]);
|
||||||
|
|
||||||
const commonSizeRow = useMemo<DisplayRow | null>(() => {
|
const commonSizeRow = useMemo<SurfaceFinancialRow | null>(() => {
|
||||||
if (displayMode === 'faithful' || !financials?.statementRows) {
|
if (displayMode === 'faithful' || !financials?.statementRows) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -539,7 +717,9 @@ function FinancialsPageContent() {
|
|||||||
options: SURFACE_OPTIONS,
|
options: SURFACE_OPTIONS,
|
||||||
onChange: (value) => {
|
onChange: (value) => {
|
||||||
setSurfaceKind(value as FinancialSurfaceKind);
|
setSurfaceKind(value as FinancialSurfaceKind);
|
||||||
setSelectedRowKey(null);
|
setSelectedFlatRowKey(null);
|
||||||
|
setSelectedRowRef(null);
|
||||||
|
setExpandedRowKeys(new Set());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -549,7 +729,9 @@ function FinancialsPageContent() {
|
|||||||
options: CADENCE_OPTIONS,
|
options: CADENCE_OPTIONS,
|
||||||
onChange: (value) => {
|
onChange: (value) => {
|
||||||
setCadence(value as FinancialCadence);
|
setCadence(value as FinancialCadence);
|
||||||
setSelectedRowKey(null);
|
setSelectedFlatRowKey(null);
|
||||||
|
setSelectedRowRef(null);
|
||||||
|
setExpandedRowKeys(new Set());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -562,7 +744,9 @@ function FinancialsPageContent() {
|
|||||||
options: DISPLAY_MODE_OPTIONS,
|
options: DISPLAY_MODE_OPTIONS,
|
||||||
onChange: (value) => {
|
onChange: (value) => {
|
||||||
setDisplayMode(value as FinancialDisplayMode);
|
setDisplayMode(value as FinancialDisplayMode);
|
||||||
setSelectedRowKey(null);
|
setSelectedFlatRowKey(null);
|
||||||
|
setSelectedRowRef(null);
|
||||||
|
setExpandedRowKeys(new Set());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -626,6 +810,56 @@ function FinancialsPageContent() {
|
|||||||
return actions;
|
return actions;
|
||||||
}, [displayMode, financials?.nextCursor, loadFinancials, loadingMore, selectedRow, showCommonSize, showPercentChange, surfaceKind, ticker]);
|
}, [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) {
|
if (isPending || !isAuthenticated) {
|
||||||
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading financial terminal...</div>;
|
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading financial terminal...</div>;
|
||||||
}
|
}
|
||||||
@@ -668,6 +902,9 @@ function FinancialsPageContent() {
|
|||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setSelectedFlatRowKey(null);
|
||||||
|
setSelectedRowRef(null);
|
||||||
|
setExpandedRowKeys(new Set());
|
||||||
setTicker(normalized);
|
setTicker(normalized);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -726,7 +963,7 @@ function FinancialsPageContent() {
|
|||||||
className="w-full sm:max-w-sm"
|
className="w-full sm:max-w-sm"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-[color:var(--terminal-muted)]">
|
<span className="text-sm text-[color:var(--terminal-muted)]">
|
||||||
{filteredRows.length} of {activeRows.length} rows
|
{rowResultCountLabel}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
@@ -788,6 +1025,10 @@ function FinancialsPageContent() {
|
|||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
{financials && isTreeStatementMode ? (
|
||||||
|
<NormalizationSummary normalization={financials.normalization} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Panel title="Surface Matrix" subtitle="Standardized statements, ratios, and KPIs render in one shared matrix." variant="surface">
|
<Panel title="Surface Matrix" subtitle="Standardized statements, ratios, and KPIs render in one shared matrix." variant="surface">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading financial matrix...</p>
|
<p className="text-sm text-[color:var(--terminal-muted)]">Loading financial matrix...</p>
|
||||||
@@ -796,15 +1037,26 @@ function FinancialsPageContent() {
|
|||||||
<p className="text-sm text-[color:var(--terminal-bright)]">This surface is not yet available in v1.</p>
|
<p className="text-sm text-[color:var(--terminal-bright)]">This surface is not yet available in v1.</p>
|
||||||
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">Adjusted and custom metrics are API-visible placeholders only. No edits or derived rows are available yet.</p>
|
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">Adjusted and custom metrics are API-visible placeholders only. No edits or derived rows are available yet.</p>
|
||||||
</div>
|
</div>
|
||||||
) : periods.length === 0 || filteredRows.length === 0 ? (
|
) : periods.length === 0 || (isTreeStatementMode ? (treeModel?.visibleNodeCount ?? 0) === 0 : filteredRows.length === 0) ? (
|
||||||
<p className="text-sm text-[color:var(--terminal-muted)]">No rows available for the selected filters yet.</p>
|
<p className="text-sm text-[color:var(--terminal-muted)]">No rows available for the selected filters yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{isStatementSurfaceKind(surfaceKind) ? (
|
{isStatementSurfaceKind(surfaceKind) ? (
|
||||||
<p className="text-xs uppercase tracking-[0.24em] text-[color:var(--terminal-muted)]">
|
<p className="text-xs uppercase tracking-[0.24em] text-[color:var(--terminal-muted)]">
|
||||||
USD · {FINANCIAL_VALUE_SCALE_OPTIONS.find((option) => option.value === valueScale)?.label ?? valueScale}
|
USD · {valueScaleLabel}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{isTreeStatementMode && treeModel ? (
|
||||||
|
<StatementMatrix
|
||||||
|
periods={periods}
|
||||||
|
sections={treeModel.sections}
|
||||||
|
selectedRowRef={selectedRowRef}
|
||||||
|
onToggleRow={toggleExpandedRow}
|
||||||
|
onSelectRow={setSelectedRowRef}
|
||||||
|
renderCellValue={renderStatementTreeCellValue}
|
||||||
|
periodLabelFormatter={formatLongDate}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<div className="data-table-wrap">
|
<div className="data-table-wrap">
|
||||||
<table className="data-table min-w-[980px]">
|
<table className="data-table min-w-[980px]">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -831,8 +1083,8 @@ function FinancialsPageContent() {
|
|||||||
{group.rows.map((row) => (
|
{group.rows.map((row) => (
|
||||||
<tr
|
<tr
|
||||||
key={row.key}
|
key={row.key}
|
||||||
className={selectedRowKey === row.key ? 'bg-[color:var(--panel-soft)]' : undefined}
|
className={selectedFlatRowKey === row.key ? 'bg-[color:var(--panel-soft)]' : undefined}
|
||||||
onClick={() => setSelectedRowKey(row.key)}
|
onClick={() => setSelectedFlatRowKey(row.key)}
|
||||||
>
|
>
|
||||||
<td className="sticky left-0 z-10 bg-[color:var(--panel)]">
|
<td className="sticky left-0 z-10 bg-[color:var(--panel)]">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -870,10 +1122,21 @@ function FinancialsPageContent() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
{isTreeStatementMode && isStatementSurfaceKind(surfaceKind) ? (
|
||||||
|
<StatementRowInspector
|
||||||
|
selection={selectedStatementRow}
|
||||||
|
dimensionRows={dimensionRows}
|
||||||
|
periods={periods}
|
||||||
|
surfaceKind={surfaceKind}
|
||||||
|
renderValue={renderStatementTreeCellValue}
|
||||||
|
renderDimensionValue={renderDimensionValue}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Panel title="Row Details" subtitle="Inspect provenance, formulas, and dimensional evidence for the selected row." variant="surface">
|
<Panel title="Row Details" subtitle="Inspect provenance, formulas, and dimensional evidence for the selected row." variant="surface">
|
||||||
{!selectedRow ? (
|
{!selectedRow ? (
|
||||||
<p className="text-sm text-[color:var(--terminal-muted)]">Select a row to inspect details.</p>
|
<p className="text-sm text-[color:var(--terminal-muted)]">Select a row to inspect details.</p>
|
||||||
@@ -956,6 +1219,7 @@ function FinancialsPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
{(surfaceKind === 'income_statement' || surfaceKind === 'balance_sheet' || surfaceKind === 'cash_flow_statement') && financials ? (
|
{(surfaceKind === 'income_statement' || surfaceKind === 'balance_sheet' || surfaceKind === 'cash_flow_statement') && financials ? (
|
||||||
<Panel title="Metric Validation" subtitle="Validation remains limited to statement-derived taxonomy metrics in v1." variant="surface">
|
<Panel title="Metric Validation" subtitle="Validation remains limited to statement-derived taxonomy metrics in v1." variant="surface">
|
||||||
|
|||||||
@@ -361,6 +361,30 @@ function GraphingPageContent() {
|
|||||||
surface: graphState.surface,
|
surface: graphState.surface,
|
||||||
metric: graphState.metric
|
metric: graphState.metric
|
||||||
}), [graphState.metric, graphState.surface, results]);
|
}), [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';
|
const hasCurrencyScale = selectedMetric?.unit === 'currency';
|
||||||
|
|
||||||
@@ -527,7 +551,7 @@ function GraphingPageContent() {
|
|||||||
<>
|
<>
|
||||||
{comparison.hasPartialData ? (
|
{comparison.hasPartialData ? (
|
||||||
<div className="mb-4 rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-3 text-sm text-[color:var(--terminal-muted)]">
|
<div className="mb-4 rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-3 text-sm text-[color:var(--terminal-muted)]">
|
||||||
Partial coverage detected. Some companies are missing values for this metric or failed to load, but the remaining series still render.
|
{partialCoverageMessage}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="mb-4 flex flex-wrap gap-2">
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
@@ -613,6 +637,7 @@ function GraphingPageContent() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Company</th>
|
<th>Company</th>
|
||||||
|
<th>Pack</th>
|
||||||
<th>Latest</th>
|
<th>Latest</th>
|
||||||
<th>Prior</th>
|
<th>Prior</th>
|
||||||
<th>Change</th>
|
<th>Change</th>
|
||||||
@@ -637,6 +662,7 @@ function GraphingPageContent() {
|
|||||||
<span className="text-xs text-[color:var(--terminal-muted)]">{row.companyName}</span>
|
<span className="text-xs text-[color:var(--terminal-muted)]">{row.companyName}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td>{row.fiscalPack ?? 'n/a'}</td>
|
||||||
<td>{selectedMetric ? formatMetricValue(row.latestValue, selectedMetric.unit, graphState.scale) : 'n/a'}</td>
|
<td>{selectedMetric ? formatMetricValue(row.latestValue, selectedMetric.unit, graphState.scale) : 'n/a'}</td>
|
||||||
<td>{selectedMetric ? formatMetricValue(row.priorValue, selectedMetric.unit, graphState.scale) : 'n/a'}</td>
|
<td>{selectedMetric ? formatMetricValue(row.priorValue, selectedMetric.unit, graphState.scale) : 'n/a'}</td>
|
||||||
<td className={cn(row.changeValue !== null && row.changeValue >= 0 ? 'text-[color:var(--accent)]' : row.changeValue !== null ? 'text-[#ffb5b5]' : 'text-[color:var(--terminal-muted)]')}>
|
<td className={cn(row.changeValue !== null && row.changeValue >= 0 ? 'text-[color:var(--accent)]' : row.changeValue !== null ? 'text-[#ffb5b5]' : 'text-[color:var(--terminal-muted)]')}>
|
||||||
@@ -647,6 +673,8 @@ function GraphingPageContent() {
|
|||||||
<td>
|
<td>
|
||||||
{row.status === 'ready' ? (
|
{row.status === 'ready' ? (
|
||||||
<span className="text-[color:var(--accent)]">Ready</span>
|
<span className="text-[color:var(--accent)]">Ready</span>
|
||||||
|
) : row.status === 'not_meaningful' ? (
|
||||||
|
<span className="text-[color:var(--terminal-muted)]">Not meaningful for this pack</span>
|
||||||
) : row.status === 'no_metric_data' ? (
|
) : row.status === 'no_metric_data' ? (
|
||||||
<span className="text-[color:var(--terminal-muted)]">No metric data</span>
|
<span className="text-[color:var(--terminal-muted)]">No metric data</span>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
58
components/financials/normalization-summary.tsx
Normal file
58
components/financials/normalization-summary.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'data-surface px-3 py-3',
|
||||||
|
props.tone === 'warning' && 'border-[#7f6250] bg-[linear-gradient(180deg,rgba(80,58,41,0.92),rgba(38,27,21,0.78))]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="panel-heading text-[10px] uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{props.label}</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">{props.value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NormalizationSummary({ normalization }: NormalizationSummaryProps) {
|
||||||
|
const hasMaterialUnmapped = normalization.materialUnmappedRowCount > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
title="Normalization Summary"
|
||||||
|
subtitle="Pack, parser, and residual mapping health for the compact statement surface."
|
||||||
|
variant="surface"
|
||||||
|
>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||||
|
<SummaryCard label="Pack" value={normalization.fiscalPack ?? 'unknown'} />
|
||||||
|
<SummaryCard label="Regime" value={normalization.regime} />
|
||||||
|
<SummaryCard label="Parser" value={`fiscal-xbrl ${normalization.parserVersion}`} />
|
||||||
|
<SummaryCard label="Unmapped Rows" value={String(normalization.unmappedRowCount)} />
|
||||||
|
<SummaryCard
|
||||||
|
label="Material Unmapped"
|
||||||
|
value={String(normalization.materialUnmappedRowCount)}
|
||||||
|
tone={hasMaterialUnmapped ? 'warning' : 'default'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{hasMaterialUnmapped ? (
|
||||||
|
<div className="mt-3 flex items-start gap-2 rounded-xl border border-[#7f6250] bg-[rgba(91,66,46,0.18)] px-3 py-3 text-sm text-[#f5d5c0]">
|
||||||
|
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||||
|
<p>Material unmapped rows were detected for this filing set. Use the inspector and detail rows before relying on cross-company comparisons.</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
components/financials/statement-matrix.tsx
Normal file
208
components/financials/statement-matrix.tsx
Normal file
@@ -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<StatementTreeNode, { kind: 'surface' }> {
|
||||||
|
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<StatementTreeNode, { kind: 'surface' }>) {
|
||||||
|
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 (
|
||||||
|
<Fragment key={node.id}>
|
||||||
|
<tr className={cn(isSelected && 'bg-[color:rgba(70,77,87,0.48)]')}>
|
||||||
|
<td className="sticky left-0 z-10 bg-[color:var(--panel)]">
|
||||||
|
<div className="flex min-w-[260px] items-start gap-2" style={{ paddingLeft: `${labelIndent}px` }}>
|
||||||
|
{canToggle ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`${node.expanded ? 'Collapse' : 'Expand'} ${node.row.label} details`}
|
||||||
|
aria-expanded={node.expanded}
|
||||||
|
aria-controls={`statement-children-${node.id}`}
|
||||||
|
className="mt-0.5 inline-flex size-11 shrink-0 items-center justify-center rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] text-[color:var(--terminal-bright)] transition hover:border-[color:var(--line-strong)]"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
props.onToggleRow(node.row.key);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{node.expanded ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex size-11 shrink-0 items-center justify-center text-[color:var(--terminal-muted)]" aria-hidden="true">
|
||||||
|
{node.kind === 'detail' ? '·' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex min-w-0 flex-1 flex-col items-start gap-1 py-2 text-left"
|
||||||
|
onClick={() => props.onSelectRow(nextSelection)}
|
||||||
|
>
|
||||||
|
<span className={cn(
|
||||||
|
'text-sm text-[color:var(--terminal-bright)]',
|
||||||
|
node.kind === 'detail' && 'text-[13px] text-[color:var(--terminal-soft)]',
|
||||||
|
node.kind === 'surface' && node.level > 0 && 'text-[color:var(--terminal-soft)]'
|
||||||
|
)}>
|
||||||
|
{node.row.label}
|
||||||
|
</span>
|
||||||
|
{node.kind === 'detail' ? (
|
||||||
|
<span className="text-xs text-[color:var(--terminal-muted)]">
|
||||||
|
{node.row.localName}
|
||||||
|
{node.row.residualFlag ? ' · residual' : ''}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{surfaceBadges(node).map((badge) => (
|
||||||
|
<span
|
||||||
|
key={`${node.row.key}-${badge.label}`}
|
||||||
|
className={cn(
|
||||||
|
'rounded-full border px-2 py-0.5 text-[10px] uppercase tracking-[0.14em]',
|
||||||
|
badgeClass(badge.tone)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{props.periods.map((period, index) => (
|
||||||
|
<td key={`${node.id}-${period.id}`}>
|
||||||
|
{props.renderCellValue(node.row, period.id, index > 0 ? props.periods[index - 1]?.id ?? null : null)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
{isSurfaceNode(node) && node.expanded ? (
|
||||||
|
<>
|
||||||
|
<tr id={`statement-children-${node.id}`} className="sr-only">
|
||||||
|
<td colSpan={props.periods.length + 1}>Expanded children for {node.row.label}</td>
|
||||||
|
</tr>
|
||||||
|
{renderNodes({
|
||||||
|
...props,
|
||||||
|
nodes: node.children
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatementMatrix(props: StatementMatrixProps) {
|
||||||
|
return (
|
||||||
|
<div className="data-table-wrap">
|
||||||
|
<table className="data-table min-w-[1040px]">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="sticky left-0 z-10 bg-[color:var(--panel)]">Metric</th>
|
||||||
|
{props.periods.map((period) => (
|
||||||
|
<th key={period.id}>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span>{props.periodLabelFormatter(period.periodEnd ?? period.filingDate)}</span>
|
||||||
|
<span className="text-[11px] normal-case tracking-normal text-[color:var(--terminal-muted)]">{period.filingType} · {period.periodLabel}</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{props.sections.map((section) => (
|
||||||
|
<Fragment key={section.key}>
|
||||||
|
{section.label ? (
|
||||||
|
<tr className="bg-[color:var(--panel-soft)]">
|
||||||
|
<td colSpan={props.periods.length + 1} className="font-semibold text-[color:var(--terminal-bright)]">
|
||||||
|
{section.label}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{renderNodes({
|
||||||
|
...props,
|
||||||
|
nodes: section.nodes
|
||||||
|
})}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
components/financials/statement-row-inspector.tsx
Normal file
174
components/financials/statement-row-inspector.tsx
Normal file
@@ -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<FinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>;
|
||||||
|
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 (
|
||||||
|
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||||
|
<p className="text-[color:var(--terminal-muted)]">{props.label}</p>
|
||||||
|
<p className="font-semibold text-[color:var(--terminal-bright)]">{props.value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderList(values: string[]) {
|
||||||
|
return values.length > 0 ? values.join(', ') : 'n/a';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatementRowInspector(props: StatementRowInspectorProps) {
|
||||||
|
const selection = props.selection;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
title="Row Details"
|
||||||
|
subtitle="Inspect compact-surface resolution, raw drill-down rows, and dimensional evidence."
|
||||||
|
variant="surface"
|
||||||
|
>
|
||||||
|
{!selection ? (
|
||||||
|
<p className="text-sm text-[color:var(--terminal-muted)]">Select a compact surface row or raw detail row to inspect details.</p>
|
||||||
|
) : selection.kind === 'surface' ? (
|
||||||
|
<div className="space-y-4 text-sm">
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<InspectorCard label="Label" value={selection.row.label} />
|
||||||
|
<InspectorCard label="Key" value={selection.row.key} />
|
||||||
|
<InspectorCard label="Resolution" value={selection.row.resolutionMethod ?? 'direct'} />
|
||||||
|
<InspectorCard label="Confidence" value={selection.row.confidence ?? 'high'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<InspectorCard label="Source Row Keys" value={renderList(selection.row.sourceRowKeys)} />
|
||||||
|
<InspectorCard label="Source Concepts" value={renderList(selection.row.sourceConcepts)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<InspectorCard label="Source Fact IDs" value={selection.row.sourceFactIds.length > 0 ? selection.row.sourceFactIds.join(', ') : 'n/a'} />
|
||||||
|
<InspectorCard label="Warning Codes" value={renderList(selection.row.warningCodes ?? [])} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<InspectorCard label="Child Surface Rows" value={selection.childSurfaceRows.length > 0 ? selection.childSurfaceRows.map((row) => row.label).join(', ') : 'None'} />
|
||||||
|
<InspectorCard label="Raw Detail Rows" value={String(selection.detailRows.length)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selection.detailRows.length > 0 ? (
|
||||||
|
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-3">
|
||||||
|
<p className="text-[color:var(--terminal-muted)]">Raw Detail Labels</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{selection.detailRows.map((row) => (
|
||||||
|
<span
|
||||||
|
key={`${selection.row.key}-${row.key}`}
|
||||||
|
className="rounded-full border border-[color:var(--line-weak)] bg-[rgba(88,102,122,0.16)] px-3 py-1 text-xs text-[color:var(--terminal-bright)]"
|
||||||
|
>
|
||||||
|
{row.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selection.row.hasDimensions ? (
|
||||||
|
props.dimensionRows.length === 0 ? (
|
||||||
|
<p className="text-sm text-[color:var(--terminal-muted)]">No dimensional facts were returned for the selected compact row.</p>
|
||||||
|
) : (
|
||||||
|
<div className="data-table-wrap">
|
||||||
|
<table className="data-table min-w-[760px]">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Period</th>
|
||||||
|
<th>Axis</th>
|
||||||
|
<th>Member</th>
|
||||||
|
<th>Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{props.dimensionRows.map((row, index) => (
|
||||||
|
<tr key={`${selection.row.key}-${row.periodId}-${row.axis}-${row.member}-${index}`}>
|
||||||
|
<td>{props.periods.find((period) => period.id === row.periodId)?.periodLabel ?? row.periodId}</td>
|
||||||
|
<td>{row.axis}</td>
|
||||||
|
<td>{row.member}</td>
|
||||||
|
<td>{props.renderDimensionValue(row.value, selection.row.key, selection.row.unit)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[color:var(--terminal-muted)]">No dimensional drill-down is available for this compact surface row.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 text-sm">
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<InspectorCard label="Label" value={selection.row.label} />
|
||||||
|
<InspectorCard label="Key" value={selection.row.key} />
|
||||||
|
<InspectorCard label="Parent Surface" value={selection.parentSurfaceRow?.label ?? selection.row.parentSurfaceKey} />
|
||||||
|
<InspectorCard label="Residual" value={selection.row.residualFlag ? 'Yes' : 'No'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<InspectorCard label="Concept Key" value={selection.row.conceptKey} />
|
||||||
|
<InspectorCard label="QName" value={selection.row.qname} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<InspectorCard label="Local Name" value={selection.row.localName} />
|
||||||
|
<InspectorCard label="Source Fact IDs" value={selection.row.sourceFactIds.length > 0 ? selection.row.sourceFactIds.join(', ') : 'n/a'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2">
|
||||||
|
<p className="text-[color:var(--terminal-muted)]">Dimensions Summary</p>
|
||||||
|
<p className="font-semibold text-[color:var(--terminal-bright)]">{renderList(selection.row.dimensionsSummary)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{props.dimensionRows.length === 0 ? (
|
||||||
|
<p className="text-sm text-[color:var(--terminal-muted)]">No dimensional facts were returned for the selected raw detail row.</p>
|
||||||
|
) : (
|
||||||
|
<div className="data-table-wrap">
|
||||||
|
<table className="data-table min-w-[760px]">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Period</th>
|
||||||
|
<th>Axis</th>
|
||||||
|
<th>Member</th>
|
||||||
|
<th>Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{props.dimensionRows.map((row, index) => (
|
||||||
|
<tr key={`${selection.row.parentSurfaceKey}-${selection.row.key}-${row.periodId}-${index}`}>
|
||||||
|
<td>{props.periods.find((period) => period.id === row.periodId)?.periodLabel ?? row.periodId}</td>
|
||||||
|
<td>{row.axis}</td>
|
||||||
|
<td>{row.member}</td>
|
||||||
|
<td>{props.renderDimensionValue(row.value, selection.row.key, props.surfaceKind === 'balance_sheet' ? 'currency' : 'currency')}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
346
e2e/financials.spec.ts
Normal file
346
e2e/financials.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
@@ -29,6 +29,12 @@ function createFinancialsPayload(input: {
|
|||||||
cadence: 'annual' | 'quarterly' | 'ltm';
|
cadence: 'annual' | 'quarterly' | 'ltm';
|
||||||
surface: string;
|
surface: string;
|
||||||
}) {
|
}) {
|
||||||
|
const fiscalPack = input.ticker === 'JPM'
|
||||||
|
? 'bank_lender'
|
||||||
|
: input.ticker === 'BLK'
|
||||||
|
? 'broker_asset_manager'
|
||||||
|
: 'core';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
financials: {
|
financials: {
|
||||||
company: {
|
company: {
|
||||||
@@ -83,7 +89,50 @@ function createFinancialsPayload(input: {
|
|||||||
resolvedSourceRowKeys: {
|
resolvedSourceRowKeys: {
|
||||||
[`${input.ticker}-p1`]: 'revenue',
|
[`${input.ticker}-p1`]: 'revenue',
|
||||||
[`${input.ticker}-p2`]: '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',
|
key: 'total_assets',
|
||||||
@@ -103,7 +152,8 @@ function createFinancialsPayload(input: {
|
|||||||
resolvedSourceRowKeys: {
|
resolvedSourceRowKeys: {
|
||||||
[`${input.ticker}-p1`]: 'total_assets',
|
[`${input.ticker}-p1`]: 'total_assets',
|
||||||
[`${input.ticker}-p2`]: 'total_assets'
|
[`${input.ticker}-p2`]: 'total_assets'
|
||||||
}
|
},
|
||||||
|
resolutionMethod: 'direct'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'free_cash_flow',
|
key: 'free_cash_flow',
|
||||||
@@ -123,10 +173,12 @@ function createFinancialsPayload(input: {
|
|||||||
resolvedSourceRowKeys: {
|
resolvedSourceRowKeys: {
|
||||||
[`${input.ticker}-p1`]: 'free_cash_flow',
|
[`${input.ticker}-p1`]: 'free_cash_flow',
|
||||||
[`${input.ticker}-p2`]: 'free_cash_flow'
|
[`${input.ticker}-p2`]: 'free_cash_flow'
|
||||||
}
|
},
|
||||||
|
resolutionMethod: 'direct'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
statementDetails: null,
|
||||||
ratioRows: [
|
ratioRows: [
|
||||||
{
|
{
|
||||||
key: 'gross_margin',
|
key: 'gross_margin',
|
||||||
@@ -177,6 +229,13 @@ function createFinancialsPayload(input: {
|
|||||||
taxonomy: null,
|
taxonomy: null,
|
||||||
validation: null
|
validation: null
|
||||||
},
|
},
|
||||||
|
normalization: {
|
||||||
|
regime: 'unknown',
|
||||||
|
fiscalPack,
|
||||||
|
parserVersion: '0.0.0',
|
||||||
|
unmappedRowCount: 0,
|
||||||
|
materialUnmappedRowCount: 0
|
||||||
|
},
|
||||||
dimensionBreakdown: null
|
dimensionBreakdown: null
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -204,6 +263,10 @@ async function mockGraphingFinancials(page: Page) {
|
|||||||
? 'NVIDIA Corporation'
|
? 'NVIDIA Corporation'
|
||||||
: ticker === 'AMD'
|
: ticker === 'AMD'
|
||||||
? 'Advanced Micro Devices, Inc.'
|
? 'Advanced Micro Devices, Inc.'
|
||||||
|
: ticker === 'BLK'
|
||||||
|
? 'BlackRock, Inc.'
|
||||||
|
: ticker === 'JPM'
|
||||||
|
? 'JPMorgan Chase & Co.'
|
||||||
: 'Microsoft Corporation';
|
: 'Microsoft Corporation';
|
||||||
|
|
||||||
await route.fulfill({
|
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).toHaveURL(/tickers=MSFT%2CAAPL%2CNVDA/);
|
||||||
await expect(page.getByRole('heading', { name: 'Graphing' })).toBeVisible();
|
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 page.getByRole('button', { name: 'Graph surface Balance Sheet' }).click();
|
||||||
await expect(page).toHaveURL(/surface=balance_sheet/);
|
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.getByLabel('Compare tickers').fill('MSFT, NVDA, AMD');
|
||||||
await page.getByRole('button', { name: 'Update Compare Set' }).click();
|
await page.getByRole('button', { name: 'Update Compare Set' }).click();
|
||||||
await expect(page).toHaveURL(/tickers=MSFT%2CNVDA%2CAMD/);
|
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 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.getByText('Partial coverage detected.')).toBeVisible();
|
||||||
await expect(page.getByRole('cell', { name: /BAD/ })).toBeVisible();
|
await expect(page.getByRole('cell', { name: /BAD/ })).toBeVisible();
|
||||||
await expect(page.getByText('Ticker not found')).toBeVisible();
|
await expect(page.getByText('Microsoft Corporation').first()).toBeVisible();
|
||||||
await expect(page.getByText('Microsoft Corporation')).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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: '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_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: '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: 'operating_expenses', label: 'Operating Expenses', category: 'opex', order: 50, unit: 'currency', localNames: ['OperatingExpenses'], labelIncludes: ['operating expenses'] },
|
||||||
{ key: 'sales_and_marketing', label: 'Sales & Marketing', category: 'opex', order: 60, unit: 'currency', localNames: ['SellingAndMarketingExpense'], labelIncludes: ['sales and marketing', 'selling and marketing'] },
|
{ key: 'selling_general_and_administrative', label: 'Selling, General & Administrative', category: 'opex', order: 60, unit: 'currency', localNames: ['SellingGeneralAndAdministrativeExpense'], labelIncludes: ['selling, general', 'selling general'] },
|
||||||
{ key: 'general_and_administrative', label: 'General & Administrative', category: 'opex', order: 70, unit: 'currency', localNames: ['GeneralAndAdministrativeExpense'], labelIncludes: ['general and administrative'] },
|
{ key: 'research_and_development', label: 'Research Expense', category: 'opex', order: 70, unit: 'currency', localNames: ['ResearchAndDevelopmentExpense'], labelIncludes: ['research and development', 'research expense'] },
|
||||||
{ key: 'selling_general_and_administrative', label: 'Selling, General & Administrative', category: 'opex', order: 80, unit: 'currency', localNames: ['SellingGeneralAndAdministrativeExpense'], labelIncludes: ['selling, general', 'selling general'] },
|
{ 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_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: 'sales_and_marketing', label: 'Sales & Marketing', category: 'opex', order: 100, unit: 'currency', localNames: ['SellingAndMarketingExpense'], labelIncludes: ['sales and marketing', 'selling and marketing'] },
|
||||||
{ key: 'interest_income', label: 'Interest Income', category: 'non_operating', order: 110, unit: 'currency', localNames: ['InterestIncomeExpenseNonoperatingNet', 'InterestIncomeOther', 'InvestmentIncomeInterest'], labelIncludes: ['interest income'] },
|
{ key: 'general_and_administrative', label: 'General & Administrative', category: 'opex', order: 110, unit: 'currency', localNames: ['GeneralAndAdministrativeExpense'], labelIncludes: ['general and administrative'] },
|
||||||
{ key: 'interest_expense', label: 'Interest Expense', category: 'non_operating', order: 120, unit: 'currency', localNames: ['InterestExpense', 'InterestAndDebtExpense'], labelIncludes: ['interest expense'] },
|
{ key: 'operating_margin', label: 'Operating Margin', category: 'margin', order: 120, unit: 'percent', labelIncludes: ['operating margin'] },
|
||||||
{ 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: 'interest_income', label: 'Interest Income', category: 'non_operating', order: 130, unit: 'currency', localNames: ['InterestIncomeExpenseNonoperatingNet', 'InterestIncomeOther', 'InvestmentIncomeInterest'], labelIncludes: ['interest income'] },
|
||||||
{ key: 'pretax_income', label: 'Pretax Income', category: 'profit', order: 140, unit: 'currency', localNames: ['IncomeBeforeTaxExpenseBenefit', 'PretaxIncome'], labelIncludes: ['income before taxes', 'pretax income'] },
|
{ key: 'interest_expense', label: 'Interest Expense', category: 'non_operating', order: 140, unit: 'currency', localNames: ['InterestExpense', 'InterestAndDebtExpense'], labelIncludes: ['interest expense'] },
|
||||||
{ key: 'income_tax_expense', label: 'Income Tax Expense', category: 'tax', order: 150, unit: 'currency', localNames: ['IncomeTaxExpenseBenefit'], labelIncludes: ['income tax'] },
|
{ 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: 'effective_tax_rate', label: 'Effective Tax Rate', category: 'tax', order: 160, unit: 'percent', labelIncludes: ['effective tax rate'] },
|
{ key: 'pretax_income', label: 'Pretax Income', category: 'profit', order: 160, unit: 'currency', localNames: ['IncomeBeforeTaxExpenseBenefit', 'PretaxIncome'], labelIncludes: ['income before taxes', 'pretax income'] },
|
||||||
{ key: 'net_income', label: 'Net Income', category: 'profit', order: 170, unit: 'currency', localNames: ['NetIncomeLoss', 'ProfitLoss'], labelIncludes: ['net income'] },
|
{ key: 'income_tax_expense', label: 'Income Tax Expense', category: 'tax', order: 170, unit: 'currency', localNames: ['IncomeTaxExpenseBenefit'], labelIncludes: ['income tax'] },
|
||||||
{ key: 'diluted_eps', label: 'Diluted EPS', category: 'per_share', order: 180, unit: 'currency', localNames: ['EarningsPerShareDiluted', 'DilutedEarningsPerShare'], labelIncludes: ['diluted eps', 'diluted earnings per share'] },
|
{ key: 'effective_tax_rate', label: 'Effective Tax Rate', category: 'tax', order: 180, unit: 'percent', labelIncludes: ['effective tax rate'] },
|
||||||
{ key: 'basic_eps', label: 'Basic EPS', category: 'per_share', order: 190, unit: 'currency', localNames: ['EarningsPerShareBasic', 'BasicEarningsPerShare'], labelIncludes: ['basic eps', 'basic earnings per share'] },
|
{ key: 'net_income', label: 'Net Income', category: 'profit', order: 190, unit: 'currency', localNames: ['NetIncomeLoss', 'ProfitLoss'], labelIncludes: ['net income'] },
|
||||||
{ key: 'diluted_shares', label: 'Diluted Shares', category: 'shares', order: 200, unit: 'shares', localNames: ['WeightedAverageNumberOfDilutedSharesOutstanding', 'WeightedAverageNumberOfShareOutstandingDiluted'], labelIncludes: ['diluted shares', 'weighted average diluted'] },
|
{ 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_shares', label: 'Basic Shares', category: 'shares', order: 210, unit: 'shares', localNames: ['WeightedAverageNumberOfSharesOutstandingBasic', 'WeightedAverageNumberOfShareOutstandingBasicAndDiluted'], labelIncludes: ['basic shares', 'weighted average basic'] },
|
{ key: 'basic_eps', label: 'Basic EPS', category: 'per_share', order: 210, unit: 'currency', localNames: ['EarningsPerShareBasic', 'BasicEarningsPerShare'], labelIncludes: ['basic eps', 'basic earnings per share'] },
|
||||||
{ key: 'depreciation_and_amortization', label: 'Depreciation & Amortization', category: 'non_cash', order: 220, unit: 'currency', localNames: ['DepreciationDepletionAndAmortization', 'DepreciationAmortizationAndAccretionNet', 'DepreciationAndAmortization'], labelIncludes: ['depreciation', 'amortization'] },
|
{ key: 'diluted_shares', label: 'Diluted Shares', category: 'shares', order: 220, unit: 'shares', localNames: ['WeightedAverageNumberOfDilutedSharesOutstanding', 'WeightedAverageNumberOfShareOutstandingDiluted'], labelIncludes: ['diluted shares', 'weighted average diluted'] },
|
||||||
{ key: 'ebitda', label: 'EBITDA', category: 'profit', order: 230, unit: 'currency', localNames: ['OperatingIncomeLoss'], labelIncludes: ['ebitda'] },
|
{ key: 'basic_shares', label: 'Basic Shares', category: 'shares', order: 230, unit: 'shares', localNames: ['WeightedAverageNumberOfSharesOutstandingBasic', 'WeightedAverageNumberOfShareOutstandingBasicAndDiluted'], labelIncludes: ['basic shares', 'weighted average basic'] },
|
||||||
{ 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: '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[];
|
] as const satisfies StatementMetricDefinition[];
|
||||||
|
|
||||||
export const BALANCE_SHEET_METRIC_DEFINITIONS: StatementMetricDefinition[] = [
|
export const BALANCE_SHEET_METRIC_DEFINITIONS: StatementMetricDefinition[] = [
|
||||||
|
|||||||
213
lib/financials/statement-view-model.test.ts
Normal file
213
lib/financials/statement-view-model.test.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import {
|
||||||
|
buildStatementTree,
|
||||||
|
resolveStatementSelection
|
||||||
|
} from '@/lib/financials/statement-view-model';
|
||||||
|
import type { DetailFinancialRow, SurfaceFinancialRow } from '@/lib/types';
|
||||||
|
|
||||||
|
function createSurfaceRow(input: Partial<SurfaceFinancialRow> & Pick<SurfaceFinancialRow, 'key' | 'label' | 'values'>): SurfaceFinancialRow {
|
||||||
|
return {
|
||||||
|
key: input.key,
|
||||||
|
label: input.label,
|
||||||
|
category: input.category ?? 'revenue',
|
||||||
|
order: input.order ?? 10,
|
||||||
|
unit: input.unit ?? 'currency',
|
||||||
|
values: input.values,
|
||||||
|
sourceConcepts: input.sourceConcepts ?? [input.key],
|
||||||
|
sourceRowKeys: input.sourceRowKeys ?? [input.key],
|
||||||
|
sourceFactIds: input.sourceFactIds ?? [1],
|
||||||
|
formulaKey: input.formulaKey ?? null,
|
||||||
|
hasDimensions: input.hasDimensions ?? false,
|
||||||
|
resolvedSourceRowKeys: input.resolvedSourceRowKeys ?? Object.fromEntries(Object.keys(input.values).map((periodId) => [periodId, input.key])),
|
||||||
|
statement: input.statement ?? 'income',
|
||||||
|
detailCount: input.detailCount,
|
||||||
|
resolutionMethod: input.resolutionMethod,
|
||||||
|
confidence: input.confidence,
|
||||||
|
warningCodes: input.warningCodes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDetailRow(input: Partial<DetailFinancialRow> & Pick<DetailFinancialRow, 'key' | 'label' | 'parentSurfaceKey' | 'values'>): DetailFinancialRow {
|
||||||
|
return {
|
||||||
|
key: input.key,
|
||||||
|
parentSurfaceKey: input.parentSurfaceKey,
|
||||||
|
label: input.label,
|
||||||
|
conceptKey: input.conceptKey ?? input.key,
|
||||||
|
qname: input.qname ?? `us-gaap:${input.key}`,
|
||||||
|
namespaceUri: input.namespaceUri ?? 'http://fasb.org/us-gaap/2024',
|
||||||
|
localName: input.localName ?? input.key,
|
||||||
|
unit: input.unit ?? 'USD',
|
||||||
|
values: input.values,
|
||||||
|
sourceFactIds: input.sourceFactIds ?? [100],
|
||||||
|
isExtension: input.isExtension ?? false,
|
||||||
|
dimensionsSummary: input.dimensionsSummary ?? [],
|
||||||
|
residualFlag: input.residualFlag ?? false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('statement view model', () => {
|
||||||
|
const categories = [{ key: 'opex', label: 'Operating Expenses', count: 4 }];
|
||||||
|
|
||||||
|
it('builds a root-only tree when there are no configured children or details', () => {
|
||||||
|
const model = buildStatementTree({
|
||||||
|
surfaceKind: 'income_statement',
|
||||||
|
rows: [
|
||||||
|
createSurfaceRow({ key: 'revenue', label: 'Revenue', category: 'revenue', values: { p1: 100 } })
|
||||||
|
],
|
||||||
|
statementDetails: null,
|
||||||
|
categories: [],
|
||||||
|
searchQuery: '',
|
||||||
|
expandedRowKeys: new Set()
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.sections).toHaveLength(1);
|
||||||
|
expect(model.sections[0]?.nodes[0]).toMatchObject({
|
||||||
|
kind: 'surface',
|
||||||
|
row: { key: 'revenue' },
|
||||||
|
expandable: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nests the operating expense child surfaces under the parent row', () => {
|
||||||
|
const model = buildStatementTree({
|
||||||
|
surfaceKind: 'income_statement',
|
||||||
|
rows: [
|
||||||
|
createSurfaceRow({ key: 'operating_expenses', label: 'Operating Expenses', category: 'opex', order: 10, values: { p1: 40 } }),
|
||||||
|
createSurfaceRow({ key: 'selling_general_and_administrative', label: 'SG&A', category: 'opex', order: 20, values: { p1: 20 } }),
|
||||||
|
createSurfaceRow({ key: 'research_and_development', label: 'Research Expense', category: 'opex', order: 30, values: { p1: 12 } }),
|
||||||
|
createSurfaceRow({ key: 'other_operating_expense', label: 'Other Expense', category: 'opex', order: 40, values: { p1: 8 } })
|
||||||
|
],
|
||||||
|
statementDetails: null,
|
||||||
|
categories,
|
||||||
|
searchQuery: '',
|
||||||
|
expandedRowKeys: new Set(['operating_expenses'])
|
||||||
|
});
|
||||||
|
|
||||||
|
const parent = model.sections[0]?.nodes[0];
|
||||||
|
expect(parent?.kind).toBe('surface');
|
||||||
|
const childKeys = parent?.kind === 'surface'
|
||||||
|
? parent.children.map((node) => node.row.key)
|
||||||
|
: [];
|
||||||
|
expect(childKeys).toEqual([
|
||||||
|
'selling_general_and_administrative',
|
||||||
|
'research_and_development',
|
||||||
|
'other_operating_expense'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nests raw detail rows under the matching child surface row', () => {
|
||||||
|
const model = buildStatementTree({
|
||||||
|
surfaceKind: 'income_statement',
|
||||||
|
rows: [
|
||||||
|
createSurfaceRow({ key: 'operating_expenses', label: 'Operating Expenses', category: 'opex', order: 10, values: { p1: 40 } }),
|
||||||
|
createSurfaceRow({ key: 'selling_general_and_administrative', label: 'SG&A', category: 'opex', order: 20, values: { p1: 20 } })
|
||||||
|
],
|
||||||
|
statementDetails: {
|
||||||
|
selling_general_and_administrative: [
|
||||||
|
createDetailRow({ key: 'corporate_sga', label: 'Corporate SG&A', parentSurfaceKey: 'selling_general_and_administrative', values: { p1: 20 } })
|
||||||
|
]
|
||||||
|
},
|
||||||
|
categories,
|
||||||
|
searchQuery: '',
|
||||||
|
expandedRowKeys: new Set(['operating_expenses', 'selling_general_and_administrative'])
|
||||||
|
});
|
||||||
|
|
||||||
|
const child = model.sections[0]?.nodes[0];
|
||||||
|
expect(child?.kind).toBe('surface');
|
||||||
|
const sgaNode = child?.kind === 'surface' ? child.children[0] : null;
|
||||||
|
expect(sgaNode?.kind).toBe('surface');
|
||||||
|
const detailNode = sgaNode?.kind === 'surface' ? sgaNode.children[0] : null;
|
||||||
|
expect(detailNode).toMatchObject({
|
||||||
|
kind: 'detail',
|
||||||
|
row: { key: 'corporate_sga' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auto-expands the parent chain when search matches a child surface or detail row', () => {
|
||||||
|
const rows = [
|
||||||
|
createSurfaceRow({ key: 'operating_expenses', label: 'Operating Expenses', category: 'opex', order: 10, values: { p1: 40 } }),
|
||||||
|
createSurfaceRow({ key: 'research_and_development', label: 'Research Expense', category: 'opex', order: 20, values: { p1: 12 } })
|
||||||
|
];
|
||||||
|
|
||||||
|
const childSearch = buildStatementTree({
|
||||||
|
surfaceKind: 'income_statement',
|
||||||
|
rows,
|
||||||
|
statementDetails: null,
|
||||||
|
categories,
|
||||||
|
searchQuery: 'research',
|
||||||
|
expandedRowKeys: new Set()
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(childSearch.autoExpandedKeys.has('operating_expenses')).toBe(true);
|
||||||
|
expect(childSearch.sections[0]?.nodes[0]?.kind === 'surface' && childSearch.sections[0]?.nodes[0].expanded).toBe(true);
|
||||||
|
|
||||||
|
const detailSearch = buildStatementTree({
|
||||||
|
surfaceKind: 'income_statement',
|
||||||
|
rows,
|
||||||
|
statementDetails: {
|
||||||
|
research_and_development: [
|
||||||
|
createDetailRow({ key: 'ai_lab_expense', label: 'AI Lab Expense', parentSurfaceKey: 'research_and_development', values: { p1: 12 } })
|
||||||
|
]
|
||||||
|
},
|
||||||
|
categories,
|
||||||
|
searchQuery: 'ai lab',
|
||||||
|
expandedRowKeys: new Set()
|
||||||
|
});
|
||||||
|
|
||||||
|
const parent = detailSearch.sections[0]?.nodes[0];
|
||||||
|
const child = parent?.kind === 'surface' ? parent.children[0] : null;
|
||||||
|
expect(parent?.kind === 'surface' && parent.expanded).toBe(true);
|
||||||
|
expect(child?.kind === 'surface' && child.expanded).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps not meaningful rows visible and resolves selections for surface and detail nodes', () => {
|
||||||
|
const rows = [
|
||||||
|
createSurfaceRow({
|
||||||
|
key: 'gross_profit',
|
||||||
|
label: 'Gross Profit',
|
||||||
|
category: 'profit',
|
||||||
|
values: { p1: null },
|
||||||
|
resolutionMethod: 'not_meaningful',
|
||||||
|
warningCodes: ['gross_profit_not_meaningful_bank_pack']
|
||||||
|
})
|
||||||
|
];
|
||||||
|
const details = {
|
||||||
|
gross_profit: [
|
||||||
|
createDetailRow({ key: 'gp_unmapped', label: 'Gross Profit residual', parentSurfaceKey: 'gross_profit', values: { p1: null }, residualFlag: true })
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const model = buildStatementTree({
|
||||||
|
surfaceKind: 'income_statement',
|
||||||
|
rows,
|
||||||
|
statementDetails: details,
|
||||||
|
categories: [],
|
||||||
|
searchQuery: '',
|
||||||
|
expandedRowKeys: new Set(['gross_profit'])
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.sections[0]?.nodes[0]).toMatchObject({
|
||||||
|
kind: 'surface',
|
||||||
|
row: { key: 'gross_profit', resolutionMethod: 'not_meaningful' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const surfaceSelection = resolveStatementSelection({
|
||||||
|
surfaceKind: 'income_statement',
|
||||||
|
rows,
|
||||||
|
statementDetails: details,
|
||||||
|
selection: { kind: 'surface', key: 'gross_profit' }
|
||||||
|
});
|
||||||
|
expect(surfaceSelection?.kind).toBe('surface');
|
||||||
|
|
||||||
|
const detailSelection = resolveStatementSelection({
|
||||||
|
surfaceKind: 'income_statement',
|
||||||
|
rows,
|
||||||
|
statementDetails: details,
|
||||||
|
selection: { kind: 'detail', key: 'gp_unmapped', parentKey: 'gross_profit' }
|
||||||
|
});
|
||||||
|
expect(detailSelection).toMatchObject({
|
||||||
|
kind: 'detail',
|
||||||
|
row: { key: 'gp_unmapped', residualFlag: true }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
314
lib/financials/statement-view-model.ts
Normal file
314
lib/financials/statement-view-model.ts
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
import type {
|
||||||
|
DetailFinancialRow,
|
||||||
|
FinancialCategory,
|
||||||
|
FinancialSurfaceKind,
|
||||||
|
SurfaceDetailMap,
|
||||||
|
SurfaceFinancialRow
|
||||||
|
} from '@/lib/types';
|
||||||
|
|
||||||
|
const SURFACE_CHILDREN: Partial<Record<Extract<FinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>, Record<string, string[]>>> = {
|
||||||
|
income_statement: {
|
||||||
|
operating_expenses: [
|
||||||
|
'selling_general_and_administrative',
|
||||||
|
'research_and_development',
|
||||||
|
'other_operating_expense'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StatementInspectorSelection = {
|
||||||
|
kind: 'surface' | 'detail';
|
||||||
|
key: string;
|
||||||
|
parentKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StatementTreeDetailNode = {
|
||||||
|
kind: 'detail';
|
||||||
|
id: string;
|
||||||
|
level: number;
|
||||||
|
row: DetailFinancialRow;
|
||||||
|
parentSurfaceKey: string;
|
||||||
|
matchesSearch: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StatementTreeSurfaceNode = {
|
||||||
|
kind: 'surface';
|
||||||
|
id: string;
|
||||||
|
level: number;
|
||||||
|
row: SurfaceFinancialRow;
|
||||||
|
childSurfaceKeys: string[];
|
||||||
|
directDetailCount: number;
|
||||||
|
children: StatementTreeNode[];
|
||||||
|
expandable: boolean;
|
||||||
|
expanded: boolean;
|
||||||
|
autoExpanded: boolean;
|
||||||
|
matchesSearch: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StatementTreeNode = StatementTreeSurfaceNode | StatementTreeDetailNode;
|
||||||
|
|
||||||
|
export type StatementTreeSection = {
|
||||||
|
key: string;
|
||||||
|
label: string | null;
|
||||||
|
nodes: StatementTreeNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StatementTreeModel = {
|
||||||
|
sections: StatementTreeSection[];
|
||||||
|
autoExpandedKeys: Set<string>;
|
||||||
|
visibleNodeCount: number;
|
||||||
|
totalNodeCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolvedStatementSelection =
|
||||||
|
| {
|
||||||
|
kind: 'surface';
|
||||||
|
row: SurfaceFinancialRow;
|
||||||
|
childSurfaceRows: SurfaceFinancialRow[];
|
||||||
|
detailRows: DetailFinancialRow[];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'detail';
|
||||||
|
row: DetailFinancialRow;
|
||||||
|
parentSurfaceRow: SurfaceFinancialRow | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Categories = Array<{
|
||||||
|
key: FinancialCategory;
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
function surfaceConfigForKind(surfaceKind: Extract<FinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>) {
|
||||||
|
return SURFACE_CHILDREN[surfaceKind] ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailNodeId(parentKey: string, row: DetailFinancialRow) {
|
||||||
|
return `detail:${parentKey}:${row.key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(value: string) {
|
||||||
|
return value.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchTextForSurface(row: SurfaceFinancialRow) {
|
||||||
|
return [
|
||||||
|
row.label,
|
||||||
|
row.key,
|
||||||
|
...row.sourceConcepts,
|
||||||
|
...row.sourceRowKeys,
|
||||||
|
...(row.warningCodes ?? [])
|
||||||
|
]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchTextForDetail(row: DetailFinancialRow) {
|
||||||
|
return [
|
||||||
|
row.label,
|
||||||
|
row.key,
|
||||||
|
row.parentSurfaceKey,
|
||||||
|
row.conceptKey,
|
||||||
|
row.qname,
|
||||||
|
row.localName,
|
||||||
|
...row.dimensionsSummary
|
||||||
|
]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortSurfaceRows(left: SurfaceFinancialRow, right: SurfaceFinancialRow) {
|
||||||
|
if (left.order !== right.order) {
|
||||||
|
return left.order - right.order;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.label.localeCompare(right.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortDetailRows(left: DetailFinancialRow, right: DetailFinancialRow) {
|
||||||
|
return left.label.localeCompare(right.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function countNodes(nodes: StatementTreeNode[]) {
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
count += 1;
|
||||||
|
if (node.kind === 'surface') {
|
||||||
|
count += countNodes(node.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStatementTree(input: {
|
||||||
|
surfaceKind: Extract<FinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>;
|
||||||
|
rows: SurfaceFinancialRow[];
|
||||||
|
statementDetails: SurfaceDetailMap | null;
|
||||||
|
categories: Categories;
|
||||||
|
searchQuery: string;
|
||||||
|
expandedRowKeys: Set<string>;
|
||||||
|
}): StatementTreeModel {
|
||||||
|
const config = surfaceConfigForKind(input.surfaceKind);
|
||||||
|
const rowByKey = new Map(input.rows.map((row) => [row.key, row]));
|
||||||
|
const childKeySet = new Set<string>();
|
||||||
|
|
||||||
|
for (const children of Object.values(config)) {
|
||||||
|
for (const childKey of children) {
|
||||||
|
if (rowByKey.has(childKey)) {
|
||||||
|
childKeySet.add(childKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSearch = normalize(input.searchQuery);
|
||||||
|
const autoExpandedKeys = new Set<string>();
|
||||||
|
|
||||||
|
const buildSurfaceNode = (row: SurfaceFinancialRow, level: number): StatementTreeSurfaceNode | null => {
|
||||||
|
const childSurfaceRows = (config[row.key] ?? [])
|
||||||
|
.map((key) => rowByKey.get(key))
|
||||||
|
.filter((candidate): candidate is SurfaceFinancialRow => Boolean(candidate))
|
||||||
|
.sort(sortSurfaceRows);
|
||||||
|
const detailRows = [...(input.statementDetails?.[row.key] ?? [])].sort(sortDetailRows);
|
||||||
|
const childSurfaceNodes = childSurfaceRows
|
||||||
|
.map((childRow) => buildSurfaceNode(childRow, level + 1))
|
||||||
|
.filter((node): node is StatementTreeSurfaceNode => Boolean(node));
|
||||||
|
const detailNodes = detailRows
|
||||||
|
.filter((detail) => normalizedSearch.length === 0 || searchTextForDetail(detail).includes(normalizedSearch))
|
||||||
|
.map((detail) => ({
|
||||||
|
kind: 'detail',
|
||||||
|
id: detailNodeId(row.key, detail),
|
||||||
|
level: level + 1,
|
||||||
|
row: detail,
|
||||||
|
parentSurfaceKey: row.key,
|
||||||
|
matchesSearch: normalizedSearch.length > 0 && searchTextForDetail(detail).includes(normalizedSearch)
|
||||||
|
}) satisfies StatementTreeDetailNode);
|
||||||
|
const children = [...childSurfaceNodes, ...detailNodes];
|
||||||
|
const matchesSearch = normalizedSearch.length > 0 && searchTextForSurface(row).includes(normalizedSearch);
|
||||||
|
const hasMatchingDescendant = normalizedSearch.length > 0 && children.length > 0;
|
||||||
|
|
||||||
|
if (normalizedSearch.length > 0 && !matchesSearch && !hasMatchingDescendant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const childSurfaceKeys = childSurfaceRows.map((candidate) => candidate.key);
|
||||||
|
const directDetailCount = detailRows.length;
|
||||||
|
const autoExpanded = normalizedSearch.length > 0 && !matchesSearch && children.length > 0;
|
||||||
|
const expanded = children.length > 0 && (input.expandedRowKeys.has(row.key) || autoExpanded);
|
||||||
|
|
||||||
|
if (autoExpanded) {
|
||||||
|
autoExpandedKeys.add(row.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'surface',
|
||||||
|
id: row.key,
|
||||||
|
level,
|
||||||
|
row,
|
||||||
|
childSurfaceKeys,
|
||||||
|
directDetailCount,
|
||||||
|
children,
|
||||||
|
expandable: children.length > 0,
|
||||||
|
expanded,
|
||||||
|
autoExpanded,
|
||||||
|
matchesSearch
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootNodes = input.rows
|
||||||
|
.filter((row) => !childKeySet.has(row.key))
|
||||||
|
.sort(sortSurfaceRows)
|
||||||
|
.map((row) => buildSurfaceNode(row, 0))
|
||||||
|
.filter((node): node is StatementTreeSurfaceNode => Boolean(node));
|
||||||
|
|
||||||
|
if (input.categories.length === 0) {
|
||||||
|
return {
|
||||||
|
sections: [{ key: 'ungrouped', label: null, nodes: rootNodes }],
|
||||||
|
autoExpandedKeys,
|
||||||
|
visibleNodeCount: countNodes(rootNodes),
|
||||||
|
totalNodeCount: input.rows.length + Object.values(input.statementDetails ?? {}).reduce((sum, rows) => sum + rows.length, 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections: StatementTreeSection[] = [];
|
||||||
|
const categoriesByKey = new Map(input.categories.map((category) => [category.key, category.label]));
|
||||||
|
|
||||||
|
for (const category of input.categories) {
|
||||||
|
const nodes = rootNodes.filter((node) => node.row.category === category.key);
|
||||||
|
if (nodes.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
key: category.key,
|
||||||
|
label: category.label,
|
||||||
|
nodes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uncategorized = rootNodes.filter((node) => !categoriesByKey.has(node.row.category));
|
||||||
|
if (uncategorized.length > 0) {
|
||||||
|
sections.push({
|
||||||
|
key: 'uncategorized',
|
||||||
|
label: null,
|
||||||
|
nodes: uncategorized
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sections,
|
||||||
|
autoExpandedKeys,
|
||||||
|
visibleNodeCount: sections.reduce((sum, section) => sum + countNodes(section.nodes), 0),
|
||||||
|
totalNodeCount: input.rows.length + Object.values(input.statementDetails ?? {}).reduce((sum, rows) => sum + rows.length, 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveStatementSelection(input: {
|
||||||
|
surfaceKind: Extract<FinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>;
|
||||||
|
rows: SurfaceFinancialRow[];
|
||||||
|
statementDetails: SurfaceDetailMap | null;
|
||||||
|
selection: StatementInspectorSelection | null;
|
||||||
|
}): ResolvedStatementSelection | null {
|
||||||
|
const selection = input.selection;
|
||||||
|
|
||||||
|
if (!selection) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowByKey = new Map(input.rows.map((row) => [row.key, row]));
|
||||||
|
const config = surfaceConfigForKind(input.surfaceKind);
|
||||||
|
|
||||||
|
if (selection.kind === 'surface') {
|
||||||
|
const row = rowByKey.get(selection.key);
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const childSurfaceRows = (config[row.key] ?? [])
|
||||||
|
.map((key) => rowByKey.get(key))
|
||||||
|
.filter((candidate): candidate is SurfaceFinancialRow => Boolean(candidate))
|
||||||
|
.sort(sortSurfaceRows);
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'surface',
|
||||||
|
row,
|
||||||
|
childSurfaceRows,
|
||||||
|
detailRows: [...(input.statementDetails?.[row.key] ?? [])].sort(sortDetailRows)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentSurfaceKey = selection.parentKey ?? null;
|
||||||
|
const detailRows = parentSurfaceKey
|
||||||
|
? input.statementDetails?.[parentSurfaceKey] ?? []
|
||||||
|
: Object.values(input.statementDetails ?? {}).flat();
|
||||||
|
const row = detailRows.find((candidate) => candidate.key === selection.key) ?? null;
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'detail',
|
||||||
|
row,
|
||||||
|
parentSurfaceRow: rowByKey.get(row.parentSurfaceKey) ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -36,6 +36,15 @@ describe('graphing catalog', () => {
|
|||||||
expect(quarterlyMetricKeys).toContain('gross_margin');
|
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', () => {
|
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'));
|
const state = parseGraphingParams(new URLSearchParams('surface=ratios&cadence=quarterly&metric=5y_revenue_cagr&tickers=msft,aapl'));
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type {
|
|||||||
CompanyFinancialStatementsResponse,
|
CompanyFinancialStatementsResponse,
|
||||||
FinancialStatementPeriod,
|
FinancialStatementPeriod,
|
||||||
RatioRow,
|
RatioRow,
|
||||||
StandardizedFinancialRow
|
SurfaceFinancialRow
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
|
|
||||||
function createPeriod(input: {
|
function createPeriod(input: {
|
||||||
@@ -26,7 +26,7 @@ function createPeriod(input: {
|
|||||||
} satisfies FinancialStatementPeriod;
|
} 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 {
|
return {
|
||||||
key,
|
key,
|
||||||
label: key,
|
label: key,
|
||||||
@@ -39,8 +39,9 @@ function createStatementRow(key: string, values: Record<string, number | null>,
|
|||||||
sourceFactIds: [1],
|
sourceFactIds: [1],
|
||||||
formulaKey: null,
|
formulaKey: null,
|
||||||
hasDimensions: false,
|
hasDimensions: false,
|
||||||
resolvedSourceRowKeys: Object.fromEntries(Object.keys(values).map((periodId) => [periodId, key]))
|
resolvedSourceRowKeys: Object.fromEntries(Object.keys(values).map((periodId) => [periodId, key])),
|
||||||
} satisfies StandardizedFinancialRow;
|
statement: 'income'
|
||||||
|
} satisfies SurfaceFinancialRow;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRatioRow(key: string, values: Record<string, number | null>, unit: RatioRow['unit'] = 'percent') {
|
function createRatioRow(key: string, values: Record<string, number | null>, unit: RatioRow['unit'] = 'percent') {
|
||||||
@@ -54,8 +55,9 @@ function createFinancials(input: {
|
|||||||
ticker: string;
|
ticker: string;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
periods: FinancialStatementPeriod[];
|
periods: FinancialStatementPeriod[];
|
||||||
statementRows?: StandardizedFinancialRow[];
|
statementRows?: SurfaceFinancialRow[];
|
||||||
ratioRows?: RatioRow[];
|
ratioRows?: RatioRow[];
|
||||||
|
fiscalPack?: string | null;
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
company: {
|
company: {
|
||||||
@@ -72,6 +74,7 @@ function createFinancials(input: {
|
|||||||
faithful: [],
|
faithful: [],
|
||||||
standardized: input.statementRows ?? []
|
standardized: input.statementRows ?? []
|
||||||
},
|
},
|
||||||
|
statementDetails: null,
|
||||||
ratioRows: input.ratioRows ?? [],
|
ratioRows: input.ratioRows ?? [],
|
||||||
kpiRows: null,
|
kpiRows: null,
|
||||||
trendSeries: [],
|
trendSeries: [],
|
||||||
@@ -100,6 +103,13 @@ function createFinancials(input: {
|
|||||||
taxonomy: null,
|
taxonomy: null,
|
||||||
validation: null
|
validation: null
|
||||||
},
|
},
|
||||||
|
normalization: {
|
||||||
|
regime: 'unknown',
|
||||||
|
fiscalPack: input.fiscalPack ?? null,
|
||||||
|
parserVersion: '0.0.0',
|
||||||
|
unmappedRowCount: 0,
|
||||||
|
materialUnmappedRowCount: 0
|
||||||
|
},
|
||||||
dimensionBreakdown: null
|
dimensionBreakdown: null
|
||||||
} satisfies CompanyFinancialStatementsResponse;
|
} satisfies CompanyFinancialStatementsResponse;
|
||||||
}
|
}
|
||||||
@@ -194,6 +204,37 @@ describe('graphing series', () => {
|
|||||||
expect(data.hasAnyData).toBe(false);
|
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', () => {
|
it('derives latest and prior values for the summary table', () => {
|
||||||
const data = buildGraphingComparisonData({
|
const data = buildGraphingComparisonData({
|
||||||
surface: 'income_statement',
|
surface: 'income_statement',
|
||||||
@@ -216,6 +257,7 @@ describe('graphing series', () => {
|
|||||||
|
|
||||||
expect(data.latestRows[0]).toMatchObject({
|
expect(data.latestRows[0]).toMatchObject({
|
||||||
ticker: 'AMD',
|
ticker: 'AMD',
|
||||||
|
fiscalPack: null,
|
||||||
latestValue: 70,
|
latestValue: 70,
|
||||||
priorValue: 50,
|
priorValue: 50,
|
||||||
changeValue: 20,
|
changeValue: 20,
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ export type GraphingSeriesPoint = {
|
|||||||
export type GraphingCompanySeries = {
|
export type GraphingCompanySeries = {
|
||||||
ticker: string;
|
ticker: string;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
status: 'ready' | 'error' | 'no_metric_data';
|
fiscalPack: string | null;
|
||||||
|
status: 'ready' | 'error' | 'no_metric_data' | 'not_meaningful';
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
unit: FinancialUnit | null;
|
unit: FinancialUnit | null;
|
||||||
points: GraphingSeriesPoint[];
|
points: GraphingSeriesPoint[];
|
||||||
@@ -38,6 +39,7 @@ export type GraphingCompanySeries = {
|
|||||||
export type GraphingLatestValueRow = {
|
export type GraphingLatestValueRow = {
|
||||||
ticker: string;
|
ticker: string;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
|
fiscalPack: string | null;
|
||||||
status: GraphingCompanySeries['status'];
|
status: GraphingCompanySeries['status'];
|
||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
latestValue: number | null;
|
latestValue: number | null;
|
||||||
@@ -86,6 +88,7 @@ function extractCompanySeries(
|
|||||||
return {
|
return {
|
||||||
ticker: result.ticker,
|
ticker: result.ticker,
|
||||||
companyName: result.ticker,
|
companyName: result.ticker,
|
||||||
|
fiscalPack: null,
|
||||||
status: 'error',
|
status: 'error',
|
||||||
errorMessage: result.error ?? 'Unable to load financial history',
|
errorMessage: result.error ?? 'Unable to load financial history',
|
||||||
unit: null,
|
unit: null,
|
||||||
@@ -97,6 +100,10 @@ function extractCompanySeries(
|
|||||||
|
|
||||||
const metricRow = extractMetricRow(result.financials, surface, metric);
|
const metricRow = extractMetricRow(result.financials, surface, metric);
|
||||||
const periods = [...result.financials.periods].sort(sortPeriods);
|
const periods = [...result.financials.periods].sort(sortPeriods);
|
||||||
|
const notMeaningful = surface !== 'ratios'
|
||||||
|
&& metricRow
|
||||||
|
&& 'resolutionMethod' in metricRow
|
||||||
|
&& metricRow.resolutionMethod === 'not_meaningful';
|
||||||
const points = periods.map((period) => ({
|
const points = periods.map((period) => ({
|
||||||
periodId: period.id,
|
periodId: period.id,
|
||||||
dateKey: period.periodEnd ?? period.filingDate,
|
dateKey: period.periodEnd ?? period.filingDate,
|
||||||
@@ -113,8 +120,9 @@ function extractCompanySeries(
|
|||||||
return {
|
return {
|
||||||
ticker: result.financials.company.ticker,
|
ticker: result.financials.company.ticker,
|
||||||
companyName: result.financials.company.companyName,
|
companyName: result.financials.company.companyName,
|
||||||
status: latestPoint ? 'ready' : 'no_metric_data',
|
fiscalPack: result.financials.normalization.fiscalPack,
|
||||||
errorMessage: latestPoint ? null : 'No data available for the selected metric.',
|
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,
|
unit: metricRow?.unit ?? null,
|
||||||
points,
|
points,
|
||||||
latestPoint,
|
latestPoint,
|
||||||
@@ -159,6 +167,7 @@ export function buildGraphingComparisonData(input: {
|
|||||||
const latestRows = companies.map((company) => ({
|
const latestRows = companies.map((company) => ({
|
||||||
ticker: company.ticker,
|
ticker: company.ticker,
|
||||||
companyName: company.companyName,
|
companyName: company.companyName,
|
||||||
|
fiscalPack: company.fiscalPack,
|
||||||
status: company.status,
|
status: company.status,
|
||||||
errorMessage: company.errorMessage,
|
errorMessage: company.errorMessage,
|
||||||
latestValue: company.latestPoint?.value ?? null,
|
latestValue: company.latestPoint?.value ?? null,
|
||||||
|
|||||||
Reference in New Issue
Block a user