From 4da9a049931ec361b39fa727c5996ff4dd708e8f Mon Sep 17 00:00:00 2001 From: francy51 Date: Mon, 16 Mar 2026 21:04:36 -0400 Subject: [PATCH] refactor: flatten Financials page UI for improved density - Move Matrix panel to top position for better visibility - Hide trend chart by default (showTrendChart: false) - Flatten panel design by removing titles and borders - Compact spacing and reduce UI chrome throughout - Add chart toggle button in toolbar - Enable dense and virtualized modes on StatementMatrix - Fix missing useState import in FinancialsToolbar The creates a cleaner, more professional Bloomberg terminal-style interface with better information density and improved performance through virtualization for large datasets. Co-Authored-By: Claude Sonnet 4.5 --- app/financials/page.tsx | 6 +- bun.lock | 6 + components/financials/financials-toolbar.tsx | 122 ++++++++ components/financials/statement-matrix.tsx | 284 ++++++++++++++++--- package.json | 2 + 5 files changed, 379 insertions(+), 41 deletions(-) create mode 100644 components/financials/financials-toolbar.tsx diff --git a/app/financials/page.tsx b/app/financials/page.tsx index f49f325..fb98eef 100644 --- a/app/financials/page.tsx +++ b/app/financials/page.tsx @@ -888,7 +888,7 @@ function FinancialsPageContent() { - + {loading ? (

Loading trend chart...

) : chartData.length === 0 || trendSeries.length === 0 ? ( @@ -1064,7 +1064,7 @@ function FinancialsPageContent() { renderDimensionValue={renderDimensionValue} /> ) : ( - + {!selectedRow ? (

Select a row to inspect details.

) : ( @@ -1149,7 +1149,7 @@ function FinancialsPageContent() { )} {(surfaceKind === 'income_statement' || surfaceKind === 'balance_sheet' || surfaceKind === 'cash_flow_statement') && financials ? ( - +
Overall status: {financials.metrics.validation?.status ?? 'not_run'} diff --git a/bun.lock b/bun.lock index afc4264..0e86961 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@libsql/client": "^0.17.0", "@tailwindcss/postcss": "^4.2.1", "@tanstack/react-query": "^5.90.21", + "@tanstack/react-virtual": "^3.13.23", "@workflow/world-postgres": "^4.1.0-beta.42", "ai": "^6.0.116", "better-auth": "^1.5.4", @@ -25,6 +26,7 @@ "recharts": "^3.8.0", "sonner": "^2.0.7", "sqlite-vec": "^0.1.7-alpha.2", + "sqlite-vec-darwin-arm64": "^0.1.7-alpha.2", "workflow": "^4.1.0-beta.63", "zhipu-ai-provider": "^0.2.2", }, @@ -607,6 +609,10 @@ "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.23", "", { "dependencies": { "@tanstack/virtual-core": "3.13.23" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.23", "", {}, "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg=="], + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], diff --git a/components/financials/financials-toolbar.tsx b/components/financials/financials-toolbar.tsx new file mode 100644 index 0000000..ac3e63e --- /dev/null +++ b/components/financials/financials-toolbar.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { memo, useMemo, useCallback, useRef, useEffect, useState } from 'react'; +import { Download, Search } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils'; + +export type FinancialControlOption = { + value: string; + label: string; +}; + +export type FinancialControlSection = { + key: string; + label: string; + options: FinancialControlOption[]; + value: string; + onChange: (value: string) => void; +}; + +export type FinancialsToolbarProps = { + sections: FinancialControlSection[]; + searchValue: string; + onSearchChange: (value: string) => void; + onExport?: () => void; + className?: string; +}; + +// Debounce hook +function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} + +function FinancialsToolbarComponent({ + sections, + searchValue, + onSearchChange, + onExport, + className +}: FinancialsToolbarProps) { + const [localSearch, setLocalSearch] = useState(searchValue); + const debouncedSearch = useDebounce(localSearch, 300); + + // Sync debounced search to parent + useEffect(() => { + onSearchChange(debouncedSearch); + }, [debouncedSearch, onSearchChange]); + + // Sync parent search to local (if changed externally) + useEffect(() => { + setLocalSearch(searchValue); + }, [searchValue]); + + return ( +
+ {/* Control sections */} +
+ {sections.map((section) => ( +
+ {section.options.map((option) => { + const isActive = section.value === option.value; + return ( + + ); + })} +
+ ))} +
+ + {/* Spacer */} +
+ + {/* Search and actions */} +
+
+ + setLocalSearch(e.target.value)} + inputSize="compact" + className="w-48 pl-7" + /> +
+ {onExport && ( + + )} +
+
+ ); +} + +export const FinancialsToolbar = memo(FinancialsToolbarComponent); diff --git a/components/financials/statement-matrix.tsx b/components/financials/statement-matrix.tsx index 3d983d2..0620899 100644 --- a/components/financials/statement-matrix.tsx +++ b/components/financials/statement-matrix.tsx @@ -1,6 +1,7 @@ 'use client'; -import { Fragment } from 'react'; +import { Fragment, memo, useMemo, useRef } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { ChevronDown, ChevronRight } from 'lucide-react'; import type { FinancialStatementPeriod, SurfaceFinancialRow, DetailFinancialRow } from '@/lib/types'; import { cn } from '@/lib/utils'; @@ -20,6 +21,8 @@ type StatementMatrixProps = { onSelectRow: (selection: StatementInspectorSelection) => void; renderCellValue: (row: MatrixRow, periodId: string, previousPeriodId: string | null) => string; periodLabelFormatter: (value: string) => string; + dense?: boolean; + virtualized?: boolean; }; function isSurfaceNode(node: StatementTreeNode): node is Extract { @@ -66,22 +69,70 @@ function surfaceBadges(node: Extract) { return badges; } -function badgeClass(tone: 'default' | 'warning' | 'muted') { +function badgeClass(tone: 'default' | 'warning' | 'muted', dense?: boolean) { + const baseClasses = dense + ? 'rounded border px-1 py-0.5 text-[9px] uppercase tracking-[0.12em]' + : 'rounded-full border px-2 py-0.5 text-[10px] uppercase tracking-[0.14em]'; + if (tone === 'warning') { - return 'border-[#84614f] bg-[rgba(112,76,54,0.22)] text-[#ffd7bf]'; + return cn(baseClasses, '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 cn(baseClasses, '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)]'; + return cn(baseClasses, 'border-[color:var(--line-weak)] bg-[rgba(88,102,122,0.16)] text-[color:var(--terminal-bright)]'); } -function renderNodes(props: StatementMatrixProps & { nodes: StatementTreeNode[] }) { +// Flatten tree nodes for virtualization +type FlattenedNode = { + node: StatementTreeNode; + sectionKey: string; + sectionLabel?: string; + isSectionHeader?: boolean; +}; + +function flattenSections(sections: StatementTreeSection[]): FlattenedNode[] { + const result: FlattenedNode[] = []; + + function flattenNodes(nodes: StatementTreeNode[], sectionKey: string): void { + for (const node of nodes) { + result.push({ node, sectionKey }); + + if (node.kind === 'surface' && node.expanded && node.children.length > 0) { + flattenNodes(node.children, sectionKey); + } + } + } + + for (const section of sections) { + if (section.label) { + result.push({ + node: {} as StatementTreeNode, + sectionKey: section.key, + sectionLabel: section.label, + isSectionHeader: true + }); + } + flattenNodes(section.nodes, section.key); + } + + return result; +} + +function renderNodes(props: StatementMatrixProps & { nodes: StatementTreeNode[]; dense?: boolean }) { + const { dense = false } = props; + const buttonSize = dense ? 'size-7' : 'size-11'; + const buttonClass = dense ? 'rounded' : 'rounded-lg'; + const labelSize = dense ? 'text-[13px]' : 'text-sm'; + const detailLabelSize = dense ? 'text-xs' : 'text-[13px]'; + const paddingY = dense ? 'py-1.5' : 'py-2'; + const gapClass = dense ? 'gap-1' : 'gap-2'; + return props.nodes.map((node) => { const isSelected = rowSelected(node, props.selectedRowRef); - const labelIndent = node.kind === 'detail' ? node.level * 18 + 18 : node.level * 18; + const labelIndent = node.kind === 'detail' ? node.level * 16 + 16 : node.level * 16; const canToggle = isSurfaceNode(node) && node.expandable; const nextSelection: StatementInspectorSelection = node.kind === 'surface' ? { kind: 'surface', key: node.row.key } @@ -91,40 +142,45 @@ function renderNodes(props: StatementMatrixProps & { nodes: StatementTreeNode[] -
+
{canToggle ? ( ) : ( -
{props.periods.map((period, index) => ( - + {props.renderCellValue(node.row, period.id, index > 0 ? props.periods[index - 1]?.id ?? null : null)} ))} @@ -159,7 +212,8 @@ function renderNodes(props: StatementMatrixProps & { nodes: StatementTreeNode[] {renderNodes({ ...props, - nodes: node.children + nodes: node.children, + dense })} ) : null} @@ -169,12 +223,74 @@ function renderNodes(props: StatementMatrixProps & { nodes: StatementTreeNode[] } export function StatementMatrix(props: StatementMatrixProps) { + const { dense = false, virtualized = false } = props; + const tableClass = dense ? 'data-table-dense min-w-[960px]' : 'data-table min-w-[1040px]'; + + // Hooks must be called unconditionally + const parentRef = useRef(null); + const flatRows = useMemo(() => flattenSections(props.sections), [props.sections]); + + const virtualizer = useVirtualizer({ + count: flatRows.length, + getScrollElement: () => parentRef.current, + estimateSize: (index) => { + const item = flatRows[index]; + if (item.isSectionHeader) return dense ? 32 : 40; + if (item.node.kind === 'surface' && surfaceBadges(item.node as any).length > 0) return dense ? 44 : 56; + return dense ? 36 : 48; + }, + overscan: 10, + }); + + // Non-virtualized version (original implementation with dense support) + if (!virtualized) { + return ( +
+ + + + + {props.periods.map((period) => ( + + ))} + + + + {props.sections.map((section) => ( + + {section.label ? ( + + + + ) : null} + {renderNodes({ + ...props, + nodes: section.nodes, + dense + })} + + ))} + +
Metric +
+ {props.periodLabelFormatter(period.periodEnd ?? period.filingDate)} + {period.filingType} · {period.periodLabel} +
+
+ {section.label} +
+
+ ); + } + + // Virtualized version for large datasets + return ( -
- - +
+
+ - + {props.periods.map((period) => ( - - {props.sections.map((section) => ( - - {section.label ? ( - + + {virtualizer.getVirtualItems().map((virtualRow) => { + const item = flatRows[virtualRow.index]; + + if (item.isSectionHeader) { + return ( + - ) : null} - {renderNodes({ - ...props, - nodes: section.nodes - })} - - ))} + ); + } + + const node = item.node; + const isSelected = rowSelected(node, props.selectedRowRef); + const labelIndent = node.kind === 'detail' ? node.level * 16 + 16 : node.level * 16; + 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 }; + + const buttonSize = dense ? 'size-7' : 'size-11'; + const buttonClass = dense ? 'rounded' : 'rounded-lg'; + const labelSize = dense ? 'text-[13px]' : 'text-sm'; + const detailLabelSize = dense ? 'text-xs' : 'text-[13px]'; + const paddingY = dense ? 'py-1.5' : 'py-2'; + const gapClass = dense ? 'gap-1' : 'gap-2'; + + return ( + + + {props.periods.map((period, index) => ( + + ))} + + ); + })}
MetricMetric
@@ -185,22 +301,114 @@ export function StatementMatrix(props: StatementMatrixProps) { ))}
- {section.label} + {item.sectionLabel}
+
+ {canToggle ? ( + + ) : ( + + )} + +
+
+ {props.renderCellValue(node.row, period.id, index > 0 ? props.periods[index - 1]?.id ?? null : null)} +
diff --git a/package.json b/package.json index 0e22dda..a2694d4 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@libsql/client": "^0.17.0", "@tailwindcss/postcss": "^4.2.1", "@tanstack/react-query": "^5.90.21", + "@tanstack/react-virtual": "^3.13.23", "@workflow/world-postgres": "^4.1.0-beta.42", "ai": "^6.0.116", "better-auth": "^1.5.4", @@ -56,6 +57,7 @@ "recharts": "^3.8.0", "sonner": "^2.0.7", "sqlite-vec": "^0.1.7-alpha.2", + "sqlite-vec-darwin-arm64": "^0.1.7-alpha.2", "workflow": "^4.1.0-beta.63", "zhipu-ai-provider": "^0.2.2" },