Add history window controls and expand taxonomy pack support
- add 3Y/5Y/10Y financial history filtering and reorganize normalization details UI - add new fiscal taxonomy surface/income bridge/KPI packs and update Rust taxonomy loading - auto-detect Homebrew SQLite for native `sqlite-vec` in local dev/e2e with docs and env guidance
This commit is contained in:
120
lib/financials/history-window.test.ts
Normal file
120
lib/financials/history-window.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
filterPeriodsByHistoryWindow,
|
||||
financialHistoryLimit,
|
||||
} from "@/lib/financials/history-window";
|
||||
import type { FinancialStatementPeriod } from "@/lib/types";
|
||||
|
||||
function createPeriod(year: number, month = 12, day = 31) {
|
||||
const suffix = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
|
||||
return {
|
||||
id: suffix,
|
||||
filingId: year,
|
||||
accessionNumber: `0000-${year}`,
|
||||
filingDate: `${year + 1}-02-01`,
|
||||
periodStart: `${year}-01-01`,
|
||||
periodEnd: suffix,
|
||||
filingType: "10-K",
|
||||
periodLabel: `FY ${year}`,
|
||||
} satisfies FinancialStatementPeriod;
|
||||
}
|
||||
|
||||
describe("financialHistoryLimit", () => {
|
||||
it("scales fetch size for annual and ltm cadences", () => {
|
||||
expect(financialHistoryLimit("annual", 3)).toBe(5);
|
||||
expect(financialHistoryLimit("ltm", 10)).toBe(12);
|
||||
});
|
||||
|
||||
it("fetches a buffered number of quarterly periods", () => {
|
||||
expect(financialHistoryLimit("quarterly", 3)).toBe(16);
|
||||
expect(financialHistoryLimit("quarterly", 5)).toBe(24);
|
||||
expect(financialHistoryLimit("quarterly", 10)).toBe(44);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterPeriodsByHistoryWindow", () => {
|
||||
it("keeps the latest three annual periods", () => {
|
||||
const periods = [
|
||||
createPeriod(2018),
|
||||
createPeriod(2019),
|
||||
createPeriod(2020),
|
||||
createPeriod(2021),
|
||||
createPeriod(2022),
|
||||
createPeriod(2023),
|
||||
createPeriod(2024),
|
||||
];
|
||||
|
||||
expect(
|
||||
filterPeriodsByHistoryWindow(periods, "annual", 3).map(
|
||||
(period) => period.id,
|
||||
),
|
||||
).toEqual(["2022-12-31", "2023-12-31", "2024-12-31"]);
|
||||
});
|
||||
|
||||
it("keeps the exact trailing annual count for longer windows", () => {
|
||||
const periods = Array.from({ length: 12 }, (_, index) =>
|
||||
createPeriod(2013 + index),
|
||||
);
|
||||
|
||||
expect(
|
||||
filterPeriodsByHistoryWindow(periods, "annual", 10).map(
|
||||
(period) => period.id,
|
||||
),
|
||||
).toEqual([
|
||||
"2015-12-31",
|
||||
"2016-12-31",
|
||||
"2017-12-31",
|
||||
"2018-12-31",
|
||||
"2019-12-31",
|
||||
"2020-12-31",
|
||||
"2021-12-31",
|
||||
"2022-12-31",
|
||||
"2023-12-31",
|
||||
"2024-12-31",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the exact trailing quarterly count", () => {
|
||||
const periods = [
|
||||
createPeriod(2022, 3, 31),
|
||||
createPeriod(2022, 6, 30),
|
||||
createPeriod(2022, 9, 30),
|
||||
createPeriod(2022, 12, 31),
|
||||
createPeriod(2023, 3, 31),
|
||||
createPeriod(2023, 6, 30),
|
||||
createPeriod(2023, 9, 30),
|
||||
createPeriod(2023, 12, 31),
|
||||
createPeriod(2024, 3, 31),
|
||||
createPeriod(2024, 6, 30),
|
||||
createPeriod(2024, 9, 30),
|
||||
createPeriod(2024, 12, 31),
|
||||
createPeriod(2025, 3, 31),
|
||||
];
|
||||
|
||||
expect(
|
||||
filterPeriodsByHistoryWindow(periods, "quarterly", 3).map(
|
||||
(period) => period.id,
|
||||
),
|
||||
).toEqual([
|
||||
"2022-06-30",
|
||||
"2022-09-30",
|
||||
"2022-12-31",
|
||||
"2023-03-31",
|
||||
"2023-06-30",
|
||||
"2023-09-30",
|
||||
"2023-12-31",
|
||||
"2024-03-31",
|
||||
"2024-06-30",
|
||||
"2024-09-30",
|
||||
"2024-12-31",
|
||||
"2025-03-31",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns all periods when the list is shorter than the requested window", () => {
|
||||
const periods = [createPeriod(2024)];
|
||||
|
||||
expect(filterPeriodsByHistoryWindow(periods, "annual", 5)).toEqual(periods);
|
||||
});
|
||||
});
|
||||
42
lib/financials/history-window.ts
Normal file
42
lib/financials/history-window.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { FinancialCadence, FinancialStatementPeriod } from "@/lib/types";
|
||||
|
||||
export type FinancialHistoryWindow = 3 | 5 | 10;
|
||||
|
||||
export function financialHistoryLimit(
|
||||
cadence: FinancialCadence,
|
||||
window: FinancialHistoryWindow,
|
||||
) {
|
||||
if (cadence === "quarterly") {
|
||||
return window * 4 + 4;
|
||||
}
|
||||
|
||||
return window + 2;
|
||||
}
|
||||
|
||||
function visiblePeriodCount(
|
||||
cadence: FinancialCadence,
|
||||
window: FinancialHistoryWindow,
|
||||
) {
|
||||
if (cadence === "quarterly") {
|
||||
return window * 4;
|
||||
}
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
export function filterPeriodsByHistoryWindow(
|
||||
periods: FinancialStatementPeriod[],
|
||||
cadence: FinancialCadence,
|
||||
window: FinancialHistoryWindow,
|
||||
) {
|
||||
if (periods.length === 0) {
|
||||
return periods;
|
||||
}
|
||||
|
||||
const count = visiblePeriodCount(cadence, window);
|
||||
if (periods.length <= count) {
|
||||
return periods;
|
||||
}
|
||||
|
||||
return periods.slice(-count);
|
||||
}
|
||||
@@ -3,27 +3,123 @@ import type {
|
||||
FinancialCategory,
|
||||
FinancialSurfaceKind,
|
||||
SurfaceDetailMap,
|
||||
SurfaceFinancialRow
|
||||
} from '@/lib/types';
|
||||
SurfaceFinancialRow,
|
||||
} from "@/lib/types";
|
||||
|
||||
const SURFACE_CHILDREN: Partial<Record<Extract<FinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>, Record<string, string[]>>> = {
|
||||
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'
|
||||
]
|
||||
}
|
||||
"selling_general_and_administrative",
|
||||
"research_and_development",
|
||||
"other_operating_expense",
|
||||
],
|
||||
},
|
||||
balance_sheet: {
|
||||
total_assets: [
|
||||
"current_assets",
|
||||
"non_current_assets",
|
||||
"assets_held_for_sale",
|
||||
],
|
||||
current_assets: [
|
||||
"cash_and_equivalents",
|
||||
"short_term_investments",
|
||||
"accounts_receivable",
|
||||
"inventory",
|
||||
"prepaid_expenses",
|
||||
"other_current_assets",
|
||||
],
|
||||
non_current_assets: [
|
||||
"property_plant_equipment_net",
|
||||
"goodwill",
|
||||
"intangible_assets_net",
|
||||
"long_term_investments",
|
||||
"deferred_tax_assets",
|
||||
"other_non_current_assets",
|
||||
],
|
||||
total_liabilities: [
|
||||
"current_liabilities",
|
||||
"non_current_liabilities",
|
||||
"liabilities_held_for_sale",
|
||||
],
|
||||
current_liabilities: [
|
||||
"accounts_payable",
|
||||
"short_term_debt",
|
||||
"current_portion_of_long_term_debt",
|
||||
"accrued_liabilities",
|
||||
"deferred_revenue",
|
||||
"other_current_liabilities",
|
||||
],
|
||||
non_current_liabilities: [
|
||||
"long_term_debt",
|
||||
"deferred_tax_liabilities",
|
||||
"deferred_revenue_non_current",
|
||||
"other_non_current_liabilities",
|
||||
],
|
||||
total_equity: [
|
||||
"stockholders_equity",
|
||||
"minority_interest",
|
||||
"total_liabilities_and_equity",
|
||||
],
|
||||
stockholders_equity: [
|
||||
"common_stock",
|
||||
"retained_earnings",
|
||||
"additional_paid_in_capital",
|
||||
"treasury_stock",
|
||||
"accumulated_other_comprehensive_income",
|
||||
"other_stockholders_equity",
|
||||
],
|
||||
},
|
||||
cash_flow_statement: {
|
||||
net_cash_from_operating: [
|
||||
"operating_cash_flow_adjustments",
|
||||
"changes_in_working_capital",
|
||||
],
|
||||
operating_cash_flow_adjustments: [
|
||||
"depreciation_and_amortization",
|
||||
"stock_based_compensation",
|
||||
"deferred_taxes",
|
||||
"other_non_cash_items",
|
||||
],
|
||||
changes_in_working_capital: [
|
||||
"change_in_accounts_receivable",
|
||||
"change_in_inventory",
|
||||
"change_in_accounts_payable",
|
||||
"change_in_other_working_capital",
|
||||
],
|
||||
net_cash_from_investing: [
|
||||
"capital_expenditures",
|
||||
"acquisitions",
|
||||
"purchases_of_investments",
|
||||
"sales_of_investments",
|
||||
"other_investing_activities",
|
||||
],
|
||||
net_cash_from_financing: [
|
||||
"debt_issuance",
|
||||
"debt_repayment",
|
||||
"stock_issuance",
|
||||
"stock_repurchase",
|
||||
"dividends_paid",
|
||||
"other_financing_activities",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export type StatementInspectorSelection = {
|
||||
kind: 'surface' | 'detail';
|
||||
kind: "surface" | "detail";
|
||||
key: string;
|
||||
parentKey?: string;
|
||||
};
|
||||
|
||||
export type StatementTreeDetailNode = {
|
||||
kind: 'detail';
|
||||
kind: "detail";
|
||||
id: string;
|
||||
level: number;
|
||||
row: DetailFinancialRow;
|
||||
@@ -32,7 +128,7 @@ export type StatementTreeDetailNode = {
|
||||
};
|
||||
|
||||
export type StatementTreeSurfaceNode = {
|
||||
kind: 'surface';
|
||||
kind: "surface";
|
||||
id: string;
|
||||
level: number;
|
||||
row: SurfaceFinancialRow;
|
||||
@@ -45,7 +141,9 @@ export type StatementTreeSurfaceNode = {
|
||||
matchesSearch: boolean;
|
||||
};
|
||||
|
||||
export type StatementTreeNode = StatementTreeSurfaceNode | StatementTreeDetailNode;
|
||||
export type StatementTreeNode =
|
||||
| StatementTreeSurfaceNode
|
||||
| StatementTreeDetailNode;
|
||||
|
||||
export type StatementTreeSection = {
|
||||
key: string;
|
||||
@@ -62,13 +160,13 @@ export type StatementTreeModel = {
|
||||
|
||||
export type ResolvedStatementSelection =
|
||||
| {
|
||||
kind: 'surface';
|
||||
kind: "surface";
|
||||
row: SurfaceFinancialRow;
|
||||
childSurfaceRows: SurfaceFinancialRow[];
|
||||
detailRows: DetailFinancialRow[];
|
||||
}
|
||||
| {
|
||||
kind: 'detail';
|
||||
kind: "detail";
|
||||
row: DetailFinancialRow;
|
||||
parentSurfaceRow: SurfaceFinancialRow | null;
|
||||
};
|
||||
@@ -79,11 +177,16 @@ type Categories = Array<{
|
||||
count: number;
|
||||
}>;
|
||||
|
||||
const UNMAPPED_DETAIL_GROUP_KEY = 'unmapped';
|
||||
const UNMAPPED_SECTION_KEY = 'unmapped_residual';
|
||||
const UNMAPPED_SECTION_LABEL = 'Unmapped / Residual';
|
||||
const UNMAPPED_DETAIL_GROUP_KEY = "unmapped";
|
||||
const UNMAPPED_SECTION_KEY = "unmapped_residual";
|
||||
const UNMAPPED_SECTION_LABEL = "Unmapped / Residual";
|
||||
|
||||
function surfaceConfigForKind(surfaceKind: Extract<FinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>) {
|
||||
function surfaceConfigForKind(
|
||||
surfaceKind: Extract<
|
||||
FinancialSurfaceKind,
|
||||
"income_statement" | "balance_sheet" | "cash_flow_statement"
|
||||
>,
|
||||
) {
|
||||
return SURFACE_CHILDREN[surfaceKind] ?? {};
|
||||
}
|
||||
|
||||
@@ -101,9 +204,9 @@ function searchTextForSurface(row: SurfaceFinancialRow) {
|
||||
row.key,
|
||||
...(row.sourceConcepts ?? []),
|
||||
...(row.sourceRowKeys ?? []),
|
||||
...(row.warningCodes ?? [])
|
||||
...(row.warningCodes ?? []),
|
||||
]
|
||||
.join(' ')
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
@@ -115,13 +218,16 @@ function searchTextForDetail(row: DetailFinancialRow) {
|
||||
row.conceptKey,
|
||||
row.qname,
|
||||
row.localName,
|
||||
...(row.dimensionsSummary ?? [])
|
||||
...(row.dimensionsSummary ?? []),
|
||||
]
|
||||
.join(' ')
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function sortSurfaceRows(left: SurfaceFinancialRow, right: SurfaceFinancialRow) {
|
||||
function sortSurfaceRows(
|
||||
left: SurfaceFinancialRow,
|
||||
right: SurfaceFinancialRow,
|
||||
) {
|
||||
if (left.order !== right.order) {
|
||||
return left.order - right.order;
|
||||
}
|
||||
@@ -141,15 +247,24 @@ function buildUnmappedDetailNodes(input: {
|
||||
|
||||
return [...(input.statementDetails?.[UNMAPPED_DETAIL_GROUP_KEY] ?? [])]
|
||||
.sort(sortDetailRows)
|
||||
.filter((detail) => normalizedSearch.length === 0 || searchTextForDetail(detail).includes(normalizedSearch))
|
||||
.map((detail) => ({
|
||||
kind: 'detail',
|
||||
id: detailNodeId(UNMAPPED_DETAIL_GROUP_KEY, detail),
|
||||
level: 0,
|
||||
row: detail,
|
||||
parentSurfaceKey: UNMAPPED_DETAIL_GROUP_KEY,
|
||||
matchesSearch: normalizedSearch.length > 0 && searchTextForDetail(detail).includes(normalizedSearch)
|
||||
}) satisfies StatementTreeDetailNode);
|
||||
.filter(
|
||||
(detail) =>
|
||||
normalizedSearch.length === 0 ||
|
||||
searchTextForDetail(detail).includes(normalizedSearch),
|
||||
)
|
||||
.map(
|
||||
(detail) =>
|
||||
({
|
||||
kind: "detail",
|
||||
id: detailNodeId(UNMAPPED_DETAIL_GROUP_KEY, detail),
|
||||
level: 0,
|
||||
row: detail,
|
||||
parentSurfaceKey: UNMAPPED_DETAIL_GROUP_KEY,
|
||||
matchesSearch:
|
||||
normalizedSearch.length > 0 &&
|
||||
searchTextForDetail(detail).includes(normalizedSearch),
|
||||
}) satisfies StatementTreeDetailNode,
|
||||
);
|
||||
}
|
||||
|
||||
function countNodes(nodes: StatementTreeNode[]) {
|
||||
@@ -157,7 +272,7 @@ function countNodes(nodes: StatementTreeNode[]) {
|
||||
|
||||
for (const node of nodes) {
|
||||
count += 1;
|
||||
if (node.kind === 'surface') {
|
||||
if (node.kind === "surface") {
|
||||
count += countNodes(node.children);
|
||||
}
|
||||
}
|
||||
@@ -166,7 +281,10 @@ function countNodes(nodes: StatementTreeNode[]) {
|
||||
}
|
||||
|
||||
export function buildStatementTree(input: {
|
||||
surfaceKind: Extract<FinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>;
|
||||
surfaceKind: Extract<
|
||||
FinancialSurfaceKind,
|
||||
"income_statement" | "balance_sheet" | "cash_flow_statement"
|
||||
>;
|
||||
rows: SurfaceFinancialRow[];
|
||||
statementDetails: SurfaceDetailMap | null;
|
||||
categories: Categories;
|
||||
@@ -188,44 +306,70 @@ export function buildStatementTree(input: {
|
||||
const normalizedSearch = normalize(input.searchQuery);
|
||||
const autoExpandedKeys = new Set<string>();
|
||||
|
||||
const buildSurfaceNode = (row: SurfaceFinancialRow, level: number): StatementTreeSurfaceNode | null => {
|
||||
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))
|
||||
.filter((candidate): candidate is SurfaceFinancialRow =>
|
||||
Boolean(candidate),
|
||||
)
|
||||
.sort(sortSurfaceRows);
|
||||
const detailRows = [...(input.statementDetails?.[row.key] ?? [])].sort(sortDetailRows);
|
||||
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);
|
||||
.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;
|
||||
const matchesSearch =
|
||||
normalizedSearch.length > 0 &&
|
||||
searchTextForSurface(row).includes(normalizedSearch);
|
||||
const hasMatchingDescendant =
|
||||
normalizedSearch.length > 0 && children.length > 0;
|
||||
|
||||
if (normalizedSearch.length > 0 && !matchesSearch && !hasMatchingDescendant) {
|
||||
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);
|
||||
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',
|
||||
kind: "surface",
|
||||
id: row.key,
|
||||
level,
|
||||
row,
|
||||
@@ -235,7 +379,7 @@ export function buildStatementTree(input: {
|
||||
expandable: children.length > 0,
|
||||
expanded,
|
||||
autoExpanded,
|
||||
matchesSearch
|
||||
matchesSearch,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -246,75 +390,101 @@ export function buildStatementTree(input: {
|
||||
.filter((node): node is StatementTreeSurfaceNode => Boolean(node));
|
||||
|
||||
if (input.categories.length === 0) {
|
||||
const sections: StatementTreeSection[] = rootNodes.length > 0
|
||||
? [{ key: 'ungrouped', label: null, nodes: rootNodes }]
|
||||
: [];
|
||||
const sections: StatementTreeSection[] =
|
||||
rootNodes.length > 0
|
||||
? [{ key: "ungrouped", label: null, nodes: rootNodes }]
|
||||
: [];
|
||||
const unmappedNodes = buildUnmappedDetailNodes({
|
||||
statementDetails: input.statementDetails,
|
||||
searchQuery: input.searchQuery
|
||||
searchQuery: input.searchQuery,
|
||||
});
|
||||
|
||||
if (unmappedNodes.length > 0) {
|
||||
sections.push({
|
||||
key: UNMAPPED_SECTION_KEY,
|
||||
label: UNMAPPED_SECTION_LABEL,
|
||||
nodes: unmappedNodes
|
||||
nodes: unmappedNodes,
|
||||
});
|
||||
}
|
||||
|
||||
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)
|
||||
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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const sections: StatementTreeSection[] = [];
|
||||
const categoriesByKey = new Map(input.categories.map((category) => [category.key, category.label]));
|
||||
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);
|
||||
const nodes = rootNodes.filter(
|
||||
(node) => node.row.category === category.key,
|
||||
);
|
||||
if (nodes.length > 0) {
|
||||
sections.push({
|
||||
key: category.key,
|
||||
label: category.label,
|
||||
nodes
|
||||
nodes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const uncategorized = rootNodes.filter((node) => !categoriesByKey.has(node.row.category));
|
||||
const uncategorized = rootNodes.filter(
|
||||
(node) => !categoriesByKey.has(node.row.category),
|
||||
);
|
||||
if (uncategorized.length > 0) {
|
||||
sections.push({
|
||||
key: 'uncategorized',
|
||||
key: "uncategorized",
|
||||
label: null,
|
||||
nodes: uncategorized
|
||||
nodes: uncategorized,
|
||||
});
|
||||
}
|
||||
|
||||
const unmappedNodes = buildUnmappedDetailNodes({
|
||||
statementDetails: input.statementDetails,
|
||||
searchQuery: input.searchQuery
|
||||
searchQuery: input.searchQuery,
|
||||
});
|
||||
if (unmappedNodes.length > 0) {
|
||||
sections.push({
|
||||
key: UNMAPPED_SECTION_KEY,
|
||||
label: UNMAPPED_SECTION_LABEL,
|
||||
nodes: unmappedNodes
|
||||
nodes: unmappedNodes,
|
||||
});
|
||||
}
|
||||
|
||||
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)
|
||||
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'>;
|
||||
surfaceKind: Extract<
|
||||
FinancialSurfaceKind,
|
||||
"income_statement" | "balance_sheet" | "cash_flow_statement"
|
||||
>;
|
||||
rows: SurfaceFinancialRow[];
|
||||
statementDetails: SurfaceDetailMap | null;
|
||||
selection: StatementInspectorSelection | null;
|
||||
@@ -328,7 +498,7 @@ export function resolveStatementSelection(input: {
|
||||
const rowByKey = new Map(input.rows.map((row) => [row.key, row]));
|
||||
const config = surfaceConfigForKind(input.surfaceKind);
|
||||
|
||||
if (selection.kind === 'surface') {
|
||||
if (selection.kind === "surface") {
|
||||
const row = rowByKey.get(selection.key);
|
||||
if (!row) {
|
||||
return null;
|
||||
@@ -336,32 +506,38 @@ export function resolveStatementSelection(input: {
|
||||
|
||||
const childSurfaceRows = (config[row.key] ?? [])
|
||||
.map((key) => rowByKey.get(key))
|
||||
.filter((candidate): candidate is SurfaceFinancialRow => Boolean(candidate))
|
||||
.filter((candidate): candidate is SurfaceFinancialRow =>
|
||||
Boolean(candidate),
|
||||
)
|
||||
.sort(sortSurfaceRows);
|
||||
|
||||
return {
|
||||
kind: 'surface',
|
||||
kind: "surface",
|
||||
row,
|
||||
childSurfaceRows,
|
||||
detailRows: [...(input.statementDetails?.[row.key] ?? [])].sort(sortDetailRows)
|
||||
detailRows: [...(input.statementDetails?.[row.key] ?? [])].sort(
|
||||
sortDetailRows,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const parentSurfaceKey = selection.parentKey ?? null;
|
||||
const detailRows = parentSurfaceKey === UNMAPPED_DETAIL_GROUP_KEY
|
||||
? input.statementDetails?.[UNMAPPED_DETAIL_GROUP_KEY] ?? []
|
||||
: parentSurfaceKey
|
||||
? input.statementDetails?.[parentSurfaceKey] ?? []
|
||||
: Object.values(input.statementDetails ?? {}).flat();
|
||||
const row = detailRows.find((candidate) => candidate.key === selection.key) ?? null;
|
||||
const detailRows =
|
||||
parentSurfaceKey === UNMAPPED_DETAIL_GROUP_KEY
|
||||
? (input.statementDetails?.[UNMAPPED_DETAIL_GROUP_KEY] ?? [])
|
||||
: 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',
|
||||
kind: "detail",
|
||||
row,
|
||||
parentSurfaceRow: rowByKey.get(row.parentSurfaceKey) ?? null
|
||||
parentSurfaceRow: rowByKey.get(row.parentSurfaceKey) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user