diff --git a/app/financials/page.tsx b/app/financials/page.tsx index 50eafa0..e4be206 100644 --- a/app/financials/page.tsx +++ b/app/financials/page.tsx @@ -11,17 +11,20 @@ import { Bar, BarChart, CartesianGrid, - Legend, Line, - LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; -import { ChartNoAxesCombined, RefreshCcw, Search } from 'lucide-react'; +import { ChartNoAxesCombined, ChevronDown, RefreshCcw, Search } from 'lucide-react'; import { AppShell } from '@/components/shell/app-shell'; import { MetricCard } from '@/components/dashboard/metric-card'; +import { + FinancialControlBar, + type FinancialControlAction, + type FinancialControlSection +} from '@/components/financials/control-bar'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Panel } from '@/components/ui/panel'; @@ -32,61 +35,69 @@ import { formatPercent, type NumberScaleUnit } from '@/lib/format'; -import { queryKeys } from '@/lib/query/keys'; -import { companyAnalysisQueryOptions } from '@/lib/query/options'; -import type { CompanyAnalysis } from '@/lib/types'; +import { companyFinancialStatementsQueryOptions } from '@/lib/query/options'; +import type { + CompanyFinancialStatementsResponse, + DimensionBreakdownRow, + FilingFaithfulStatementRow, + FinancialHistoryWindow, + FinancialStatementKind, + FinancialStatementMode, + StandardizedStatementRow +} from '@/lib/types'; -type StatementPeriodPoint = { - filingDate: string; - filingType: '10-K' | '10-Q'; - periodLabel: 'Quarter End' | 'Fiscal Year End'; +type LoadOptions = { + cursor?: string | null; + append?: boolean; }; -type FinancialSeriesPoint = StatementPeriodPoint & { - periodKind: 'quarterly' | 'fiscalYearEnd'; - label: string; - revenue: number | null; - netIncome: number | null; - totalAssets: number | null; - cash: number | null; - debt: number | null; - netMargin: number | null; - debtToAssets: number | null; -}; - -type CompanyMetricPoint = StatementPeriodPoint & { - metrics: string[]; -}; - -type StatementMatrixRow = { - key: string; - label: string; - value: (point: TPoint, index: number, series: TPoint[]) => string; - valueClassName?: (point: TPoint, index: number, series: TPoint[]) => string | undefined; -}; - -type ChartPeriodFilter = 'quarterlyAndFiscalYearEnd' | 'quarterlyOnly' | 'fiscalYearEndOnly'; - -const CHART_PERIOD_FILTER_OPTIONS: Array<{ value: ChartPeriodFilter; label: string }> = [ - { value: 'quarterlyAndFiscalYearEnd', label: 'Quarterly + Fiscal Year End' }, - { value: 'quarterlyOnly', label: 'Quarterly only' }, - { value: 'fiscalYearEndOnly', label: 'Fiscal Year End only' } -]; - const FINANCIAL_VALUE_SCALE_OPTIONS: Array<{ value: NumberScaleUnit; label: string }> = [ { value: 'thousands', label: 'Thousands (K)' }, { value: 'millions', label: 'Millions (M)' }, { value: 'billions', label: 'Billions (B)' } ]; -const CHART_TEXT = '#e8fff8'; +const MODE_OPTIONS: Array<{ value: FinancialStatementMode; label: string }> = [ + { value: 'standardized', label: 'Standardized' }, + { value: 'filing_faithful', label: 'Filing-faithful' } +]; + +const STATEMENT_OPTIONS: Array<{ value: FinancialStatementKind; label: string }> = [ + { value: 'income', label: 'Income' }, + { value: 'balance', label: 'Balance Sheet' }, + { value: 'cash_flow', label: 'Cash Flow' }, + { value: 'equity', label: 'Equity' }, + { value: 'comprehensive_income', label: 'Comprehensive Income' } +]; + +const WINDOW_OPTIONS: Array<{ value: FinancialHistoryWindow; label: string }> = [ + { value: '10y', label: '10 Years' }, + { value: 'all', label: 'Full Available' } +]; + const CHART_MUTED = '#b4ced9'; const CHART_GRID = 'rgba(126, 217, 255, 0.24)'; const CHART_TOOLTIP_BG = 'rgba(6, 17, 24, 0.95)'; const CHART_TOOLTIP_BORDER = 'rgba(123, 255, 217, 0.45)'; -function renderLegendLabel(value: string) { - return {value}; +type OverviewPoint = { + periodId: string; + filingDate: string; + label: string; + revenue: number | null; + netIncome: number | null; + totalAssets: number | null; + cash: number | null; + debt: number | null; +}; + +function formatLongDate(value: string) { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return 'Unknown'; + } + + return format(parsed, 'MMM dd, yyyy'); } function formatShortDate(value: string) { @@ -98,13 +109,21 @@ function formatShortDate(value: string) { return format(parsed, 'MMM yyyy'); } -function formatLongDate(value: string) { - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) { - return 'Unknown'; +function asDisplayCurrency(value: number | null, scale: NumberScaleUnit) { + return value === null ? 'n/a' : formatCurrencyByScale(value, scale); +} + +function asAxisCurrencyTick(value: number, scale: NumberScaleUnit) { + return formatCurrencyByScale(value, scale, { maximumFractionDigits: 1 }); +} + +function asTooltipCurrency(value: unknown, scale: NumberScaleUnit) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return 'n/a'; } - return format(parsed, 'MMM dd, yyyy'); + return formatCurrencyByScale(numeric, scale); } function ratioPercent(numerator: number | null, denominator: number | null) { @@ -115,271 +134,163 @@ function ratioPercent(numerator: number | null, denominator: number | null) { return (numerator / denominator) * 100; } -function asDisplayCurrency( - value: number | null, - scale: NumberScaleUnit +function toStandardizedRows(data: CompanyFinancialStatementsResponse | null) { + if (!data || data.mode !== 'standardized') { + return []; + } + + return data.rows as StandardizedStatementRow[]; +} + +function toFilingFaithfulRows(data: CompanyFinancialStatementsResponse | null) { + if (!data || data.mode !== 'filing_faithful') { + return []; + } + + return data.rows as FilingFaithfulStatementRow[]; +} + +function rowValue(row: { values: Record }, periodId: string) { + return periodId in row.values ? row.values[periodId] : null; +} + +function mergeFinancialPages( + base: CompanyFinancialStatementsResponse | null, + next: CompanyFinancialStatementsResponse ) { - return value === null ? 'n/a' : formatCurrencyByScale(value, scale); -} - -function asDisplayPercent(value: number | null) { - return value === null ? 'n/a' : formatPercent(value); -} - -function asDisplayMultiple(value: number | null) { - return value === null ? 'n/a' : `${value.toFixed(2)}x`; -} - -function asDisplaySignedCurrency( - value: number | null, - scale: NumberScaleUnit -) { - if (value === null) { - return 'n/a'; + if (!base) { + return next; } - const formatted = formatCurrencyByScale(value, scale); - return value > 0 ? `+${formatted}` : formatted; -} + const periods = [...base.periods, ...next.periods] + .filter((period, index, list) => list.findIndex((item) => item.id === period.id) === index) + .sort((a, b) => Date.parse(a.filingDate) - Date.parse(b.filingDate)); -function normalizeTooltipValue(value: unknown) { - if (Array.isArray(value)) { - const [first] = value; - return typeof first === 'number' || typeof first === 'string' ? first : null; - } + const rowMap = new Map(); - if (typeof value === 'number' || typeof value === 'string') { - return value; - } - - return null; -} - -function asTooltipCurrency( - value: unknown, - scale: NumberScaleUnit -) { - const normalized = normalizeTooltipValue(value); - if (normalized === null) { - return 'n/a'; - } - - const numeric = Number(normalized); - if (!Number.isFinite(numeric)) { - return 'n/a'; - } - - return formatCurrencyByScale(numeric, scale); -} - -function asAxisCurrencyTick( - value: number, - scale: NumberScaleUnit -) { - return formatCurrencyByScale(value, scale, { maximumFractionDigits: 1 }); -} - -function includesChartPeriod(point: FinancialSeriesPoint, filter: ChartPeriodFilter) { - if (filter === 'quarterlyOnly') { - return point.periodKind === 'quarterly'; - } - - if (filter === 'fiscalYearEndOnly') { - return point.periodKind === 'fiscalYearEnd'; - } - - return true; -} - -function ratioMultiple(numerator: number | null, denominator: number | null) { - if (numerator === null || denominator === null || denominator === 0) { - return null; - } - - return numerator / denominator; -} - -function StatementMatrixTable({ - points, - rows -}: { - points: TPoint[]; - rows: Array>; -}) { - return ( -
- - - - - {points.map((point, index) => ( - - ))} - - - - {rows.map((row) => ( - - - {points.map((point, index, series) => ( - - ))} - - ))} - -
Metric -
- {formatLongDate(point.filingDate)} - {point.filingType} · {point.periodLabel} -
-
{row.label} - {row.value(point, index, series)} -
-
- ); -} - -function buildOperatingStatementRows( - scale: NumberScaleUnit -): Array> { - return [ - { - key: 'revenue', - label: 'Revenue', - value: (point) => asDisplayCurrency(point.revenue, scale) - }, - { - key: 'net-income', - label: 'Net Income', - value: (point) => asDisplayCurrency(point.netIncome, scale), - valueClassName: (point) => { - if (point.netIncome === null) { - return undefined; - } - - return point.netIncome >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9f9f]'; - } - }, - { - key: 'net-margin', - label: 'Net Margin', - value: (point) => asDisplayPercent(point.netMargin) + for (const row of [...base.rows, ...next.rows]) { + const existing = rowMap.get(row.key); + if (!existing) { + rowMap.set(row.key, { + ...row, + values: { ...row.values } + }); + continue; } - ]; -} -function buildBalanceSheetRows( - scale: NumberScaleUnit -): Array> { - return [ - { - key: 'total-assets', - label: 'Total Assets', - value: (point) => asDisplayCurrency(point.totalAssets, scale) - }, - { - key: 'cash', - label: 'Cash', - value: (point) => asDisplayCurrency(point.cash, scale) - }, - { - key: 'debt', - label: 'Debt', - value: (point) => asDisplayCurrency(point.debt, scale) - }, - { - key: 'debt-to-assets', - label: 'Debt / Assets', - value: (point) => asDisplayPercent(point.debtToAssets), - valueClassName: (point) => { - if (point.debtToAssets === null) { - return undefined; - } - - return point.debtToAssets <= 60 ? 'text-[#96f5bf]' : 'text-[#ffd08a]'; + existing.hasDimensions = existing.hasDimensions || row.hasDimensions; + for (const [periodId, value] of Object.entries(row.values)) { + if (!(periodId in existing.values)) { + existing.values[periodId] = value; } } - ]; -} -function buildCashFlowProxyRows( - scale: NumberScaleUnit -): Array> { - return [ - { - key: 'ending-cash', - label: 'Ending Cash Balance', - value: (point) => asDisplayCurrency(point.cash, scale) - }, - { - key: 'cash-change', - label: 'Cash Change vs Prior', - value: (point, index, series) => { - if (index === 0) { - return 'n/a'; + if ('sourceConcepts' in existing && 'sourceConcepts' in row) { + for (const concept of row.sourceConcepts) { + if (!existing.sourceConcepts.includes(concept)) { + existing.sourceConcepts.push(concept); } - - const previous = series[index - 1]; - if (point.cash === null || previous.cash === null) { - return 'n/a'; - } - - return asDisplaySignedCurrency(point.cash - previous.cash, scale); - }, - valueClassName: (point, index, series) => { - if (index === 0) { - return undefined; - } - - const previous = series[index - 1]; - if (point.cash === null || previous.cash === null) { - return undefined; - } - - return point.cash - previous.cash >= 0 ? 'text-[#96f5bf]' : 'text-[#ff9f9f]'; } - }, - { - key: 'debt-change', - label: 'Debt Change vs Prior', - value: (point, index, series) => { - if (index === 0) { - return 'n/a'; - } - - const previous = series[index - 1]; - if (point.debt === null || previous.debt === null) { - return 'n/a'; - } - - return asDisplaySignedCurrency(point.debt - previous.debt, scale); - }, - valueClassName: (point, index, series) => { - if (index === 0) { - return undefined; - } - - const previous = series[index - 1]; - if (point.debt === null || previous.debt === null) { - return undefined; - } - - return point.debt - previous.debt <= 0 ? 'text-[#96f5bf]' : 'text-[#ff9f9f]'; - } - }, - { - key: 'cash-to-debt', - label: 'Cash / Debt', - value: (point) => asDisplayMultiple(ratioMultiple(point.cash, point.debt)) } - ]; + } + + const dimensionBreakdown = (() => { + if (!base.dimensionBreakdown && !next.dimensionBreakdown) { + return null; + } + + const map = new Map(); + for (const source of [base.dimensionBreakdown, next.dimensionBreakdown]) { + if (!source) { + continue; + } + + for (const [key, rows] of Object.entries(source)) { + const existing = map.get(key); + if (existing) { + existing.push(...rows); + } else { + map.set(key, [...rows]); + } + } + } + + return Object.fromEntries(map.entries()); + })(); + + const mergedRows = [...rowMap.values()]; + + return { + ...next, + periods, + rows: next.mode === 'standardized' + ? mergedRows as StandardizedStatementRow[] + : mergedRows as FilingFaithfulStatementRow[], + nextCursor: next.nextCursor, + coverage: { + filings: periods.length, + rows: rowMap.size, + dimensions: dimensionBreakdown + ? Object.values(dimensionBreakdown).reduce((total, rows) => total + rows.length, 0) + : 0 + }, + dataSourceStatus: { + ...next.dataSourceStatus, + queuedSync: base.dataSourceStatus.queuedSync || next.dataSourceStatus.queuedSync + }, + dimensionBreakdown + }; } -function isFinancialPeriodForm(filingType: CompanyAnalysis['financials'][number]['filingType']): filingType is '10-K' | '10-Q' { - return filingType === '10-K' || filingType === '10-Q'; +function findStandardizedRowValue( + data: CompanyFinancialStatementsResponse | null, + preferredKey: string, + fallbackIncludes: string[] +) { + const rows = toStandardizedRows(data); + const exact = rows.find((row) => row.key === preferredKey); + if (exact) { + return exact; + } + + return rows.find((row) => { + const haystack = `${row.key} ${row.label} ${row.concept}`.toLowerCase(); + return fallbackIncludes.some((needle) => haystack.includes(needle)); + }) ?? null; +} + +function buildOverviewSeries( + incomeData: CompanyFinancialStatementsResponse | null, + balanceData: CompanyFinancialStatementsResponse | null +): OverviewPoint[] { + const periodMap = new Map(); + + for (const source of [incomeData, balanceData]) { + for (const period of source?.periods ?? []) { + periodMap.set(period.id, { filingDate: period.filingDate }); + } + } + + const periods = [...periodMap.entries()] + .map(([periodId, data]) => ({ periodId, filingDate: data.filingDate })) + .sort((a, b) => Date.parse(a.filingDate) - Date.parse(b.filingDate)); + + const revenueRow = findStandardizedRowValue(incomeData, 'revenue', ['revenue', 'sales']); + const netIncomeRow = findStandardizedRowValue(incomeData, 'net-income', ['net income', 'profit']); + const assetsRow = findStandardizedRowValue(balanceData, 'total-assets', ['total assets']); + const cashRow = findStandardizedRowValue(balanceData, 'cash-and-equivalents', ['cash']); + const debtRow = findStandardizedRowValue(balanceData, 'total-debt', ['debt', 'borrowings']); + + return periods.map((period) => ({ + periodId: period.periodId, + filingDate: period.filingDate, + label: formatShortDate(period.filingDate), + revenue: revenueRow ? rowValue(revenueRow, period.periodId) : null, + netIncome: netIncomeRow ? rowValue(netIncomeRow, period.periodId) : null, + totalAssets: assetsRow ? rowValue(assetsRow, period.periodId) : null, + cash: cashRow ? rowValue(cashRow, period.periodId) : null, + debt: debtRow ? rowValue(debtRow, period.periodId) : null + })); } export default function FinancialsPage() { @@ -398,11 +309,18 @@ function FinancialsPageContent() { const [tickerInput, setTickerInput] = useState('MSFT'); const [ticker, setTicker] = useState('MSFT'); - const [analysis, setAnalysis] = useState(null); + const [mode, setMode] = useState('standardized'); + const [statement, setStatement] = useState('income'); + const [window, setWindow] = useState('10y'); + const [valueScale, setValueScale] = useState('millions'); + const [financials, setFinancials] = useState(null); + const [overviewIncome, setOverviewIncome] = useState(null); + const [overviewBalance, setOverviewBalance] = useState(null); + const [selectedRowKey, setSelectedRowKey] = useState(null); + const [dimensionsEnabled, setDimensionsEnabled] = useState(false); const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); - const [chartPeriodFilter, setChartPeriodFilter] = useState('quarterlyAndFiscalYearEnd'); - const [financialValueScale, setFinancialValueScale] = useState('millions'); useEffect(() => { const fromQuery = searchParams.get('ticker'); @@ -419,133 +337,217 @@ function FinancialsPageContent() { setTicker(normalized); }, [searchParams]); - const loadFinancials = useCallback(async (symbol: string) => { - const options = companyAnalysisQueryOptions(symbol); + const loadOverview = useCallback(async (symbol: string, selectedWindow: FinancialHistoryWindow) => { + const [incomeResponse, balanceResponse] = await Promise.all([ + queryClient.ensureQueryData(companyFinancialStatementsQueryOptions({ + ticker: symbol, + mode: 'standardized', + statement: 'income', + window: selectedWindow, + includeDimensions: false, + limit: selectedWindow === 'all' ? 120 : 80 + })), + queryClient.ensureQueryData(companyFinancialStatementsQueryOptions({ + ticker: symbol, + mode: 'standardized', + statement: 'balance', + window: selectedWindow, + includeDimensions: false, + limit: selectedWindow === 'all' ? 120 : 80 + })) + ]); - if (!queryClient.getQueryData(options.queryKey)) { + setOverviewIncome(incomeResponse.financials); + setOverviewBalance(balanceResponse.financials); + }, [queryClient]); + + const loadFinancials = useCallback(async (symbol: string, options?: LoadOptions) => { + const normalizedTicker = symbol.trim().toUpperCase(); + const nextCursor = options?.cursor ?? null; + const includeDimensions = dimensionsEnabled || selectedRowKey !== null; + + if (!options?.append) { setLoading(true); + } else { + setLoadingMore(true); } setError(null); try { - const response = await queryClient.ensureQueryData(options); - setAnalysis(response.analysis); + const response = await queryClient.ensureQueryData(companyFinancialStatementsQueryOptions({ + ticker: normalizedTicker, + mode, + statement, + window, + includeDimensions, + cursor: nextCursor, + limit: window === 'all' ? 60 : 80 + })); + + setFinancials((current) => { + if (options?.append) { + return mergeFinancialPages(current, response.financials); + } + + return response.financials; + }); + + await loadOverview(normalizedTicker, window); } catch (err) { setError(err instanceof Error ? err.message : 'Unable to load financial history'); - setAnalysis(null); + if (!options?.append) { + setFinancials(null); + } } finally { setLoading(false); + setLoadingMore(false); } - }, [queryClient]); + }, [ + queryClient, + mode, + statement, + window, + dimensionsEnabled, + selectedRowKey, + loadOverview + ]); useEffect(() => { if (!isPending && isAuthenticated) { void loadFinancials(ticker); } - }, [isPending, isAuthenticated, ticker, loadFinancials]); + }, [isPending, isAuthenticated, ticker, mode, statement, window, dimensionsEnabled, loadFinancials]); - const financialSeries = useMemo(() => { - return (analysis?.financials ?? []) - .filter((entry): entry is CompanyAnalysis['financials'][number] & { filingType: '10-K' | '10-Q' } => { - return isFinancialPeriodForm(entry.filingType); - }) - .slice() - .sort((a, b) => Date.parse(a.filingDate) - Date.parse(b.filingDate)) - .map((entry) => ({ - filingDate: entry.filingDate, - filingType: entry.filingType, - periodKind: entry.filingType === '10-Q' ? 'quarterly' : 'fiscalYearEnd', - periodLabel: entry.filingType === '10-Q' ? 'Quarter End' : 'Fiscal Year End', - label: formatShortDate(entry.filingDate), - revenue: entry.revenue ?? null, - netIncome: entry.netIncome ?? null, - totalAssets: entry.totalAssets ?? null, - cash: entry.cash ?? null, - debt: entry.debt ?? null, - netMargin: ratioPercent(entry.netIncome ?? null, entry.revenue ?? null), - debtToAssets: ratioPercent(entry.debt ?? null, entry.totalAssets ?? null) - })); - }, [analysis?.financials]); + const periods = useMemo(() => { + return [...(financials?.periods ?? [])] + .sort((a, b) => Date.parse(a.filingDate) - Date.parse(b.filingDate)); + }, [financials?.periods]); - const chartSeries = useMemo(() => { - return financialSeries.filter((point) => includesChartPeriod(point, chartPeriodFilter)); - }, [financialSeries, chartPeriodFilter]); + const standardizedRows = useMemo(() => toStandardizedRows(financials), [financials]); + const filingFaithfulRows = useMemo(() => toFilingFaithfulRows(financials), [financials]); - const selectedChartFilterLabel = useMemo(() => { - return CHART_PERIOD_FILTER_OPTIONS.find((option) => option.value === chartPeriodFilter)?.label ?? 'Quarterly + Fiscal Year End'; - }, [chartPeriodFilter]); + const statementRows = mode === 'standardized' + ? standardizedRows + : filingFaithfulRows; - const selectedFinancialScaleLabel = useMemo(() => { - return FINANCIAL_VALUE_SCALE_OPTIONS.find((option) => option.value === financialValueScale)?.label ?? 'Millions (M)'; - }, [financialValueScale]); + const overviewSeries = useMemo(() => { + return buildOverviewSeries(overviewIncome, overviewBalance); + }, [overviewIncome, overviewBalance]); - const latestSnapshot = financialSeries[financialSeries.length - 1] ?? null; + const latestOverview = overviewSeries[overviewSeries.length - 1] ?? null; - const liquidityRatio = useMemo(() => { - if (!latestSnapshot || latestSnapshot.cash === null || latestSnapshot.debt === null || latestSnapshot.debt === 0) { + const selectedRow = useMemo(() => { + if (!selectedRowKey) { return null; } - return latestSnapshot.cash / latestSnapshot.debt; - }, [latestSnapshot]); + return statementRows.find((row) => row.key === selectedRowKey) ?? null; + }, [selectedRowKey, statementRows]); - const coverage = useMemo(() => { - const total = chartSeries.length; + const dimensionRows = useMemo(() => { + if (!selectedRow || !financials?.dimensionBreakdown) { + return []; + } - const asCoverage = (entries: number) => { - if (total === 0) { - return '0%'; + const direct = financials.dimensionBreakdown[selectedRow.key] ?? []; + if (direct.length > 0) { + return direct; + } + + if ('concept' in selectedRow && selectedRow.concept) { + const conceptKey = selectedRow.concept.toLowerCase(); + for (const rows of Object.values(financials.dimensionBreakdown)) { + const matched = rows.filter((row) => (row.concept ?? '').toLowerCase() === conceptKey); + if (matched.length > 0) { + return matched; + } } + } - return `${Math.round((entries / total) * 100)}%`; - }; + return []; + }, [selectedRow, financials?.dimensionBreakdown]); - return { - total, - revenue: asCoverage(chartSeries.filter((point) => point.revenue !== null).length), - netIncome: asCoverage(chartSeries.filter((point) => point.netIncome !== null).length), - assets: asCoverage(chartSeries.filter((point) => point.totalAssets !== null).length), - cash: asCoverage(chartSeries.filter((point) => point.cash !== null).length), - debt: asCoverage(chartSeries.filter((point) => point.debt !== null).length) - }; - }, [chartSeries]); + const selectedScaleLabel = useMemo(() => { + return FINANCIAL_VALUE_SCALE_OPTIONS.find((option) => option.value === valueScale)?.label ?? 'Millions (M)'; + }, [valueScale]); - const companyMetricSeries = useMemo(() => { - return (analysis?.filings ?? []) - .filter((entry): entry is CompanyAnalysis['filings'][number] & { filing_type: '10-K' | '10-Q' } => { - return isFinancialPeriodForm(entry.filing_type); - }) - .slice() - .sort((a, b) => Date.parse(a.filing_date) - Date.parse(b.filing_date)) - .map((entry) => ({ - filingDate: entry.filing_date, - filingType: entry.filing_type, - periodLabel: entry.filing_type === '10-Q' ? 'Quarter End' : 'Fiscal Year End', - metrics: entry.analysis?.companyMetrics ?? [] - })); - }, [analysis?.filings]); + const controlSections = useMemo(() => [ + { + id: 'mode', + label: 'Mode', + value: mode, + options: MODE_OPTIONS, + onChange: (nextValue) => { + setMode(nextValue as FinancialStatementMode); + setSelectedRowKey(null); + } + }, + { + id: 'statement', + label: 'Statement', + value: statement, + options: STATEMENT_OPTIONS, + onChange: (nextValue) => { + setStatement(nextValue as FinancialStatementKind); + setSelectedRowKey(null); + } + }, + { + id: 'history', + label: 'Window', + value: window, + options: WINDOW_OPTIONS, + onChange: (nextValue) => { + setWindow(nextValue as FinancialHistoryWindow); + setSelectedRowKey(null); + } + }, + { + id: 'scale', + label: 'Scale', + value: valueScale, + options: FINANCIAL_VALUE_SCALE_OPTIONS, + onChange: (nextValue) => setValueScale(nextValue as NumberScaleUnit) + } + ], [mode, statement, window, valueScale]); - const companyMetricRows = useMemo>>(() => { - const rowCount = companyMetricSeries.reduce((max, point) => Math.max(max, point.metrics.length), 0); - return Array.from({ length: rowCount }, (_, index) => ({ - key: `company-metric-${index + 1}`, - label: `Metric ${index + 1}`, - value: (point) => point.metrics[index] ?? 'n/a' - })); - }, [companyMetricSeries]); + const controlActions = useMemo(() => { + const actions: FinancialControlAction[] = []; - const operatingStatementRows = useMemo(() => { - return buildOperatingStatementRows(financialValueScale); - }, [financialValueScale]); + if (window === '10y') { + actions.push({ + id: 'load-full-history', + label: 'Load Full History', + variant: 'secondary', + onClick: () => { + setWindow('all'); + setSelectedRowKey(null); + } + }); + } - const balanceSheetRows = useMemo(() => { - return buildBalanceSheetRows(financialValueScale); - }, [financialValueScale]); + if (window === 'all' && financials?.nextCursor) { + actions.push({ + id: 'load-older-periods', + label: loadingMore ? 'Loading Older...' : 'Load Older Periods', + variant: 'secondary', + disabled: loadingMore, + onClick: () => { + if (!financials.nextCursor) { + return; + } - const cashFlowProxyRows = useMemo(() => { - return buildCashFlowProxyRows(financialValueScale); - }, [financialValueScale]); + void loadFinancials(ticker, { + cursor: financials.nextCursor, + append: true + }); + } + }); + } + + return actions; + }, [window, financials?.nextCursor, loadingMore, loadFinancials, ticker]); if (isPending || !isAuthenticated) { return
Loading financial terminal...
; @@ -554,13 +556,12 @@ function FinancialsPageContent() { return ( { - void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(ticker.trim().toUpperCase()) }); void loadFinancials(ticker); }} > @@ -569,7 +570,7 @@ function FinancialsPageContent() { )} > - +
{ @@ -591,25 +592,15 @@ function FinancialsPageContent() { Load Financials - {analysis ? ( - <> - prefetchResearchTicker(analysis.company.ticker)} - onFocus={() => prefetchResearchTicker(analysis.company.ticker)} - className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]" - > - Open full analysis - - prefetchResearchTicker(analysis.company.ticker)} - onFocus={() => prefetchResearchTicker(analysis.company.ticker)} - className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]" - > - Open filings stream - - + {financials ? ( + prefetchResearchTicker(financials.company.ticker)} + onFocus={() => prefetchResearchTicker(financials.company.ticker)} + className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]" + > + Open analysis + ) : null}
@@ -623,98 +614,61 @@ function FinancialsPageContent() {
= 0} + value={latestOverview ? asDisplayCurrency(latestOverview.netIncome, valueScale) : 'n/a'} + delta={latestOverview ? `Net margin ${formatPercent(ratioPercent(latestOverview.netIncome, latestOverview.revenue) ?? 0)}` : 'No standardized history'} + positive={(latestOverview?.netIncome ?? 0) >= 0} />
- -
- {CHART_PERIOD_FILTER_OPTIONS.map((option) => ( - - ))} -
-
- - -
- {FINANCIAL_VALUE_SCALE_OPTIONS.map((option) => ( - - ))} -
-
+
- + {loading ? ( -

Loading statement data...

- ) : chartSeries.length === 0 ? ( -

No filing metrics match the current chart period filter.

+

Loading overview chart...

+ ) : overviewSeries.length === 0 ? ( +

No standardized income history available yet.

) : ( -
+
- + - + asAxisCurrencyTick(value, financialValueScale)} + tickFormatter={(value: number) => asAxisCurrencyTick(value, valueScale)} /> asTooltipCurrency(value, financialValueScale)} + formatter={(value) => asTooltipCurrency(value, valueScale)} contentStyle={{ backgroundColor: CHART_TOOLTIP_BG, border: `1px solid ${CHART_TOOLTIP_BORDER}`, borderRadius: '0.75rem' }} - labelStyle={{ color: CHART_TEXT }} - itemStyle={{ color: CHART_TEXT }} - cursor={{ fill: 'rgba(104, 255, 213, 0.08)' }} /> - @@ -723,52 +677,31 @@ function FinancialsPageContent() { )} - + {loading ? ( -

Loading balance sheet data...

- ) : chartSeries.length === 0 ? ( -

No balance sheet metrics match the current chart period filter.

+

Loading balance chart...

+ ) : overviewSeries.length === 0 ? ( +

No standardized balance history available yet.

) : ( -
+
- - - - - - - + - + asAxisCurrencyTick(value, financialValueScale)} + tickFormatter={(value: number) => asAxisCurrencyTick(value, valueScale)} /> asTooltipCurrency(value, financialValueScale)} + formatter={(value) => asTooltipCurrency(value, valueScale)} contentStyle={{ backgroundColor: CHART_TOOLTIP_BG, border: `1px solid ${CHART_TOOLTIP_BORDER}`, borderRadius: '0.75rem' }} - labelStyle={{ color: CHART_TEXT }} - itemStyle={{ color: CHART_TEXT }} - cursor={{ fill: 'rgba(104, 255, 213, 0.08)' }} /> - - + @@ -778,136 +711,138 @@ function FinancialsPageContent() {
-
- - {loading ? ( -

Loading ratio trends...

- ) : chartSeries.length === 0 ? ( -

No ratio points match the current chart period filter.

- ) : ( -
- - - - - `${value.toFixed(0)}%`} - /> - { - const normalized = normalizeTooltipValue(value); - if (normalized === null) { - return 'n/a'; + + {loading ? ( +

Loading statement matrix...

+ ) : periods.length === 0 || statementRows.length === 0 ? ( +

No statement rows available for the selected filters yet.

+ ) : ( +
+ + + + + {periods.map((period) => ( + + ))} + + + + {statementRows.map((row) => ( + { + setSelectedRowKey(row.key); + if (row.hasDimensions && !dimensionsEnabled) { + setDimensionsEnabled(true); } - - const numeric = Number(normalized); - return Number.isFinite(numeric) ? `${numeric.toFixed(2)}%` : 'n/a'; }} - contentStyle={{ - backgroundColor: CHART_TOOLTIP_BG, - border: `1px solid ${CHART_TOOLTIP_BORDER}`, - borderRadius: '0.75rem' - }} - labelStyle={{ color: CHART_TEXT }} - itemStyle={{ color: CHART_TEXT }} - cursor={{ stroke: 'rgba(104, 255, 213, 0.35)', strokeWidth: 1 }} - /> - - - - - - - )} - + > + + {periods.map((period) => ( + + ))} + + ))} + +
Metric +
+ {formatLongDate(period.filingDate)} + {period.filingType} · {period.periodLabel} +
+
+ {'depth' in row ? ( +
+ {row.label} + {row.hasDimensions ? : null} +
+ ) : ( +
+ {row.label} + {row.hasDimensions ? : null} +
+ )} +
+ {asDisplayCurrency(rowValue(row, period.id), valueScale)} +
+
+ )} - -
-
-
Revenue coverage
-
{coverage.revenue}
-
-
-
Net income coverage
-
{coverage.netIncome}
-
-
-
Asset coverage
-
{coverage.assets}
-
-
-
Cash coverage
-
{coverage.cash}
-
-
-
Debt coverage
-
{coverage.debt}
-
-
-
-
+
-
- - {loading ? ( -

Loading operating statement...

- ) : financialSeries.length === 0 ? ( -

No operating statement rows are available for this ticker yet.

- ) : ( - - )} -
+ + {!selectedRow ? ( +

Select a statement row to inspect dimensional facts.

+ ) : !selectedRow.hasDimensions ? ( +

No dimensional data is available for {selectedRow.label}.

+ ) : !dimensionsEnabled ? ( +

Enable dimensions by selecting the row again.

+ ) : dimensionRows.length === 0 ? ( +

Dimensions are still loading or unavailable for this row.

+ ) : ( +
+ + + + + + + + + + + {dimensionRows.map((row, index) => { + const period = periods.find((item) => item.id === row.periodId); + return ( + + + + + + + ); + })} + +
PeriodAxisMemberValue
{period ? formatLongDate(period.filingDate) : row.periodId}{row.axis}{row.member}{asDisplayCurrency(row.value, valueScale)}
+
+ )} +
- - {loading ? ( -

Loading balance sheet...

- ) : financialSeries.length === 0 ? ( -

No balance sheet rows are available for this ticker yet.

- ) : ( - - )} + {financials ? ( + +
+
+

Hydrated

+

{financials.dataSourceStatus.hydratedFilings}

+
+
+

Partial

+

{financials.dataSourceStatus.partialFilings}

+
+
+

Failed

+

{financials.dataSourceStatus.failedFilings}

+
+
+

Pending

+

{financials.dataSourceStatus.pendingFilings}

+
+
+

Background Sync

+

{financials.dataSourceStatus.queuedSync ? 'Queued' : 'Idle'}

+
+
- - - {loading ? ( -

Loading cash flow statement...

- ) : financialSeries.length === 0 ? ( -

No cash flow statement rows are available for this ticker yet.

- ) : ( - - )} -
- - - {loading ? ( -

Loading company metrics...

- ) : companyMetricSeries.length === 0 ? ( -

No filing periods are available for company metrics yet.

- ) : companyMetricRows.length === 0 ? ( -

No company-specific KPI metrics were extracted for the selected filings.

- ) : ( - - )} -
-
+ ) : null}
- Financial lens: revenue + margin + balance sheet strength + Financial Statements V2: standardized + filing-faithful history
diff --git a/components/financials/control-bar.tsx b/components/financials/control-bar.tsx new file mode 100644 index 0000000..256cabe --- /dev/null +++ b/components/financials/control-bar.tsx @@ -0,0 +1,99 @@ +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +type ControlButtonVariant = 'primary' | 'ghost' | 'secondary' | 'danger'; + +export type FinancialControlOption = { + value: string; + label: string; + disabled?: boolean; +}; + +export type FinancialControlSection = { + id: string; + label: string; + value: string; + options: FinancialControlOption[]; + onChange: (value: string) => void; +}; + +export type FinancialControlAction = { + id: string; + label: string; + onClick: () => void; + disabled?: boolean; + variant?: ControlButtonVariant; +}; + +type FinancialControlBarProps = { + title?: string; + subtitle?: string; + sections: FinancialControlSection[]; + actions?: FinancialControlAction[]; + className?: string; +}; + +export function FinancialControlBar({ + title = 'Control Bar', + subtitle, + sections, + actions, + className +}: FinancialControlBarProps) { + return ( +
+
+
+

{title}

+ {subtitle ? ( +

{subtitle}

+ ) : null} +
+ + {actions && actions.length > 0 ? ( +
+ {actions.map((action) => ( + + ))} +
+ ) : null} +
+ +
+
+ {sections.map((section) => ( +
+ {section.label} +
+ {section.options.map((option) => ( + + ))} +
+
+ ))} +
+
+
+ ); +}