diff --git a/app/financials/page.tsx b/app/financials/page.tsx new file mode 100644 index 0000000..f46f780 --- /dev/null +++ b/app/financials/page.tsx @@ -0,0 +1,456 @@ +'use client'; + +import Link from 'next/link'; +import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; +import { format } from 'date-fns'; +import { useSearchParams } from 'next/navigation'; +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + Legend, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis +} from 'recharts'; +import { ChartNoAxesCombined, RefreshCcw, Search } from 'lucide-react'; +import { AppShell } from '@/components/shell/app-shell'; +import { MetricCard } from '@/components/dashboard/metric-card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Panel } from '@/components/ui/panel'; +import { useAuthGuard } from '@/hooks/use-auth-guard'; +import { getCompanyAnalysis } from '@/lib/api'; +import { formatCompactCurrency, formatCurrency, formatPercent } from '@/lib/format'; +import type { CompanyAnalysis } from '@/lib/types'; + +type FinancialSeriesPoint = { + filingDate: string; + filingType: '10-K' | '10-Q' | '8-K'; + label: string; + revenue: number | null; + netIncome: number | null; + totalAssets: number | null; + cash: number | null; + debt: number | null; + netMargin: number | null; + debtToAssets: number | null; +}; + +const AXIS_CURRENCY = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + notation: 'compact', + maximumFractionDigits: 1 +}); + +function formatShortDate(value: string) { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return 'Unknown'; + } + + return format(parsed, 'MMM yyyy'); +} + +function formatLongDate(value: string) { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return 'Unknown'; + } + + return format(parsed, 'MMM dd, yyyy'); +} + +function ratioPercent(numerator: number | null, denominator: number | null) { + if (numerator === null || denominator === null || denominator === 0) { + return null; + } + + return (numerator / denominator) * 100; +} + +function asDisplayCurrency(value: number | null) { + return value === null ? 'n/a' : formatCurrency(value); +} + +function asDisplayPercent(value: number | null) { + return value === null ? 'n/a' : formatPercent(value); +} + +function normalizeTooltipValue(value: unknown) { + if (Array.isArray(value)) { + const [first] = value; + return typeof first === 'number' || typeof first === 'string' ? first : null; + } + + if (typeof value === 'number' || typeof value === 'string') { + return value; + } + + return null; +} + +function asTooltipCurrency(value: unknown) { + const normalized = normalizeTooltipValue(value); + if (normalized === null) { + return 'n/a'; + } + + const numeric = Number(normalized); + if (!Number.isFinite(numeric)) { + return 'n/a'; + } + + return formatCompactCurrency(numeric); +} + +export default function FinancialsPage() { + return ( + Loading financial terminal...}> + + + ); +} + +function FinancialsPageContent() { + const { isPending, isAuthenticated } = useAuthGuard(); + const searchParams = useSearchParams(); + + const [tickerInput, setTickerInput] = useState('MSFT'); + const [ticker, setTicker] = useState('MSFT'); + const [analysis, setAnalysis] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fromQuery = searchParams.get('ticker'); + if (!fromQuery) { + return; + } + + const normalized = fromQuery.trim().toUpperCase(); + if (!normalized) { + return; + } + + setTickerInput(normalized); + setTicker(normalized); + }, [searchParams]); + + const loadFinancials = useCallback(async (symbol: string) => { + setLoading(true); + setError(null); + + try { + const response = await getCompanyAnalysis(symbol); + setAnalysis(response.analysis); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unable to load financial history'); + setAnalysis(null); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (!isPending && isAuthenticated) { + void loadFinancials(ticker); + } + }, [isPending, isAuthenticated, ticker, loadFinancials]); + + const financialSeries = useMemo(() => { + return (analysis?.financials ?? []) + .slice() + .sort((a, b) => Date.parse(a.filingDate) - Date.parse(b.filingDate)) + .map((entry) => ({ + filingDate: entry.filingDate, + filingType: entry.filingType, + 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 latestSnapshot = financialSeries[financialSeries.length - 1] ?? null; + + const liquidityRatio = useMemo(() => { + if (!latestSnapshot || latestSnapshot.cash === null || latestSnapshot.debt === null || latestSnapshot.debt === 0) { + return null; + } + + return latestSnapshot.cash / latestSnapshot.debt; + }, [latestSnapshot]); + + const coverage = useMemo(() => { + const total = financialSeries.length; + + const asCoverage = (entries: number) => { + if (total === 0) { + return '0%'; + } + + return `${Math.round((entries / total) * 100)}%`; + }; + + return { + total, + revenue: asCoverage(financialSeries.filter((point) => point.revenue !== null).length), + netIncome: asCoverage(financialSeries.filter((point) => point.netIncome !== null).length), + assets: asCoverage(financialSeries.filter((point) => point.totalAssets !== null).length), + cash: asCoverage(financialSeries.filter((point) => point.cash !== null).length), + debt: asCoverage(financialSeries.filter((point) => point.debt !== null).length) + }; + }, [financialSeries]); + + if (isPending || !isAuthenticated) { + return
Loading financial terminal...
; + } + + return ( + void loadFinancials(ticker)}> + + Refresh + + )} + > + +
{ + event.preventDefault(); + const normalized = tickerInput.trim().toUpperCase(); + if (!normalized) { + return; + } + setTicker(normalized); + }} + > + setTickerInput(event.target.value.toUpperCase())} + placeholder="Ticker (AAPL)" + className="max-w-xs" + /> + + {analysis ? ( + <> + + Open full analysis + + + Open filings stream + + + ) : null} +
+
+ + {error ? ( + +

{error}

+
+ ) : null} + +
+ + = 0} + /> + + +
+ +
+ + {loading ? ( +

Loading statement data...

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

No parsed filing metrics available yet for this ticker.

+ ) : ( +
+ + + + + AXIS_CURRENCY.format(value)} /> + asTooltipCurrency(value)} /> + + + + + +
+ )} +
+ + + {loading ? ( +

Loading balance sheet data...

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

No balance sheet metrics available yet.

+ ) : ( +
+ + + + + + + + + + + AXIS_CURRENCY.format(value)} /> + asTooltipCurrency(value)} /> + + + + + + +
+ )} +
+
+ +
+ + {loading ? ( +

Loading ratio trends...

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

No ratio data available yet.

+ ) : ( +
+ + + + + `${value.toFixed(0)}%`} /> + { + const normalized = normalizeTooltipValue(value); + if (normalized === null) { + return 'n/a'; + } + + const numeric = Number(normalized); + return Number.isFinite(numeric) ? `${numeric.toFixed(2)}%` : 'n/a'; + }} + /> + + + + + +
+ )} +
+ + +
+
+
Revenue coverage
+
{coverage.revenue}
+
+
+
Net income coverage
+
{coverage.netIncome}
+
+
+
Asset coverage
+
{coverage.assets}
+
+
+
Cash coverage
+
{coverage.cash}
+
+
+
Debt coverage
+
{coverage.debt}
+
+
+
+
+ + + {loading ? ( +

Loading table...

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

No financial rows are available for this ticker yet.

+ ) : ( +
+ + + + + + + + + + + + + + + {financialSeries.map((point) => ( + + + + + + + + + + + ))} + +
FiledFormRevenueNet IncomeTotal AssetsCashDebtNet Margin
{formatLongDate(point.filingDate)}{point.filingType}{asDisplayCurrency(point.revenue)}= 0 ? 'text-[#96f5bf]' : 'text-[#ff9f9f]'}>{asDisplayCurrency(point.netIncome)}{asDisplayCurrency(point.totalAssets)}{asDisplayCurrency(point.cash)}{asDisplayCurrency(point.debt)}{asDisplayPercent(point.netMargin)}
+
+ )} +
+ + +
+ + Financial lens: revenue + margin + balance sheet strength +
+
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 4dec0f6..474c017 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -202,11 +202,15 @@ export default function CommandCenterPage() { -
+

Analysis

Inspect one company across prices, filings, financials, and AI reports.

+ +

Financials

+

Focus on multi-period filing metrics, margins, leverage, and balance sheet composition.

+

Filings

Sync SEC filings and trigger AI memo analysis.

diff --git a/components/shell/app-shell.tsx b/components/shell/app-shell.tsx index cceb70b..b0e85bc 100644 --- a/components/shell/app-shell.tsx +++ b/components/shell/app-shell.tsx @@ -3,7 +3,7 @@ import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; import { useState } from 'react'; -import { Activity, BookOpenText, ChartCandlestick, Eye, LineChart, LogOut } from 'lucide-react'; +import { Activity, BookOpenText, ChartCandlestick, Eye, Landmark, LineChart, LogOut } from 'lucide-react'; import { authClient } from '@/lib/auth-client'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -18,6 +18,7 @@ type AppShellProps = { const NAV_ITEMS = [ { href: '/', label: 'Command Center', icon: Activity }, { href: '/analysis', label: 'Company Analysis', icon: LineChart }, + { href: '/financials', label: 'Financials', icon: Landmark }, { href: '/filings', label: 'Filings Stream', icon: BookOpenText }, { href: '/portfolio', label: 'Portfolio Matrix', icon: ChartCandlestick }, { href: '/watchlist', label: 'Watchlist', icon: Eye }