feat(financials): add compact surface UI and graphing states
This commit is contained in:
@@ -22,6 +22,9 @@ import {
|
||||
RefreshCcw,
|
||||
Search
|
||||
} from 'lucide-react';
|
||||
import { NormalizationSummary } from '@/components/financials/normalization-summary';
|
||||
import { StatementMatrix } from '@/components/financials/statement-matrix';
|
||||
import { StatementRowInspector } from '@/components/financials/statement-row-inspector';
|
||||
import { AppShell } from '@/components/shell/app-shell';
|
||||
import {
|
||||
FinancialControlBar,
|
||||
@@ -42,10 +45,16 @@ import {
|
||||
type NumberScaleUnit
|
||||
} from '@/lib/format';
|
||||
import { buildGraphingHref } from '@/lib/graphing/catalog';
|
||||
import {
|
||||
buildStatementTree,
|
||||
resolveStatementSelection,
|
||||
type StatementInspectorSelection
|
||||
} from '@/lib/financials/statement-view-model';
|
||||
import { queryKeys } from '@/lib/query/keys';
|
||||
import { companyFinancialStatementsQueryOptions } from '@/lib/query/options';
|
||||
import type {
|
||||
CompanyFinancialStatementsResponse,
|
||||
DetailFinancialRow,
|
||||
DerivedFinancialRow,
|
||||
FinancialCadence,
|
||||
FinancialDisplayMode,
|
||||
@@ -54,6 +63,8 @@ import type {
|
||||
RatioRow,
|
||||
StandardizedFinancialRow,
|
||||
StructuredKpiRow,
|
||||
SurfaceDetailMap,
|
||||
SurfaceFinancialRow,
|
||||
TaxonomyStatementRow,
|
||||
TrendSeries
|
||||
} from '@/lib/types';
|
||||
@@ -63,7 +74,8 @@ type LoadOptions = {
|
||||
append?: boolean;
|
||||
};
|
||||
|
||||
type DisplayRow = TaxonomyStatementRow | StandardizedFinancialRow | RatioRow | StructuredKpiRow;
|
||||
type FlatDisplayRow = TaxonomyStatementRow | StandardizedFinancialRow | RatioRow | StructuredKpiRow;
|
||||
type StatementMatrixRow = SurfaceFinancialRow | DetailFinancialRow;
|
||||
|
||||
const FINANCIAL_VALUE_SCALE_OPTIONS: Array<{ value: NumberScaleUnit; label: string }> = [
|
||||
{ value: 'thousands', label: 'Thousands (K)' },
|
||||
@@ -106,19 +118,23 @@ function formatLongDate(value: string) {
|
||||
return format(parsed, 'MMM dd, yyyy');
|
||||
}
|
||||
|
||||
function isTaxonomyRow(row: DisplayRow): row is TaxonomyStatementRow {
|
||||
function isTaxonomyRow(row: FlatDisplayRow): row is TaxonomyStatementRow {
|
||||
return 'localName' in row;
|
||||
}
|
||||
|
||||
function isDerivedRow(row: DisplayRow): row is DerivedFinancialRow {
|
||||
function isDerivedRow(row: FlatDisplayRow): row is DerivedFinancialRow {
|
||||
return 'formulaKey' in row;
|
||||
}
|
||||
|
||||
function isKpiRow(row: DisplayRow): row is StructuredKpiRow {
|
||||
function isKpiRow(row: FlatDisplayRow): row is StructuredKpiRow {
|
||||
return 'provenanceType' in row;
|
||||
}
|
||||
|
||||
function rowValue(row: DisplayRow, periodId: string) {
|
||||
function rowValue(row: FlatDisplayRow, periodId: string) {
|
||||
return row.values[periodId] ?? null;
|
||||
}
|
||||
|
||||
function statementRowValue(row: StatementMatrixRow, periodId: string) {
|
||||
return row.values[periodId] ?? null;
|
||||
}
|
||||
|
||||
@@ -181,10 +197,10 @@ function chartTickFormatter(
|
||||
}
|
||||
|
||||
function buildDisplayValue(input: {
|
||||
row: DisplayRow;
|
||||
row: FlatDisplayRow;
|
||||
periodId: string;
|
||||
previousPeriodId: string | null;
|
||||
commonSizeRow: DisplayRow | null;
|
||||
commonSizeRow: FlatDisplayRow | null;
|
||||
displayMode: FinancialDisplayMode;
|
||||
showPercentChange: boolean;
|
||||
showCommonSize: boolean;
|
||||
@@ -245,7 +261,78 @@ function buildDisplayValue(input: {
|
||||
});
|
||||
}
|
||||
|
||||
function groupRows(rows: DisplayRow[], categories: CompanyFinancialStatementsResponse['categories']) {
|
||||
function unitFromDetailRow(row: DetailFinancialRow): FinancialUnit {
|
||||
const normalizedUnit = row.unit?.toLowerCase() ?? '';
|
||||
|
||||
if (normalizedUnit.includes('shares')) {
|
||||
return 'shares';
|
||||
}
|
||||
|
||||
if (normalizedUnit === 'pure' || normalizedUnit.includes('ratio')) {
|
||||
return 'ratio';
|
||||
}
|
||||
|
||||
if (normalizedUnit.includes('percent')) {
|
||||
return 'percent';
|
||||
}
|
||||
|
||||
return 'currency';
|
||||
}
|
||||
|
||||
function buildStatementTreeDisplayValue(input: {
|
||||
row: StatementMatrixRow;
|
||||
periodId: string;
|
||||
previousPeriodId: string | null;
|
||||
commonSizeRow: SurfaceFinancialRow | null;
|
||||
showPercentChange: boolean;
|
||||
showCommonSize: boolean;
|
||||
scale: NumberScaleUnit;
|
||||
surfaceKind: Extract<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) {
|
||||
return [{ label: null, rows }];
|
||||
}
|
||||
@@ -258,6 +345,42 @@ function groupRows(rows: DisplayRow[], categories: CompanyFinancialStatementsRes
|
||||
.filter((group) => group.rows.length > 0);
|
||||
}
|
||||
|
||||
function mergeDetailMaps(base: SurfaceDetailMap | null, next: SurfaceDetailMap | null) {
|
||||
if (!base) {
|
||||
return next;
|
||||
}
|
||||
|
||||
if (!next) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const merged: SurfaceDetailMap = structuredClone(base);
|
||||
|
||||
for (const [surfaceKey, detailRows] of Object.entries(next)) {
|
||||
const existingRows = merged[surfaceKey] ?? [];
|
||||
const rowMap = new Map(existingRows.map((row) => [row.key, row]));
|
||||
|
||||
for (const detailRow of detailRows) {
|
||||
const existing = rowMap.get(detailRow.key);
|
||||
if (!existing) {
|
||||
rowMap.set(detailRow.key, structuredClone(detailRow));
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.values = {
|
||||
...existing.values,
|
||||
...detailRow.values
|
||||
};
|
||||
existing.sourceFactIds = [...new Set([...existing.sourceFactIds, ...detailRow.sourceFactIds])];
|
||||
existing.dimensionsSummary = [...new Set([...existing.dimensionsSummary, ...detailRow.dimensionsSummary])];
|
||||
}
|
||||
|
||||
merged[surfaceKey] = [...rowMap.values()];
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function mergeFinancialPages(
|
||||
base: CompanyFinancialStatementsResponse | null,
|
||||
next: CompanyFinancialStatementsResponse
|
||||
@@ -297,6 +420,7 @@ function mergeFinancialPages(
|
||||
standardized: mergeRows([...base.statementRows.standardized, ...next.statementRows.standardized])
|
||||
}
|
||||
: next.statementRows,
|
||||
statementDetails: mergeDetailMaps(base.statementDetails, next.statementDetails),
|
||||
ratioRows: next.ratioRows && base.ratioRows ? mergeRows([...base.ratioRows, ...next.ratioRows]) : next.ratioRows,
|
||||
kpiRows: next.kpiRows && base.kpiRows ? mergeRows([...base.kpiRows, ...next.kpiRows]) : next.kpiRows,
|
||||
trendSeries: next.trendSeries,
|
||||
@@ -348,7 +472,9 @@ function FinancialsPageContent() {
|
||||
const [showPercentChange, setShowPercentChange] = useState(false);
|
||||
const [showCommonSize, setShowCommonSize] = useState(false);
|
||||
const [financials, setFinancials] = useState<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 [loadingMore, setLoadingMore] = useState(false);
|
||||
const [syncingFinancials, setSyncingFinancials] = useState(false);
|
||||
@@ -367,6 +493,9 @@ function FinancialsPageContent() {
|
||||
|
||||
setTickerInput(normalized);
|
||||
setTicker(normalized);
|
||||
setSelectedFlatRowKey(null);
|
||||
setSelectedRowRef(null);
|
||||
setExpandedRowKeys(new Set());
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -389,7 +518,8 @@ function FinancialsPageContent() {
|
||||
const loadFinancials = useCallback(async (symbol: string, options?: LoadOptions) => {
|
||||
const normalizedTicker = symbol.trim().toUpperCase();
|
||||
const nextCursor = options?.cursor ?? null;
|
||||
const includeDimensions = selectedRowKey !== null && (surfaceKind === 'segments_kpis' || surfaceKind === 'income_statement' || surfaceKind === 'balance_sheet' || surfaceKind === 'cash_flow_statement');
|
||||
const includeDimensions = (selectedFlatRowKey !== null || selectedRowRef !== null)
|
||||
&& (surfaceKind === 'segments_kpis' || surfaceKind === 'income_statement' || surfaceKind === 'balance_sheet' || surfaceKind === 'cash_flow_statement');
|
||||
|
||||
if (!options?.append) {
|
||||
setLoading(true);
|
||||
@@ -420,7 +550,7 @@ function FinancialsPageContent() {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [cadence, queryClient, selectedRowKey, surfaceKind]);
|
||||
}, [cadence, queryClient, selectedFlatRowKey, selectedRowRef, surfaceKind]);
|
||||
|
||||
const syncFinancials = useCallback(async () => {
|
||||
const targetTicker = (financials?.company.ticker ?? ticker).trim().toUpperCase();
|
||||
@@ -455,7 +585,9 @@ function FinancialsPageContent() {
|
||||
.sort((left, right) => Date.parse(left.periodEnd ?? left.filingDate) - Date.parse(right.periodEnd ?? right.filingDate));
|
||||
}, [financials?.periods]);
|
||||
|
||||
const activeRows = useMemo<DisplayRow[]>(() => {
|
||||
const isTreeStatementMode = displayMode === 'standardized' && isStatementSurfaceKind(surfaceKind);
|
||||
|
||||
const activeRows = useMemo<FlatDisplayRow[]>(() => {
|
||||
if (!financials) {
|
||||
return [];
|
||||
}
|
||||
@@ -477,35 +609,81 @@ function FinancialsPageContent() {
|
||||
}, [displayMode, financials, surfaceKind]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if (isTreeStatementMode) {
|
||||
return activeRows;
|
||||
}
|
||||
|
||||
const normalizedSearch = rowSearch.trim().toLowerCase();
|
||||
if (!normalizedSearch) {
|
||||
return activeRows;
|
||||
}
|
||||
|
||||
return activeRows.filter((row) => row.label.toLowerCase().includes(normalizedSearch));
|
||||
}, [activeRows, rowSearch]);
|
||||
}, [activeRows, isTreeStatementMode, rowSearch]);
|
||||
|
||||
const groupedRows = useMemo(() => {
|
||||
return groupRows(filteredRows, financials?.categories ?? []);
|
||||
}, [filteredRows, financials?.categories]);
|
||||
|
||||
const selectedRow = useMemo(() => {
|
||||
if (!selectedRowKey) {
|
||||
const treeModel = useMemo(() => {
|
||||
if (!isTreeStatementMode || !financials || !isStatementSurfaceKind(surfaceKind)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return activeRows.find((row) => row.key === selectedRowKey) ?? null;
|
||||
}, [activeRows, selectedRowKey]);
|
||||
return buildStatementTree({
|
||||
surfaceKind,
|
||||
rows: financials.statementRows?.standardized ?? [],
|
||||
statementDetails: financials.statementDetails,
|
||||
categories: financials.categories,
|
||||
searchQuery: rowSearch,
|
||||
expandedRowKeys
|
||||
});
|
||||
}, [expandedRowKeys, financials, isTreeStatementMode, rowSearch, surfaceKind]);
|
||||
|
||||
const selectedRow = useMemo(() => {
|
||||
if (!selectedFlatRowKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return activeRows.find((row) => row.key === selectedFlatRowKey) ?? null;
|
||||
}, [activeRows, selectedFlatRowKey]);
|
||||
|
||||
const selectedStatementRow = useMemo(() => {
|
||||
if (!isTreeStatementMode || !financials || !isStatementSurfaceKind(surfaceKind)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolveStatementSelection({
|
||||
surfaceKind,
|
||||
rows: financials.statementRows?.standardized ?? [],
|
||||
statementDetails: financials.statementDetails,
|
||||
selection: selectedRowRef
|
||||
});
|
||||
}, [financials, isTreeStatementMode, selectedRowRef, surfaceKind]);
|
||||
|
||||
const dimensionRows = useMemo(() => {
|
||||
if (!selectedRow || !financials?.dimensionBreakdown) {
|
||||
if (!financials?.dimensionBreakdown) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (selectedStatementRow?.kind === 'surface') {
|
||||
return financials.dimensionBreakdown[selectedStatementRow.row.key] ?? [];
|
||||
}
|
||||
|
||||
if (selectedStatementRow?.kind === 'detail') {
|
||||
return financials.dimensionBreakdown[selectedStatementRow.row.key]
|
||||
?? financials.dimensionBreakdown[selectedStatementRow.row.parentSurfaceKey]
|
||||
?? [];
|
||||
}
|
||||
|
||||
if (!selectedRow) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return financials.dimensionBreakdown[selectedRow.key] ?? [];
|
||||
}, [financials?.dimensionBreakdown, selectedRow]);
|
||||
}, [financials?.dimensionBreakdown, selectedRow, selectedStatementRow]);
|
||||
|
||||
const commonSizeRow = useMemo<DisplayRow | null>(() => {
|
||||
const commonSizeRow = useMemo<SurfaceFinancialRow | null>(() => {
|
||||
if (displayMode === 'faithful' || !financials?.statementRows) {
|
||||
return null;
|
||||
}
|
||||
@@ -539,7 +717,9 @@ function FinancialsPageContent() {
|
||||
options: SURFACE_OPTIONS,
|
||||
onChange: (value) => {
|
||||
setSurfaceKind(value as FinancialSurfaceKind);
|
||||
setSelectedRowKey(null);
|
||||
setSelectedFlatRowKey(null);
|
||||
setSelectedRowRef(null);
|
||||
setExpandedRowKeys(new Set());
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -549,7 +729,9 @@ function FinancialsPageContent() {
|
||||
options: CADENCE_OPTIONS,
|
||||
onChange: (value) => {
|
||||
setCadence(value as FinancialCadence);
|
||||
setSelectedRowKey(null);
|
||||
setSelectedFlatRowKey(null);
|
||||
setSelectedRowRef(null);
|
||||
setExpandedRowKeys(new Set());
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -562,7 +744,9 @@ function FinancialsPageContent() {
|
||||
options: DISPLAY_MODE_OPTIONS,
|
||||
onChange: (value) => {
|
||||
setDisplayMode(value as FinancialDisplayMode);
|
||||
setSelectedRowKey(null);
|
||||
setSelectedFlatRowKey(null);
|
||||
setSelectedRowRef(null);
|
||||
setExpandedRowKeys(new Set());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -626,6 +810,56 @@ function FinancialsPageContent() {
|
||||
return actions;
|
||||
}, [displayMode, financials?.nextCursor, loadFinancials, loadingMore, selectedRow, showCommonSize, showPercentChange, surfaceKind, ticker]);
|
||||
|
||||
const toggleExpandedRow = useCallback((key: string) => {
|
||||
setExpandedRowKeys((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const rowResultCountLabel = useMemo(() => {
|
||||
if (treeModel) {
|
||||
return `${treeModel.visibleNodeCount} of ${treeModel.totalNodeCount} visible rows`;
|
||||
}
|
||||
|
||||
return `${filteredRows.length} of ${activeRows.length} rows`;
|
||||
}, [activeRows.length, filteredRows.length, treeModel]);
|
||||
|
||||
const valueScaleLabel = FINANCIAL_VALUE_SCALE_OPTIONS.find((option) => option.value === valueScale)?.label ?? valueScale;
|
||||
|
||||
const renderStatementTreeCellValue = useCallback((row: StatementMatrixRow, periodId: string, previousPeriodId: string | null) => {
|
||||
if (!isStatementSurfaceKind(surfaceKind)) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return buildStatementTreeDisplayValue({
|
||||
row,
|
||||
periodId,
|
||||
previousPeriodId,
|
||||
commonSizeRow,
|
||||
showPercentChange,
|
||||
showCommonSize,
|
||||
scale: valueScale,
|
||||
surfaceKind
|
||||
});
|
||||
}, [commonSizeRow, showCommonSize, showPercentChange, surfaceKind, valueScale]);
|
||||
|
||||
const renderDimensionValue = useCallback((value: number | null, rowKey: string, unit: FinancialUnit) => {
|
||||
return formatMetricValue({
|
||||
value,
|
||||
unit,
|
||||
scale: valueScale,
|
||||
rowKey,
|
||||
surfaceKind
|
||||
});
|
||||
}, [surfaceKind, valueScale]);
|
||||
|
||||
if (isPending || !isAuthenticated) {
|
||||
return <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) {
|
||||
return;
|
||||
}
|
||||
setSelectedFlatRowKey(null);
|
||||
setSelectedRowRef(null);
|
||||
setExpandedRowKeys(new Set());
|
||||
setTicker(normalized);
|
||||
}}
|
||||
>
|
||||
@@ -726,7 +963,7 @@ function FinancialsPageContent() {
|
||||
className="w-full sm:max-w-sm"
|
||||
/>
|
||||
<span className="text-sm text-[color:var(--terminal-muted)]">
|
||||
{filteredRows.length} of {activeRows.length} rows
|
||||
{rowResultCountLabel}
|
||||
</span>
|
||||
</div>
|
||||
</Panel>
|
||||
@@ -788,6 +1025,10 @@ function FinancialsPageContent() {
|
||||
)}
|
||||
</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">
|
||||
{loading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading financial matrix...</p>
|
||||
@@ -796,158 +1037,87 @@ function FinancialsPageContent() {
|
||||
<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>
|
||||
</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>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{isStatementSurfaceKind(surfaceKind) ? (
|
||||
<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>
|
||||
) : null}
|
||||
<div className="data-table-wrap">
|
||||
<table className="data-table min-w-[980px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="sticky left-0 z-10 bg-[color:var(--panel)]">Metric</th>
|
||||
{periods.map((period) => (
|
||||
<th key={period.id}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>{formatLongDate(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>
|
||||
{groupedRows.map((group) => (
|
||||
<Fragment key={group.label ?? 'ungrouped'}>
|
||||
{group.label ? (
|
||||
<tr className="bg-[color:var(--panel-soft)]">
|
||||
<td colSpan={periods.length + 1} className="font-semibold text-[color:var(--terminal-bright)]">{group.label}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{group.rows.map((row) => (
|
||||
<tr
|
||||
key={row.key}
|
||||
className={selectedRowKey === row.key ? 'bg-[color:var(--panel-soft)]' : undefined}
|
||||
onClick={() => setSelectedRowKey(row.key)}
|
||||
>
|
||||
<td className="sticky left-0 z-10 bg-[color:var(--panel)]">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span style={{ paddingLeft: `${isTaxonomyRow(row) ? Math.min(row.depth, 10) * 12 : 0}px` }}>{row.label}</span>
|
||||
{'hasDimensions' in row && row.hasDimensions ? <ChevronDown className="size-3 text-[color:var(--accent)]" /> : null}
|
||||
</div>
|
||||
{isDerivedRow(row) && row.formulaKey ? (
|
||||
<span className="text-xs text-[color:var(--terminal-muted)]">Formula: {row.formulaKey}</span>
|
||||
) : null}
|
||||
{isKpiRow(row) ? (
|
||||
<span className="text-xs text-[color:var(--terminal-muted)]">Provenance: {row.provenanceType}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
{periods.map((period, index) => (
|
||||
<td key={`${row.key}-${period.id}`}>
|
||||
{buildDisplayValue({
|
||||
row,
|
||||
periodId: period.id,
|
||||
previousPeriodId: index > 0 ? periods[index - 1]?.id ?? null : null,
|
||||
commonSizeRow,
|
||||
displayMode,
|
||||
showPercentChange,
|
||||
showCommonSize,
|
||||
scale: valueScale,
|
||||
surfaceKind
|
||||
})}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Row Details" subtitle="Inspect provenance, formulas, and dimensional evidence for the selected row." variant="surface">
|
||||
{!selectedRow ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Select a row to inspect details.</p>
|
||||
) : (
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<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)]">Label</p>
|
||||
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.label}</p>
|
||||
</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)]">Key</p>
|
||||
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.key}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isTaxonomyRow(selectedRow) ? (
|
||||
<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)]">Taxonomy Concept</p>
|
||||
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.qname}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<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)]">Category</p>
|
||||
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.category}</p>
|
||||
</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)]">Unit</p>
|
||||
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.unit}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDerivedRow(selectedRow) ? (
|
||||
<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)]">Source Row Keys</p>
|
||||
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.sourceRowKeys.join(', ') || 'n/a'}</p>
|
||||
<p className="mt-2 text-[color:var(--terminal-muted)]">Source Concepts</p>
|
||||
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.sourceConcepts.join(', ') || 'n/a'}</p>
|
||||
<p className="mt-2 text-[color:var(--terminal-muted)]">Source Fact IDs</p>
|
||||
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.sourceFactIds.join(', ') || 'n/a'}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!selectedRow || !('hasDimensions' in selectedRow) || !selectedRow.hasDimensions ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No dimensional drill-down is available for this row.</p>
|
||||
) : dimensionRows.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No dimensional facts were returned for the selected row.</p>
|
||||
{isTreeStatementMode && treeModel ? (
|
||||
<StatementMatrix
|
||||
periods={periods}
|
||||
sections={treeModel.sections}
|
||||
selectedRowRef={selectedRowRef}
|
||||
onToggleRow={toggleExpandedRow}
|
||||
onSelectRow={setSelectedRowRef}
|
||||
renderCellValue={renderStatementTreeCellValue}
|
||||
periodLabelFormatter={formatLongDate}
|
||||
/>
|
||||
) : (
|
||||
<div className="data-table-wrap">
|
||||
<table className="data-table min-w-[760px]">
|
||||
<table className="data-table min-w-[980px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Period</th>
|
||||
<th>Axis</th>
|
||||
<th>Member</th>
|
||||
<th>Value</th>
|
||||
<th className="sticky left-0 z-10 bg-[color:var(--panel)]">Metric</th>
|
||||
{periods.map((period) => (
|
||||
<th key={period.id}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>{formatLongDate(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>
|
||||
{dimensionRows.map((row, index) => (
|
||||
<tr key={`${row.rowKey}-${row.periodId}-${row.axis}-${row.member}-${index}`}>
|
||||
<td>{periods.find((period) => period.id === row.periodId)?.periodLabel ?? row.periodId}</td>
|
||||
<td>{row.axis}</td>
|
||||
<td>{row.member}</td>
|
||||
<td>{formatMetricValue({
|
||||
value: row.value,
|
||||
unit: isTaxonomyRow(selectedRow) ? 'currency' : selectedRow.unit,
|
||||
scale: valueScale,
|
||||
rowKey: selectedRow.key,
|
||||
surfaceKind
|
||||
})}</td>
|
||||
</tr>
|
||||
{groupedRows.map((group) => (
|
||||
<Fragment key={group.label ?? 'ungrouped'}>
|
||||
{group.label ? (
|
||||
<tr className="bg-[color:var(--panel-soft)]">
|
||||
<td colSpan={periods.length + 1} className="font-semibold text-[color:var(--terminal-bright)]">{group.label}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{group.rows.map((row) => (
|
||||
<tr
|
||||
key={row.key}
|
||||
className={selectedFlatRowKey === row.key ? 'bg-[color:var(--panel-soft)]' : undefined}
|
||||
onClick={() => setSelectedFlatRowKey(row.key)}
|
||||
>
|
||||
<td className="sticky left-0 z-10 bg-[color:var(--panel)]">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span style={{ paddingLeft: `${isTaxonomyRow(row) ? Math.min(row.depth, 10) * 12 : 0}px` }}>{row.label}</span>
|
||||
{'hasDimensions' in row && row.hasDimensions ? <ChevronDown className="size-3 text-[color:var(--accent)]" /> : null}
|
||||
</div>
|
||||
{isDerivedRow(row) && row.formulaKey ? (
|
||||
<span className="text-xs text-[color:var(--terminal-muted)]">Formula: {row.formulaKey}</span>
|
||||
) : null}
|
||||
{isKpiRow(row) ? (
|
||||
<span className="text-xs text-[color:var(--terminal-muted)]">Provenance: {row.provenanceType}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
{periods.map((period, index) => (
|
||||
<td key={`${row.key}-${period.id}`}>
|
||||
{buildDisplayValue({
|
||||
row,
|
||||
periodId: period.id,
|
||||
previousPeriodId: index > 0 ? periods[index - 1]?.id ?? null : null,
|
||||
commonSizeRow,
|
||||
displayMode,
|
||||
showPercentChange,
|
||||
showCommonSize,
|
||||
scale: valueScale,
|
||||
surfaceKind
|
||||
})}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -957,6 +1127,100 @@ function FinancialsPageContent() {
|
||||
)}
|
||||
</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">
|
||||
{!selectedRow ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Select a row to inspect details.</p>
|
||||
) : (
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<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)]">Label</p>
|
||||
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.label}</p>
|
||||
</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)]">Key</p>
|
||||
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.key}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isTaxonomyRow(selectedRow) ? (
|
||||
<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)]">Taxonomy Concept</p>
|
||||
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.qname}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<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)]">Category</p>
|
||||
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.category}</p>
|
||||
</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)]">Unit</p>
|
||||
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.unit}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDerivedRow(selectedRow) ? (
|
||||
<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)]">Source Row Keys</p>
|
||||
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.sourceRowKeys.join(', ') || 'n/a'}</p>
|
||||
<p className="mt-2 text-[color:var(--terminal-muted)]">Source Concepts</p>
|
||||
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.sourceConcepts.join(', ') || 'n/a'}</p>
|
||||
<p className="mt-2 text-[color:var(--terminal-muted)]">Source Fact IDs</p>
|
||||
<p className="font-semibold text-[color:var(--terminal-bright)]">{selectedRow.sourceFactIds.join(', ') || 'n/a'}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!selectedRow || !('hasDimensions' in selectedRow) || !selectedRow.hasDimensions ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No dimensional drill-down is available for this row.</p>
|
||||
) : dimensionRows.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No dimensional facts were returned for the selected 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>
|
||||
{dimensionRows.map((row, index) => (
|
||||
<tr key={`${row.rowKey}-${row.periodId}-${row.axis}-${row.member}-${index}`}>
|
||||
<td>{periods.find((period) => period.id === row.periodId)?.periodLabel ?? row.periodId}</td>
|
||||
<td>{row.axis}</td>
|
||||
<td>{row.member}</td>
|
||||
<td>{formatMetricValue({
|
||||
value: row.value,
|
||||
unit: isTaxonomyRow(selectedRow) ? 'currency' : selectedRow.unit,
|
||||
scale: valueScale,
|
||||
rowKey: selectedRow.key,
|
||||
surfaceKind
|
||||
})}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{(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">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm text-[color:var(--terminal-muted)]">
|
||||
|
||||
@@ -361,6 +361,30 @@ function GraphingPageContent() {
|
||||
surface: graphState.surface,
|
||||
metric: graphState.metric
|
||||
}), [graphState.metric, graphState.surface, results]);
|
||||
const partialCoverageMessage = useMemo(() => {
|
||||
const notMeaningfulCount = comparison.companies.filter((company) => company.status === 'not_meaningful').length;
|
||||
const missingCount = comparison.companies.filter((company) => company.status === 'no_metric_data').length;
|
||||
const errorCount = comparison.companies.filter((company) => company.status === 'error').length;
|
||||
const parts: string[] = [];
|
||||
|
||||
if (notMeaningfulCount > 0) {
|
||||
parts.push(`${notMeaningfulCount} ${notMeaningfulCount === 1 ? 'company marks' : 'companies mark'} this metric as not meaningful for the selected pack`);
|
||||
}
|
||||
|
||||
if (missingCount > 0) {
|
||||
parts.push(`${missingCount} ${missingCount === 1 ? 'company has' : 'companies have'} no metric data`);
|
||||
}
|
||||
|
||||
if (errorCount > 0) {
|
||||
parts.push(`${errorCount} ${errorCount === 1 ? 'company failed' : 'companies failed'} to load`);
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return 'Partial coverage detected. Some companies are missing values for this metric.';
|
||||
}
|
||||
|
||||
return `Partial coverage detected. ${parts.join('. ')}.`;
|
||||
}, [comparison.companies]);
|
||||
|
||||
const hasCurrencyScale = selectedMetric?.unit === 'currency';
|
||||
|
||||
@@ -527,7 +551,7 @@ function GraphingPageContent() {
|
||||
<>
|
||||
{comparison.hasPartialData ? (
|
||||
<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>
|
||||
) : null}
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
@@ -613,6 +637,7 @@ function GraphingPageContent() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Company</th>
|
||||
<th>Pack</th>
|
||||
<th>Latest</th>
|
||||
<th>Prior</th>
|
||||
<th>Change</th>
|
||||
@@ -637,6 +662,7 @@ function GraphingPageContent() {
|
||||
<span className="text-xs text-[color:var(--terminal-muted)]">{row.companyName}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{row.fiscalPack ?? '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 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>
|
||||
{row.status === 'ready' ? (
|
||||
<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' ? (
|
||||
<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';
|
||||
surface: string;
|
||||
}) {
|
||||
const fiscalPack = input.ticker === 'JPM'
|
||||
? 'bank_lender'
|
||||
: input.ticker === 'BLK'
|
||||
? 'broker_asset_manager'
|
||||
: 'core';
|
||||
|
||||
return {
|
||||
financials: {
|
||||
company: {
|
||||
@@ -83,7 +89,50 @@ function createFinancialsPayload(input: {
|
||||
resolvedSourceRowKeys: {
|
||||
[`${input.ticker}-p1`]: 'revenue',
|
||||
[`${input.ticker}-p2`]: 'revenue'
|
||||
}
|
||||
},
|
||||
resolutionMethod: 'direct'
|
||||
},
|
||||
{
|
||||
key: 'gross_profit',
|
||||
label: 'Gross Profit',
|
||||
category: 'profit',
|
||||
order: 15,
|
||||
unit: 'currency',
|
||||
values: {
|
||||
[`${input.ticker}-p1`]: input.ticker === 'JPM' ? null : input.ticker === 'AAPL' ? 138 : 112,
|
||||
[`${input.ticker}-p2`]: input.ticker === 'JPM' ? null : input.ticker === 'AAPL' ? 156 : 128
|
||||
},
|
||||
sourceConcepts: ['gross_profit'],
|
||||
sourceRowKeys: ['gross_profit'],
|
||||
sourceFactIds: [11],
|
||||
formulaKey: input.ticker === 'JPM' ? null : 'revenue_less_cost_of_revenue',
|
||||
hasDimensions: false,
|
||||
resolvedSourceRowKeys: {
|
||||
[`${input.ticker}-p1`]: 'gross_profit',
|
||||
[`${input.ticker}-p2`]: 'gross_profit'
|
||||
},
|
||||
resolutionMethod: input.ticker === 'JPM' ? 'not_meaningful' : 'formula_derived'
|
||||
},
|
||||
{
|
||||
key: 'other_operating_expense',
|
||||
label: 'Other Expense',
|
||||
category: 'opex',
|
||||
order: 16,
|
||||
unit: 'currency',
|
||||
values: {
|
||||
[`${input.ticker}-p1`]: input.ticker === 'BLK' ? null : input.ticker === 'AAPL' ? 12 : 8,
|
||||
[`${input.ticker}-p2`]: input.ticker === 'BLK' ? null : input.ticker === 'AAPL' ? 14 : 10
|
||||
},
|
||||
sourceConcepts: ['other_operating_expense'],
|
||||
sourceRowKeys: ['other_operating_expense'],
|
||||
sourceFactIds: [12],
|
||||
formulaKey: input.ticker === 'BLK' ? null : 'operating_expenses_residual',
|
||||
hasDimensions: false,
|
||||
resolvedSourceRowKeys: {
|
||||
[`${input.ticker}-p1`]: 'other_operating_expense',
|
||||
[`${input.ticker}-p2`]: 'other_operating_expense'
|
||||
},
|
||||
resolutionMethod: input.ticker === 'BLK' ? 'not_meaningful' : 'formula_derived'
|
||||
},
|
||||
{
|
||||
key: 'total_assets',
|
||||
@@ -103,7 +152,8 @@ function createFinancialsPayload(input: {
|
||||
resolvedSourceRowKeys: {
|
||||
[`${input.ticker}-p1`]: 'total_assets',
|
||||
[`${input.ticker}-p2`]: 'total_assets'
|
||||
}
|
||||
},
|
||||
resolutionMethod: 'direct'
|
||||
},
|
||||
{
|
||||
key: 'free_cash_flow',
|
||||
@@ -123,10 +173,12 @@ function createFinancialsPayload(input: {
|
||||
resolvedSourceRowKeys: {
|
||||
[`${input.ticker}-p1`]: 'free_cash_flow',
|
||||
[`${input.ticker}-p2`]: 'free_cash_flow'
|
||||
}
|
||||
},
|
||||
resolutionMethod: 'direct'
|
||||
}
|
||||
]
|
||||
},
|
||||
statementDetails: null,
|
||||
ratioRows: [
|
||||
{
|
||||
key: 'gross_margin',
|
||||
@@ -177,6 +229,13 @@ function createFinancialsPayload(input: {
|
||||
taxonomy: null,
|
||||
validation: null
|
||||
},
|
||||
normalization: {
|
||||
regime: 'unknown',
|
||||
fiscalPack,
|
||||
parserVersion: '0.0.0',
|
||||
unmappedRowCount: 0,
|
||||
materialUnmappedRowCount: 0
|
||||
},
|
||||
dimensionBreakdown: null
|
||||
}
|
||||
};
|
||||
@@ -204,6 +263,10 @@ async function mockGraphingFinancials(page: Page) {
|
||||
? 'NVIDIA Corporation'
|
||||
: ticker === 'AMD'
|
||||
? 'Advanced Micro Devices, Inc.'
|
||||
: ticker === 'BLK'
|
||||
? 'BlackRock, Inc.'
|
||||
: ticker === 'JPM'
|
||||
? 'JPMorgan Chase & Co.'
|
||||
: 'Microsoft Corporation';
|
||||
|
||||
await route.fulfill({
|
||||
@@ -227,7 +290,7 @@ test('supports graphing compare controls and partial failures', async ({ page },
|
||||
|
||||
await expect(page).toHaveURL(/tickers=MSFT%2CAAPL%2CNVDA/);
|
||||
await expect(page.getByRole('heading', { name: 'Graphing' })).toBeVisible();
|
||||
await expect(page.getByText('Microsoft Corporation')).toBeVisible();
|
||||
await expect(page.getByText('Microsoft Corporation').first()).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Graph surface Balance Sheet' }).click();
|
||||
await expect(page).toHaveURL(/surface=balance_sheet/);
|
||||
@@ -245,11 +308,25 @@ test('supports graphing compare controls and partial failures', async ({ page },
|
||||
await page.getByLabel('Compare tickers').fill('MSFT, NVDA, AMD');
|
||||
await page.getByRole('button', { name: 'Update Compare Set' }).click();
|
||||
await expect(page).toHaveURL(/tickers=MSFT%2CNVDA%2CAMD/);
|
||||
await expect(page.getByText('Advanced Micro Devices, Inc.')).toBeVisible();
|
||||
await expect(page.getByText('Advanced Micro Devices, Inc.').first()).toBeVisible();
|
||||
|
||||
await page.goto('/graphing?tickers=MSFT,BAD&surface=income_statement&metric=revenue&cadence=annual&chart=line&scale=millions');
|
||||
await expect(page.getByText('Partial coverage detected.')).toBeVisible();
|
||||
await expect(page.getByRole('cell', { name: /BAD/ })).toBeVisible();
|
||||
await expect(page.getByText('Ticker not found')).toBeVisible();
|
||||
await expect(page.getByText('Microsoft Corporation')).toBeVisible();
|
||||
await expect(page.getByText('Microsoft Corporation').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('distinguishes not meaningful metrics from missing data in the latest values table', async ({ page }, testInfo) => {
|
||||
await signUp(page, testInfo);
|
||||
await mockGraphingFinancials(page);
|
||||
|
||||
await page.goto('/graphing?tickers=MSFT,BLK&surface=income_statement&metric=other_operating_expense&cadence=annual&chart=line&scale=millions');
|
||||
await expect(page.getByRole('combobox', { name: 'Metric selector' })).toHaveValue('other_operating_expense');
|
||||
await expect(page.getByRole('cell', { name: 'broker_asset_manager' })).toBeVisible();
|
||||
await expect(page.getByText('Not meaningful for this pack')).toBeVisible();
|
||||
|
||||
await page.goto('/graphing?tickers=JPM,MSFT&surface=income_statement&metric=gross_profit&cadence=annual&chart=line&scale=millions');
|
||||
await expect(page.getByText('not meaningful for the selected pack', { exact: false })).toBeVisible();
|
||||
await expect(page.getByRole('cell', { name: 'bank_lender' })).toBeVisible();
|
||||
await expect(page.getByText('Ready')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -42,26 +42,28 @@ export const INCOME_STATEMENT_METRIC_DEFINITIONS: StatementMetricDefinition[] =
|
||||
{ key: 'cost_of_revenue', label: 'Cost of Revenue', category: 'expense', order: 20, unit: 'currency', localNames: ['CostOfRevenue', 'CostOfGoodsSold', 'CostOfSales', 'CostOfProductsSold', 'CostOfServices'], labelIncludes: ['cost of revenue', 'cost of sales', 'cost of goods sold'] },
|
||||
{ key: 'gross_profit', label: 'Gross Profit', category: 'profit', order: 30, unit: 'currency', localNames: ['GrossProfit'] },
|
||||
{ key: 'gross_margin', label: 'Gross Margin', category: 'margin', order: 40, unit: 'percent', labelIncludes: ['gross margin'] },
|
||||
{ key: 'research_and_development', label: 'Research & Development', category: 'opex', order: 50, unit: 'currency', localNames: ['ResearchAndDevelopmentExpense'], labelIncludes: ['research and development'] },
|
||||
{ key: 'sales_and_marketing', label: 'Sales & Marketing', category: 'opex', order: 60, unit: 'currency', localNames: ['SellingAndMarketingExpense'], labelIncludes: ['sales and marketing', 'selling and marketing'] },
|
||||
{ key: 'general_and_administrative', label: 'General & Administrative', category: 'opex', order: 70, unit: 'currency', localNames: ['GeneralAndAdministrativeExpense'], labelIncludes: ['general and administrative'] },
|
||||
{ key: 'selling_general_and_administrative', label: 'Selling, General & Administrative', category: 'opex', order: 80, unit: 'currency', localNames: ['SellingGeneralAndAdministrativeExpense'], labelIncludes: ['selling, general', 'selling general'] },
|
||||
{ key: 'operating_expenses', label: 'Operating Expenses', category: 'opex', order: 50, unit: 'currency', localNames: ['OperatingExpenses'], labelIncludes: ['operating expenses'] },
|
||||
{ key: 'selling_general_and_administrative', label: 'Selling, General & Administrative', category: 'opex', order: 60, unit: 'currency', localNames: ['SellingGeneralAndAdministrativeExpense'], labelIncludes: ['selling, general', 'selling general'] },
|
||||
{ key: 'research_and_development', label: 'Research Expense', category: 'opex', order: 70, unit: 'currency', localNames: ['ResearchAndDevelopmentExpense'], labelIncludes: ['research and development', 'research expense'] },
|
||||
{ key: 'other_operating_expense', label: 'Other Expense', category: 'opex', order: 80, unit: 'currency', localNames: ['OtherOperatingExpense'], labelIncludes: ['other operating expense', 'other expense'] },
|
||||
{ key: 'operating_income', label: 'Operating Income', category: 'profit', order: 90, unit: 'currency', localNames: ['OperatingIncomeLoss', 'IncomeLossFromOperations'], labelIncludes: ['operating income'] },
|
||||
{ key: 'operating_margin', label: 'Operating Margin', category: 'margin', order: 100, unit: 'percent', labelIncludes: ['operating margin'] },
|
||||
{ key: 'interest_income', label: 'Interest Income', category: 'non_operating', order: 110, unit: 'currency', localNames: ['InterestIncomeExpenseNonoperatingNet', 'InterestIncomeOther', 'InvestmentIncomeInterest'], labelIncludes: ['interest income'] },
|
||||
{ key: 'interest_expense', label: 'Interest Expense', category: 'non_operating', order: 120, unit: 'currency', localNames: ['InterestExpense', 'InterestAndDebtExpense'], labelIncludes: ['interest expense'] },
|
||||
{ key: 'other_non_operating_income', label: 'Other Non-Operating Income', category: 'non_operating', order: 130, unit: 'currency', localNames: ['OtherNonoperatingIncomeExpense', 'NonoperatingIncomeExpense'], labelIncludes: ['other non-operating', 'non-operating income'] },
|
||||
{ key: 'pretax_income', label: 'Pretax Income', category: 'profit', order: 140, unit: 'currency', localNames: ['IncomeBeforeTaxExpenseBenefit', 'PretaxIncome'], labelIncludes: ['income before taxes', 'pretax income'] },
|
||||
{ key: 'income_tax_expense', label: 'Income Tax Expense', category: 'tax', order: 150, unit: 'currency', localNames: ['IncomeTaxExpenseBenefit'], labelIncludes: ['income tax'] },
|
||||
{ key: 'effective_tax_rate', label: 'Effective Tax Rate', category: 'tax', order: 160, unit: 'percent', labelIncludes: ['effective tax rate'] },
|
||||
{ key: 'net_income', label: 'Net Income', category: 'profit', order: 170, unit: 'currency', localNames: ['NetIncomeLoss', 'ProfitLoss'], labelIncludes: ['net income'] },
|
||||
{ key: 'diluted_eps', label: 'Diluted EPS', category: 'per_share', order: 180, unit: 'currency', localNames: ['EarningsPerShareDiluted', 'DilutedEarningsPerShare'], labelIncludes: ['diluted eps', 'diluted earnings per share'] },
|
||||
{ key: 'basic_eps', label: 'Basic EPS', category: 'per_share', order: 190, unit: 'currency', localNames: ['EarningsPerShareBasic', 'BasicEarningsPerShare'], labelIncludes: ['basic eps', 'basic earnings per share'] },
|
||||
{ key: 'diluted_shares', label: 'Diluted Shares', category: 'shares', order: 200, unit: 'shares', localNames: ['WeightedAverageNumberOfDilutedSharesOutstanding', 'WeightedAverageNumberOfShareOutstandingDiluted'], labelIncludes: ['diluted shares', 'weighted average diluted'] },
|
||||
{ key: 'basic_shares', label: 'Basic Shares', category: 'shares', order: 210, unit: 'shares', localNames: ['WeightedAverageNumberOfSharesOutstandingBasic', 'WeightedAverageNumberOfShareOutstandingBasicAndDiluted'], labelIncludes: ['basic shares', 'weighted average basic'] },
|
||||
{ key: 'depreciation_and_amortization', label: 'Depreciation & Amortization', category: 'non_cash', order: 220, unit: 'currency', localNames: ['DepreciationDepletionAndAmortization', 'DepreciationAmortizationAndAccretionNet', 'DepreciationAndAmortization'], labelIncludes: ['depreciation', 'amortization'] },
|
||||
{ key: 'ebitda', label: 'EBITDA', category: 'profit', order: 230, unit: 'currency', localNames: ['OperatingIncomeLoss'], labelIncludes: ['ebitda'] },
|
||||
{ key: 'stock_based_compensation', label: 'Stock-Based Compensation', category: 'non_cash', order: 240, unit: 'currency', localNames: ['ShareBasedCompensation', 'AllocatedShareBasedCompensationExpense'], labelIncludes: ['stock-based compensation', 'share-based compensation'] }
|
||||
{ key: 'sales_and_marketing', label: 'Sales & Marketing', category: 'opex', order: 100, unit: 'currency', localNames: ['SellingAndMarketingExpense'], labelIncludes: ['sales and marketing', 'selling and marketing'] },
|
||||
{ key: 'general_and_administrative', label: 'General & Administrative', category: 'opex', order: 110, unit: 'currency', localNames: ['GeneralAndAdministrativeExpense'], labelIncludes: ['general and administrative'] },
|
||||
{ key: 'operating_margin', label: 'Operating Margin', category: 'margin', order: 120, unit: 'percent', labelIncludes: ['operating margin'] },
|
||||
{ key: 'interest_income', label: 'Interest Income', category: 'non_operating', order: 130, unit: 'currency', localNames: ['InterestIncomeExpenseNonoperatingNet', 'InterestIncomeOther', 'InvestmentIncomeInterest'], labelIncludes: ['interest income'] },
|
||||
{ key: 'interest_expense', label: 'Interest Expense', category: 'non_operating', order: 140, unit: 'currency', localNames: ['InterestExpense', 'InterestAndDebtExpense'], labelIncludes: ['interest expense'] },
|
||||
{ key: 'other_non_operating_income', label: 'Other Non-Operating Income', category: 'non_operating', order: 150, unit: 'currency', localNames: ['OtherNonoperatingIncomeExpense', 'NonoperatingIncomeExpense'], labelIncludes: ['other non-operating', 'non-operating income'] },
|
||||
{ key: 'pretax_income', label: 'Pretax Income', category: 'profit', order: 160, unit: 'currency', localNames: ['IncomeBeforeTaxExpenseBenefit', 'PretaxIncome'], labelIncludes: ['income before taxes', 'pretax income'] },
|
||||
{ key: 'income_tax_expense', label: 'Income Tax Expense', category: 'tax', order: 170, unit: 'currency', localNames: ['IncomeTaxExpenseBenefit'], labelIncludes: ['income tax'] },
|
||||
{ key: 'effective_tax_rate', label: 'Effective Tax Rate', category: 'tax', order: 180, unit: 'percent', labelIncludes: ['effective tax rate'] },
|
||||
{ key: 'net_income', label: 'Net Income', category: 'profit', order: 190, unit: 'currency', localNames: ['NetIncomeLoss', 'ProfitLoss'], labelIncludes: ['net income'] },
|
||||
{ key: 'diluted_eps', label: 'Diluted EPS', category: 'per_share', order: 200, unit: 'currency', localNames: ['EarningsPerShareDiluted', 'DilutedEarningsPerShare'], labelIncludes: ['diluted eps', 'diluted earnings per share'] },
|
||||
{ key: 'basic_eps', label: 'Basic EPS', category: 'per_share', order: 210, unit: 'currency', localNames: ['EarningsPerShareBasic', 'BasicEarningsPerShare'], labelIncludes: ['basic eps', 'basic earnings per share'] },
|
||||
{ key: 'diluted_shares', label: 'Diluted Shares', category: 'shares', order: 220, unit: 'shares', localNames: ['WeightedAverageNumberOfDilutedSharesOutstanding', 'WeightedAverageNumberOfShareOutstandingDiluted'], labelIncludes: ['diluted shares', 'weighted average diluted'] },
|
||||
{ key: 'basic_shares', label: 'Basic Shares', category: 'shares', order: 230, unit: 'shares', localNames: ['WeightedAverageNumberOfSharesOutstandingBasic', 'WeightedAverageNumberOfShareOutstandingBasicAndDiluted'], labelIncludes: ['basic shares', 'weighted average basic'] },
|
||||
{ key: 'depreciation_and_amortization', label: 'Depreciation & Amortization', category: 'non_cash', order: 240, unit: 'currency', localNames: ['DepreciationDepletionAndAmortization', 'DepreciationAmortizationAndAccretionNet', 'DepreciationAndAmortization'], labelIncludes: ['depreciation', 'amortization'] },
|
||||
{ key: 'ebitda', label: 'EBITDA', category: 'profit', order: 250, unit: 'currency', localNames: ['OperatingIncomeLoss'], labelIncludes: ['ebitda'] },
|
||||
{ key: 'stock_based_compensation', label: 'Stock-Based Compensation', category: 'non_cash', order: 260, unit: 'currency', localNames: ['ShareBasedCompensation', 'AllocatedShareBasedCompensationExpense'], labelIncludes: ['stock-based compensation', 'share-based compensation'] }
|
||||
] as const satisfies StatementMetricDefinition[];
|
||||
|
||||
export const BALANCE_SHEET_METRIC_DEFINITIONS: StatementMetricDefinition[] = [
|
||||
|
||||
213
lib/financials/statement-view-model.test.ts
Normal file
213
lib/financials/statement-view-model.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
buildStatementTree,
|
||||
resolveStatementSelection
|
||||
} from '@/lib/financials/statement-view-model';
|
||||
import type { DetailFinancialRow, SurfaceFinancialRow } from '@/lib/types';
|
||||
|
||||
function createSurfaceRow(input: Partial<SurfaceFinancialRow> & Pick<SurfaceFinancialRow, 'key' | 'label' | 'values'>): SurfaceFinancialRow {
|
||||
return {
|
||||
key: input.key,
|
||||
label: input.label,
|
||||
category: input.category ?? 'revenue',
|
||||
order: input.order ?? 10,
|
||||
unit: input.unit ?? 'currency',
|
||||
values: input.values,
|
||||
sourceConcepts: input.sourceConcepts ?? [input.key],
|
||||
sourceRowKeys: input.sourceRowKeys ?? [input.key],
|
||||
sourceFactIds: input.sourceFactIds ?? [1],
|
||||
formulaKey: input.formulaKey ?? null,
|
||||
hasDimensions: input.hasDimensions ?? false,
|
||||
resolvedSourceRowKeys: input.resolvedSourceRowKeys ?? Object.fromEntries(Object.keys(input.values).map((periodId) => [periodId, input.key])),
|
||||
statement: input.statement ?? 'income',
|
||||
detailCount: input.detailCount,
|
||||
resolutionMethod: input.resolutionMethod,
|
||||
confidence: input.confidence,
|
||||
warningCodes: input.warningCodes
|
||||
};
|
||||
}
|
||||
|
||||
function createDetailRow(input: Partial<DetailFinancialRow> & Pick<DetailFinancialRow, 'key' | 'label' | 'parentSurfaceKey' | 'values'>): DetailFinancialRow {
|
||||
return {
|
||||
key: input.key,
|
||||
parentSurfaceKey: input.parentSurfaceKey,
|
||||
label: input.label,
|
||||
conceptKey: input.conceptKey ?? input.key,
|
||||
qname: input.qname ?? `us-gaap:${input.key}`,
|
||||
namespaceUri: input.namespaceUri ?? 'http://fasb.org/us-gaap/2024',
|
||||
localName: input.localName ?? input.key,
|
||||
unit: input.unit ?? 'USD',
|
||||
values: input.values,
|
||||
sourceFactIds: input.sourceFactIds ?? [100],
|
||||
isExtension: input.isExtension ?? false,
|
||||
dimensionsSummary: input.dimensionsSummary ?? [],
|
||||
residualFlag: input.residualFlag ?? false
|
||||
};
|
||||
}
|
||||
|
||||
describe('statement view model', () => {
|
||||
const categories = [{ key: 'opex', label: 'Operating Expenses', count: 4 }];
|
||||
|
||||
it('builds a root-only tree when there are no configured children or details', () => {
|
||||
const model = buildStatementTree({
|
||||
surfaceKind: 'income_statement',
|
||||
rows: [
|
||||
createSurfaceRow({ key: 'revenue', label: 'Revenue', category: 'revenue', values: { p1: 100 } })
|
||||
],
|
||||
statementDetails: null,
|
||||
categories: [],
|
||||
searchQuery: '',
|
||||
expandedRowKeys: new Set()
|
||||
});
|
||||
|
||||
expect(model.sections).toHaveLength(1);
|
||||
expect(model.sections[0]?.nodes[0]).toMatchObject({
|
||||
kind: 'surface',
|
||||
row: { key: 'revenue' },
|
||||
expandable: false
|
||||
});
|
||||
});
|
||||
|
||||
it('nests the operating expense child surfaces under the parent row', () => {
|
||||
const model = buildStatementTree({
|
||||
surfaceKind: 'income_statement',
|
||||
rows: [
|
||||
createSurfaceRow({ key: 'operating_expenses', label: 'Operating Expenses', category: 'opex', order: 10, values: { p1: 40 } }),
|
||||
createSurfaceRow({ key: 'selling_general_and_administrative', label: 'SG&A', category: 'opex', order: 20, values: { p1: 20 } }),
|
||||
createSurfaceRow({ key: 'research_and_development', label: 'Research Expense', category: 'opex', order: 30, values: { p1: 12 } }),
|
||||
createSurfaceRow({ key: 'other_operating_expense', label: 'Other Expense', category: 'opex', order: 40, values: { p1: 8 } })
|
||||
],
|
||||
statementDetails: null,
|
||||
categories,
|
||||
searchQuery: '',
|
||||
expandedRowKeys: new Set(['operating_expenses'])
|
||||
});
|
||||
|
||||
const parent = model.sections[0]?.nodes[0];
|
||||
expect(parent?.kind).toBe('surface');
|
||||
const childKeys = parent?.kind === 'surface'
|
||||
? parent.children.map((node) => node.row.key)
|
||||
: [];
|
||||
expect(childKeys).toEqual([
|
||||
'selling_general_and_administrative',
|
||||
'research_and_development',
|
||||
'other_operating_expense'
|
||||
]);
|
||||
});
|
||||
|
||||
it('nests raw detail rows under the matching child surface row', () => {
|
||||
const model = buildStatementTree({
|
||||
surfaceKind: 'income_statement',
|
||||
rows: [
|
||||
createSurfaceRow({ key: 'operating_expenses', label: 'Operating Expenses', category: 'opex', order: 10, values: { p1: 40 } }),
|
||||
createSurfaceRow({ key: 'selling_general_and_administrative', label: 'SG&A', category: 'opex', order: 20, values: { p1: 20 } })
|
||||
],
|
||||
statementDetails: {
|
||||
selling_general_and_administrative: [
|
||||
createDetailRow({ key: 'corporate_sga', label: 'Corporate SG&A', parentSurfaceKey: 'selling_general_and_administrative', values: { p1: 20 } })
|
||||
]
|
||||
},
|
||||
categories,
|
||||
searchQuery: '',
|
||||
expandedRowKeys: new Set(['operating_expenses', 'selling_general_and_administrative'])
|
||||
});
|
||||
|
||||
const child = model.sections[0]?.nodes[0];
|
||||
expect(child?.kind).toBe('surface');
|
||||
const sgaNode = child?.kind === 'surface' ? child.children[0] : null;
|
||||
expect(sgaNode?.kind).toBe('surface');
|
||||
const detailNode = sgaNode?.kind === 'surface' ? sgaNode.children[0] : null;
|
||||
expect(detailNode).toMatchObject({
|
||||
kind: 'detail',
|
||||
row: { key: 'corporate_sga' }
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-expands the parent chain when search matches a child surface or detail row', () => {
|
||||
const rows = [
|
||||
createSurfaceRow({ key: 'operating_expenses', label: 'Operating Expenses', category: 'opex', order: 10, values: { p1: 40 } }),
|
||||
createSurfaceRow({ key: 'research_and_development', label: 'Research Expense', category: 'opex', order: 20, values: { p1: 12 } })
|
||||
];
|
||||
|
||||
const childSearch = buildStatementTree({
|
||||
surfaceKind: 'income_statement',
|
||||
rows,
|
||||
statementDetails: null,
|
||||
categories,
|
||||
searchQuery: 'research',
|
||||
expandedRowKeys: new Set()
|
||||
});
|
||||
|
||||
expect(childSearch.autoExpandedKeys.has('operating_expenses')).toBe(true);
|
||||
expect(childSearch.sections[0]?.nodes[0]?.kind === 'surface' && childSearch.sections[0]?.nodes[0].expanded).toBe(true);
|
||||
|
||||
const detailSearch = buildStatementTree({
|
||||
surfaceKind: 'income_statement',
|
||||
rows,
|
||||
statementDetails: {
|
||||
research_and_development: [
|
||||
createDetailRow({ key: 'ai_lab_expense', label: 'AI Lab Expense', parentSurfaceKey: 'research_and_development', values: { p1: 12 } })
|
||||
]
|
||||
},
|
||||
categories,
|
||||
searchQuery: 'ai lab',
|
||||
expandedRowKeys: new Set()
|
||||
});
|
||||
|
||||
const parent = detailSearch.sections[0]?.nodes[0];
|
||||
const child = parent?.kind === 'surface' ? parent.children[0] : null;
|
||||
expect(parent?.kind === 'surface' && parent.expanded).toBe(true);
|
||||
expect(child?.kind === 'surface' && child.expanded).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps not meaningful rows visible and resolves selections for surface and detail nodes', () => {
|
||||
const rows = [
|
||||
createSurfaceRow({
|
||||
key: 'gross_profit',
|
||||
label: 'Gross Profit',
|
||||
category: 'profit',
|
||||
values: { p1: null },
|
||||
resolutionMethod: 'not_meaningful',
|
||||
warningCodes: ['gross_profit_not_meaningful_bank_pack']
|
||||
})
|
||||
];
|
||||
const details = {
|
||||
gross_profit: [
|
||||
createDetailRow({ key: 'gp_unmapped', label: 'Gross Profit residual', parentSurfaceKey: 'gross_profit', values: { p1: null }, residualFlag: true })
|
||||
]
|
||||
};
|
||||
|
||||
const model = buildStatementTree({
|
||||
surfaceKind: 'income_statement',
|
||||
rows,
|
||||
statementDetails: details,
|
||||
categories: [],
|
||||
searchQuery: '',
|
||||
expandedRowKeys: new Set(['gross_profit'])
|
||||
});
|
||||
|
||||
expect(model.sections[0]?.nodes[0]).toMatchObject({
|
||||
kind: 'surface',
|
||||
row: { key: 'gross_profit', resolutionMethod: 'not_meaningful' }
|
||||
});
|
||||
|
||||
const surfaceSelection = resolveStatementSelection({
|
||||
surfaceKind: 'income_statement',
|
||||
rows,
|
||||
statementDetails: details,
|
||||
selection: { kind: 'surface', key: 'gross_profit' }
|
||||
});
|
||||
expect(surfaceSelection?.kind).toBe('surface');
|
||||
|
||||
const detailSelection = resolveStatementSelection({
|
||||
surfaceKind: 'income_statement',
|
||||
rows,
|
||||
statementDetails: details,
|
||||
selection: { kind: 'detail', key: 'gp_unmapped', parentKey: 'gross_profit' }
|
||||
});
|
||||
expect(detailSelection).toMatchObject({
|
||||
kind: 'detail',
|
||||
row: { key: 'gp_unmapped', residualFlag: true }
|
||||
});
|
||||
});
|
||||
});
|
||||
314
lib/financials/statement-view-model.ts
Normal file
314
lib/financials/statement-view-model.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import type {
|
||||
DetailFinancialRow,
|
||||
FinancialCategory,
|
||||
FinancialSurfaceKind,
|
||||
SurfaceDetailMap,
|
||||
SurfaceFinancialRow
|
||||
} from '@/lib/types';
|
||||
|
||||
const SURFACE_CHILDREN: Partial<Record<Extract<FinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>, Record<string, string[]>>> = {
|
||||
income_statement: {
|
||||
operating_expenses: [
|
||||
'selling_general_and_administrative',
|
||||
'research_and_development',
|
||||
'other_operating_expense'
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export type StatementInspectorSelection = {
|
||||
kind: 'surface' | 'detail';
|
||||
key: string;
|
||||
parentKey?: string;
|
||||
};
|
||||
|
||||
export type StatementTreeDetailNode = {
|
||||
kind: 'detail';
|
||||
id: string;
|
||||
level: number;
|
||||
row: DetailFinancialRow;
|
||||
parentSurfaceKey: string;
|
||||
matchesSearch: boolean;
|
||||
};
|
||||
|
||||
export type StatementTreeSurfaceNode = {
|
||||
kind: 'surface';
|
||||
id: string;
|
||||
level: number;
|
||||
row: SurfaceFinancialRow;
|
||||
childSurfaceKeys: string[];
|
||||
directDetailCount: number;
|
||||
children: StatementTreeNode[];
|
||||
expandable: boolean;
|
||||
expanded: boolean;
|
||||
autoExpanded: boolean;
|
||||
matchesSearch: boolean;
|
||||
};
|
||||
|
||||
export type StatementTreeNode = StatementTreeSurfaceNode | StatementTreeDetailNode;
|
||||
|
||||
export type StatementTreeSection = {
|
||||
key: string;
|
||||
label: string | null;
|
||||
nodes: StatementTreeNode[];
|
||||
};
|
||||
|
||||
export type StatementTreeModel = {
|
||||
sections: StatementTreeSection[];
|
||||
autoExpandedKeys: Set<string>;
|
||||
visibleNodeCount: number;
|
||||
totalNodeCount: number;
|
||||
};
|
||||
|
||||
export type ResolvedStatementSelection =
|
||||
| {
|
||||
kind: 'surface';
|
||||
row: SurfaceFinancialRow;
|
||||
childSurfaceRows: SurfaceFinancialRow[];
|
||||
detailRows: DetailFinancialRow[];
|
||||
}
|
||||
| {
|
||||
kind: 'detail';
|
||||
row: DetailFinancialRow;
|
||||
parentSurfaceRow: SurfaceFinancialRow | null;
|
||||
};
|
||||
|
||||
type Categories = Array<{
|
||||
key: FinancialCategory;
|
||||
label: string;
|
||||
count: number;
|
||||
}>;
|
||||
|
||||
function surfaceConfigForKind(surfaceKind: Extract<FinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>) {
|
||||
return SURFACE_CHILDREN[surfaceKind] ?? {};
|
||||
}
|
||||
|
||||
function detailNodeId(parentKey: string, row: DetailFinancialRow) {
|
||||
return `detail:${parentKey}:${row.key}`;
|
||||
}
|
||||
|
||||
function normalize(value: string) {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function searchTextForSurface(row: SurfaceFinancialRow) {
|
||||
return [
|
||||
row.label,
|
||||
row.key,
|
||||
...row.sourceConcepts,
|
||||
...row.sourceRowKeys,
|
||||
...(row.warningCodes ?? [])
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function searchTextForDetail(row: DetailFinancialRow) {
|
||||
return [
|
||||
row.label,
|
||||
row.key,
|
||||
row.parentSurfaceKey,
|
||||
row.conceptKey,
|
||||
row.qname,
|
||||
row.localName,
|
||||
...row.dimensionsSummary
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function sortSurfaceRows(left: SurfaceFinancialRow, right: SurfaceFinancialRow) {
|
||||
if (left.order !== right.order) {
|
||||
return left.order - right.order;
|
||||
}
|
||||
|
||||
return left.label.localeCompare(right.label);
|
||||
}
|
||||
|
||||
function sortDetailRows(left: DetailFinancialRow, right: DetailFinancialRow) {
|
||||
return left.label.localeCompare(right.label);
|
||||
}
|
||||
|
||||
function countNodes(nodes: StatementTreeNode[]) {
|
||||
let count = 0;
|
||||
|
||||
for (const node of nodes) {
|
||||
count += 1;
|
||||
if (node.kind === 'surface') {
|
||||
count += countNodes(node.children);
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
export function buildStatementTree(input: {
|
||||
surfaceKind: Extract<FinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>;
|
||||
rows: SurfaceFinancialRow[];
|
||||
statementDetails: SurfaceDetailMap | null;
|
||||
categories: Categories;
|
||||
searchQuery: string;
|
||||
expandedRowKeys: Set<string>;
|
||||
}): StatementTreeModel {
|
||||
const config = surfaceConfigForKind(input.surfaceKind);
|
||||
const rowByKey = new Map(input.rows.map((row) => [row.key, row]));
|
||||
const childKeySet = new Set<string>();
|
||||
|
||||
for (const children of Object.values(config)) {
|
||||
for (const childKey of children) {
|
||||
if (rowByKey.has(childKey)) {
|
||||
childKeySet.add(childKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedSearch = normalize(input.searchQuery);
|
||||
const autoExpandedKeys = new Set<string>();
|
||||
|
||||
const buildSurfaceNode = (row: SurfaceFinancialRow, level: number): StatementTreeSurfaceNode | null => {
|
||||
const childSurfaceRows = (config[row.key] ?? [])
|
||||
.map((key) => rowByKey.get(key))
|
||||
.filter((candidate): candidate is SurfaceFinancialRow => Boolean(candidate))
|
||||
.sort(sortSurfaceRows);
|
||||
const detailRows = [...(input.statementDetails?.[row.key] ?? [])].sort(sortDetailRows);
|
||||
const childSurfaceNodes = childSurfaceRows
|
||||
.map((childRow) => buildSurfaceNode(childRow, level + 1))
|
||||
.filter((node): node is StatementTreeSurfaceNode => Boolean(node));
|
||||
const detailNodes = detailRows
|
||||
.filter((detail) => normalizedSearch.length === 0 || searchTextForDetail(detail).includes(normalizedSearch))
|
||||
.map((detail) => ({
|
||||
kind: 'detail',
|
||||
id: detailNodeId(row.key, detail),
|
||||
level: level + 1,
|
||||
row: detail,
|
||||
parentSurfaceKey: row.key,
|
||||
matchesSearch: normalizedSearch.length > 0 && searchTextForDetail(detail).includes(normalizedSearch)
|
||||
}) satisfies StatementTreeDetailNode);
|
||||
const children = [...childSurfaceNodes, ...detailNodes];
|
||||
const matchesSearch = normalizedSearch.length > 0 && searchTextForSurface(row).includes(normalizedSearch);
|
||||
const hasMatchingDescendant = normalizedSearch.length > 0 && children.length > 0;
|
||||
|
||||
if (normalizedSearch.length > 0 && !matchesSearch && !hasMatchingDescendant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const childSurfaceKeys = childSurfaceRows.map((candidate) => candidate.key);
|
||||
const directDetailCount = detailRows.length;
|
||||
const autoExpanded = normalizedSearch.length > 0 && !matchesSearch && children.length > 0;
|
||||
const expanded = children.length > 0 && (input.expandedRowKeys.has(row.key) || autoExpanded);
|
||||
|
||||
if (autoExpanded) {
|
||||
autoExpandedKeys.add(row.key);
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'surface',
|
||||
id: row.key,
|
||||
level,
|
||||
row,
|
||||
childSurfaceKeys,
|
||||
directDetailCount,
|
||||
children,
|
||||
expandable: children.length > 0,
|
||||
expanded,
|
||||
autoExpanded,
|
||||
matchesSearch
|
||||
};
|
||||
};
|
||||
|
||||
const rootNodes = input.rows
|
||||
.filter((row) => !childKeySet.has(row.key))
|
||||
.sort(sortSurfaceRows)
|
||||
.map((row) => buildSurfaceNode(row, 0))
|
||||
.filter((node): node is StatementTreeSurfaceNode => Boolean(node));
|
||||
|
||||
if (input.categories.length === 0) {
|
||||
return {
|
||||
sections: [{ key: 'ungrouped', label: null, nodes: rootNodes }],
|
||||
autoExpandedKeys,
|
||||
visibleNodeCount: countNodes(rootNodes),
|
||||
totalNodeCount: input.rows.length + Object.values(input.statementDetails ?? {}).reduce((sum, rows) => sum + rows.length, 0)
|
||||
};
|
||||
}
|
||||
|
||||
const sections: StatementTreeSection[] = [];
|
||||
const categoriesByKey = new Map(input.categories.map((category) => [category.key, category.label]));
|
||||
|
||||
for (const category of input.categories) {
|
||||
const nodes = rootNodes.filter((node) => node.row.category === category.key);
|
||||
if (nodes.length > 0) {
|
||||
sections.push({
|
||||
key: category.key,
|
||||
label: category.label,
|
||||
nodes
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const uncategorized = rootNodes.filter((node) => !categoriesByKey.has(node.row.category));
|
||||
if (uncategorized.length > 0) {
|
||||
sections.push({
|
||||
key: 'uncategorized',
|
||||
label: null,
|
||||
nodes: uncategorized
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
sections,
|
||||
autoExpandedKeys,
|
||||
visibleNodeCount: sections.reduce((sum, section) => sum + countNodes(section.nodes), 0),
|
||||
totalNodeCount: input.rows.length + Object.values(input.statementDetails ?? {}).reduce((sum, rows) => sum + rows.length, 0)
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveStatementSelection(input: {
|
||||
surfaceKind: Extract<FinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>;
|
||||
rows: SurfaceFinancialRow[];
|
||||
statementDetails: SurfaceDetailMap | null;
|
||||
selection: StatementInspectorSelection | null;
|
||||
}): ResolvedStatementSelection | null {
|
||||
const selection = input.selection;
|
||||
|
||||
if (!selection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rowByKey = new Map(input.rows.map((row) => [row.key, row]));
|
||||
const config = surfaceConfigForKind(input.surfaceKind);
|
||||
|
||||
if (selection.kind === 'surface') {
|
||||
const row = rowByKey.get(selection.key);
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const childSurfaceRows = (config[row.key] ?? [])
|
||||
.map((key) => rowByKey.get(key))
|
||||
.filter((candidate): candidate is SurfaceFinancialRow => Boolean(candidate))
|
||||
.sort(sortSurfaceRows);
|
||||
|
||||
return {
|
||||
kind: 'surface',
|
||||
row,
|
||||
childSurfaceRows,
|
||||
detailRows: [...(input.statementDetails?.[row.key] ?? [])].sort(sortDetailRows)
|
||||
};
|
||||
}
|
||||
|
||||
const parentSurfaceKey = selection.parentKey ?? null;
|
||||
const detailRows = parentSurfaceKey
|
||||
? input.statementDetails?.[parentSurfaceKey] ?? []
|
||||
: Object.values(input.statementDetails ?? {}).flat();
|
||||
const row = detailRows.find((candidate) => candidate.key === selection.key) ?? null;
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'detail',
|
||||
row,
|
||||
parentSurfaceRow: rowByKey.get(row.parentSurfaceKey) ?? null
|
||||
};
|
||||
}
|
||||
@@ -36,6 +36,15 @@ describe('graphing catalog', () => {
|
||||
expect(quarterlyMetricKeys).toContain('gross_margin');
|
||||
});
|
||||
|
||||
it('includes other operating expense in the income statement metric catalog', () => {
|
||||
const metricKeys = metricsForSurfaceAndCadence('income_statement', 'annual').map((metric) => metric.key);
|
||||
|
||||
expect(metricKeys).toContain('operating_expenses');
|
||||
expect(metricKeys).toContain('selling_general_and_administrative');
|
||||
expect(metricKeys).toContain('research_and_development');
|
||||
expect(metricKeys).toContain('other_operating_expense');
|
||||
});
|
||||
|
||||
it('replaces invalid metrics after surface and cadence normalization', () => {
|
||||
const state = parseGraphingParams(new URLSearchParams('surface=ratios&cadence=quarterly&metric=5y_revenue_cagr&tickers=msft,aapl'));
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
CompanyFinancialStatementsResponse,
|
||||
FinancialStatementPeriod,
|
||||
RatioRow,
|
||||
StandardizedFinancialRow
|
||||
SurfaceFinancialRow
|
||||
} from '@/lib/types';
|
||||
|
||||
function createPeriod(input: {
|
||||
@@ -26,7 +26,7 @@ function createPeriod(input: {
|
||||
} satisfies FinancialStatementPeriod;
|
||||
}
|
||||
|
||||
function createStatementRow(key: string, values: Record<string, number | null>, unit: StandardizedFinancialRow['unit'] = 'currency') {
|
||||
function createStatementRow(key: string, values: Record<string, number | null>, unit: SurfaceFinancialRow['unit'] = 'currency') {
|
||||
return {
|
||||
key,
|
||||
label: key,
|
||||
@@ -39,8 +39,9 @@ function createStatementRow(key: string, values: Record<string, number | null>,
|
||||
sourceFactIds: [1],
|
||||
formulaKey: null,
|
||||
hasDimensions: false,
|
||||
resolvedSourceRowKeys: Object.fromEntries(Object.keys(values).map((periodId) => [periodId, key]))
|
||||
} satisfies StandardizedFinancialRow;
|
||||
resolvedSourceRowKeys: Object.fromEntries(Object.keys(values).map((periodId) => [periodId, key])),
|
||||
statement: 'income'
|
||||
} satisfies SurfaceFinancialRow;
|
||||
}
|
||||
|
||||
function createRatioRow(key: string, values: Record<string, number | null>, unit: RatioRow['unit'] = 'percent') {
|
||||
@@ -54,8 +55,9 @@ function createFinancials(input: {
|
||||
ticker: string;
|
||||
companyName: string;
|
||||
periods: FinancialStatementPeriod[];
|
||||
statementRows?: StandardizedFinancialRow[];
|
||||
statementRows?: SurfaceFinancialRow[];
|
||||
ratioRows?: RatioRow[];
|
||||
fiscalPack?: string | null;
|
||||
}) {
|
||||
return {
|
||||
company: {
|
||||
@@ -72,6 +74,7 @@ function createFinancials(input: {
|
||||
faithful: [],
|
||||
standardized: input.statementRows ?? []
|
||||
},
|
||||
statementDetails: null,
|
||||
ratioRows: input.ratioRows ?? [],
|
||||
kpiRows: null,
|
||||
trendSeries: [],
|
||||
@@ -100,6 +103,13 @@ function createFinancials(input: {
|
||||
taxonomy: null,
|
||||
validation: null
|
||||
},
|
||||
normalization: {
|
||||
regime: 'unknown',
|
||||
fiscalPack: input.fiscalPack ?? null,
|
||||
parserVersion: '0.0.0',
|
||||
unmappedRowCount: 0,
|
||||
materialUnmappedRowCount: 0
|
||||
},
|
||||
dimensionBreakdown: null
|
||||
} satisfies CompanyFinancialStatementsResponse;
|
||||
}
|
||||
@@ -194,6 +204,37 @@ describe('graphing series', () => {
|
||||
expect(data.hasAnyData).toBe(false);
|
||||
});
|
||||
|
||||
it('marks not meaningful standardized rows separately from missing metric data', () => {
|
||||
const data = buildGraphingComparisonData({
|
||||
surface: 'income_statement',
|
||||
metric: 'gross_profit',
|
||||
results: [
|
||||
{
|
||||
ticker: 'JPM',
|
||||
financials: createFinancials({
|
||||
ticker: 'JPM',
|
||||
companyName: 'JPMorgan Chase & Co.',
|
||||
fiscalPack: 'bank_lender',
|
||||
periods: [createPeriod({ id: 'jpm-fy', filingId: 1, filingDate: '2026-02-13', periodEnd: '2025-12-31', filingType: '10-K' })],
|
||||
statementRows: [{
|
||||
...createStatementRow('gross_profit', { 'jpm-fy': null }),
|
||||
resolutionMethod: 'not_meaningful'
|
||||
}]
|
||||
})
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(data.latestRows[0]).toMatchObject({
|
||||
ticker: 'JPM',
|
||||
fiscalPack: 'bank_lender',
|
||||
status: 'not_meaningful',
|
||||
errorMessage: 'Not meaningful for this pack.'
|
||||
});
|
||||
expect(data.hasAnyData).toBe(false);
|
||||
expect(data.hasPartialData).toBe(true);
|
||||
});
|
||||
|
||||
it('derives latest and prior values for the summary table', () => {
|
||||
const data = buildGraphingComparisonData({
|
||||
surface: 'income_statement',
|
||||
@@ -216,6 +257,7 @@ describe('graphing series', () => {
|
||||
|
||||
expect(data.latestRows[0]).toMatchObject({
|
||||
ticker: 'AMD',
|
||||
fiscalPack: null,
|
||||
latestValue: 70,
|
||||
priorValue: 50,
|
||||
changeValue: 20,
|
||||
|
||||
@@ -27,7 +27,8 @@ export type GraphingSeriesPoint = {
|
||||
export type GraphingCompanySeries = {
|
||||
ticker: string;
|
||||
companyName: string;
|
||||
status: 'ready' | 'error' | 'no_metric_data';
|
||||
fiscalPack: string | null;
|
||||
status: 'ready' | 'error' | 'no_metric_data' | 'not_meaningful';
|
||||
errorMessage: string | null;
|
||||
unit: FinancialUnit | null;
|
||||
points: GraphingSeriesPoint[];
|
||||
@@ -38,6 +39,7 @@ export type GraphingCompanySeries = {
|
||||
export type GraphingLatestValueRow = {
|
||||
ticker: string;
|
||||
companyName: string;
|
||||
fiscalPack: string | null;
|
||||
status: GraphingCompanySeries['status'];
|
||||
errorMessage: string | null;
|
||||
latestValue: number | null;
|
||||
@@ -86,6 +88,7 @@ function extractCompanySeries(
|
||||
return {
|
||||
ticker: result.ticker,
|
||||
companyName: result.ticker,
|
||||
fiscalPack: null,
|
||||
status: 'error',
|
||||
errorMessage: result.error ?? 'Unable to load financial history',
|
||||
unit: null,
|
||||
@@ -97,6 +100,10 @@ function extractCompanySeries(
|
||||
|
||||
const metricRow = extractMetricRow(result.financials, surface, metric);
|
||||
const periods = [...result.financials.periods].sort(sortPeriods);
|
||||
const notMeaningful = surface !== 'ratios'
|
||||
&& metricRow
|
||||
&& 'resolutionMethod' in metricRow
|
||||
&& metricRow.resolutionMethod === 'not_meaningful';
|
||||
const points = periods.map((period) => ({
|
||||
periodId: period.id,
|
||||
dateKey: period.periodEnd ?? period.filingDate,
|
||||
@@ -113,8 +120,9 @@ function extractCompanySeries(
|
||||
return {
|
||||
ticker: result.financials.company.ticker,
|
||||
companyName: result.financials.company.companyName,
|
||||
status: latestPoint ? 'ready' : 'no_metric_data',
|
||||
errorMessage: latestPoint ? null : 'No data available for the selected metric.',
|
||||
fiscalPack: result.financials.normalization.fiscalPack,
|
||||
status: notMeaningful ? 'not_meaningful' : latestPoint ? 'ready' : 'no_metric_data',
|
||||
errorMessage: notMeaningful ? 'Not meaningful for this pack.' : latestPoint ? null : 'No data available for the selected metric.',
|
||||
unit: metricRow?.unit ?? null,
|
||||
points,
|
||||
latestPoint,
|
||||
@@ -159,6 +167,7 @@ export function buildGraphingComparisonData(input: {
|
||||
const latestRows = companies.map((company) => ({
|
||||
ticker: company.ticker,
|
||||
companyName: company.companyName,
|
||||
fiscalPack: company.fiscalPack,
|
||||
status: company.status,
|
||||
errorMessage: company.errorMessage,
|
||||
latestValue: company.latestPoint?.value ?? null,
|
||||
|
||||
Reference in New Issue
Block a user