Files
Neon-Desk/app/graphing/page.tsx

665 lines
25 KiB
TypeScript

'use client';
import { useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import { Bar, BarChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import Link from 'next/link';
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { BarChart3, RefreshCcw, Search, X } from 'lucide-react';
import { AppShell } from '@/components/shell/app-shell';
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 { useLinkPrefetch } from '@/hooks/use-link-prefetch';
import {
GRAPH_CADENCE_OPTIONS,
GRAPH_CHART_OPTIONS,
GRAPH_SCALE_OPTIONS,
GRAPH_SURFACE_LABELS,
buildGraphingHref,
getGraphMetricDefinition,
metricsForSurfaceAndCadence,
normalizeGraphTickers,
parseGraphingParams,
resolveGraphMetric,
serializeGraphingParams,
type GraphChartKind,
type GraphMetricDefinition,
type GraphingUrlState
} from '@/lib/graphing/catalog';
import {
buildGraphingComparisonData,
type GraphingChartDatum,
type GraphingFetchResult,
type GraphingLatestValueRow,
type GraphingSeriesPoint
} from '@/lib/graphing/series';
import { ApiError } from '@/lib/api';
import {
formatCurrencyByScale,
formatPercent,
type NumberScaleUnit
} from '@/lib/format';
import type {
FinancialCadence,
FinancialUnit
} from '@/lib/types';
import { companyFinancialStatementsQueryOptions } from '@/lib/query/options';
import { cn } from '@/lib/utils';
const CHART_COLORS = ['#68ffd5', '#5fd3ff', '#ffd08a', '#ff8a8a', '#c39bff'] as const;
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)';
type TooltipEntry = {
dataKey?: string | number;
color?: string;
value?: number | string | null;
payload?: GraphingChartDatum;
};
function formatLongDate(value: string | null) {
if (!value) {
return 'n/a';
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return 'n/a';
}
return format(parsed, 'MMM dd, yyyy');
}
function formatShortDate(value: string) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return format(parsed, 'MMM yyyy');
}
function formatMetricValue(
value: number | null | undefined,
unit: FinancialUnit,
scale: NumberScaleUnit
) {
if (value === null || value === undefined) {
return 'n/a';
}
switch (unit) {
case 'currency':
return formatCurrencyByScale(value, scale);
case 'percent':
return formatPercent(value * 100);
case 'ratio':
return `${value.toFixed(2)}x`;
case 'shares':
case 'count':
return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 2 }).format(value);
default:
return String(value);
}
}
function formatChangeValue(
value: number | null,
unit: FinancialUnit,
scale: NumberScaleUnit
) {
if (value === null) {
return 'n/a';
}
if (unit === 'percent') {
const signed = value >= 0 ? '+' : '';
return `${signed}${formatPercent(value * 100)}`;
}
if (unit === 'ratio') {
const signed = value >= 0 ? '+' : '';
return `${signed}${value.toFixed(2)}x`;
}
if (unit === 'currency') {
const formatted = formatCurrencyByScale(Math.abs(value), scale);
return value >= 0 ? `+${formatted}` : `-${formatted}`;
}
const formatted = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 2 }).format(Math.abs(value));
return value >= 0 ? `+${formatted}` : `-${formatted}`;
}
function tickerPillClass(disabled?: boolean) {
return cn(
'inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs',
disabled
? 'border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] text-[color:var(--terminal-muted)]'
: 'border-[color:var(--line-strong)] bg-[color:var(--panel-bright)] text-[color:var(--terminal-bright)]'
);
}
function ControlSection<T extends string>(props: {
label: string;
value: T;
options: Array<{ value: T; label: string }>;
onChange: (value: T) => void;
ariaLabel: string;
}) {
return (
<div className="space-y-2">
<p className="panel-heading text-[11px] uppercase tracking-[0.18em] text-[color:var(--terminal-muted)]">{props.label}</p>
<div className="flex flex-wrap gap-2">
{props.options.map((option) => (
<Button
key={option.value}
type="button"
aria-label={`${props.ariaLabel} ${option.label}`}
variant={option.value === props.value ? 'primary' : 'ghost'}
className="px-3 py-1.5 text-xs"
onClick={() => props.onChange(option.value)}
>
{option.label}
</Button>
))}
</div>
</div>
);
}
function ComparisonTooltip(props: {
active?: boolean;
payload?: TooltipEntry[];
metric: GraphMetricDefinition;
scale: NumberScaleUnit;
}) {
if (!props.active || !props.payload || props.payload.length === 0) {
return null;
}
const datum = props.payload[0]?.payload;
if (!datum) {
return null;
}
const entries = props.payload
.filter((entry) => typeof entry.dataKey === 'string')
.map((entry) => {
const ticker = entry.dataKey as string;
const meta = datum[`meta__${ticker}`] as GraphingSeriesPoint | undefined;
return {
ticker,
color: entry.color ?? CHART_MUTED,
value: typeof entry.value === 'number' ? entry.value : null,
meta
};
});
return (
<div
className="min-w-[220px] rounded-xl border px-3 py-3 text-sm shadow-[0_16px_40px_rgba(0,0,0,0.34)]"
style={{
backgroundColor: CHART_TOOLTIP_BG,
borderColor: CHART_TOOLTIP_BORDER
}}
>
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
{formatLongDate(entries[0]?.meta?.dateKey ?? null)}
</p>
<div className="mt-3 space-y-2">
{entries.map((entry) => (
<div key={entry.ticker} className="rounded-lg border border-[color:var(--line-weak)] bg-[color:rgba(4,16,24,0.72)] px-2 py-2">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="size-2 rounded-full" style={{ backgroundColor: entry.color }} aria-hidden="true" />
<span className="font-medium text-[color:var(--terminal-bright)]">{entry.ticker}</span>
</div>
<span className="text-[color:var(--terminal-bright)]">
{formatMetricValue(entry.value, props.metric.unit, props.scale)}
</span>
</div>
{entry.meta ? (
<p className="mt-1 text-xs text-[color:var(--terminal-muted)]">
{entry.meta.filingType} · {entry.meta.periodLabel}
</p>
) : null}
</div>
))}
</div>
</div>
);
}
export default function GraphingPage() {
return (
<Suspense fallback={<div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading graphing desk...</div>}>
<GraphingPageContent />
</Suspense>
);
}
function GraphingPageContent() {
const { isPending, isAuthenticated } = useAuthGuard();
const searchParams = useSearchParams();
const router = useRouter();
const queryClient = useQueryClient();
const { prefetchResearchTicker } = useLinkPrefetch();
const graphState = useMemo(() => parseGraphingParams(searchParams), [searchParams]);
const canonicalQuery = useMemo(() => serializeGraphingParams(graphState), [graphState]);
const currentQuery = searchParams.toString();
const [tickerInput, setTickerInput] = useState(graphState.tickers.join(', '));
const [results, setResults] = useState<GraphingFetchResult[]>([]);
const [loading, setLoading] = useState(true);
const [refreshNonce, setRefreshNonce] = useState(0);
useEffect(() => {
setTickerInput(graphState.tickers.join(', '));
}, [graphState.tickers]);
useEffect(() => {
if (currentQuery !== canonicalQuery) {
router.replace(`/graphing?${canonicalQuery}`, { scroll: false });
}
}, [canonicalQuery, currentQuery, router]);
const replaceGraphState = useCallback((patch: Partial<GraphingUrlState>) => {
const nextSurface = patch.surface ?? graphState.surface;
const nextCadence = patch.cadence ?? graphState.cadence;
const nextTickers = patch.tickers && patch.tickers.length > 0 ? patch.tickers : graphState.tickers;
const nextMetric = resolveGraphMetric(
nextSurface,
nextCadence,
patch.metric ?? graphState.metric
);
const nextState: GraphingUrlState = {
tickers: nextTickers,
surface: nextSurface,
cadence: nextCadence,
metric: nextMetric,
chart: patch.chart ?? graphState.chart,
scale: patch.scale ?? graphState.scale
};
router.replace(`/graphing?${serializeGraphingParams(nextState)}`, { scroll: false });
}, [graphState, router]);
useEffect(() => {
if (isPending || !isAuthenticated) {
return;
}
let cancelled = false;
async function loadComparisonSet() {
setLoading(true);
const settled = await Promise.allSettled(graphState.tickers.map(async (ticker) => {
const response = await queryClient.ensureQueryData(companyFinancialStatementsQueryOptions({
ticker,
surfaceKind: graphState.surface,
cadence: graphState.cadence,
includeDimensions: false,
includeFacts: false,
limit: 16
}));
return response.financials;
}));
if (cancelled) {
return;
}
setResults(settled.map((entry, index) => {
const ticker = graphState.tickers[index] ?? `ticker-${index + 1}`;
if (entry.status === 'fulfilled') {
return {
ticker,
financials: entry.value
} satisfies GraphingFetchResult;
}
const reason = entry.reason instanceof ApiError
? entry.reason.message
: entry.reason instanceof Error
? entry.reason.message
: 'Unable to load financial history';
return {
ticker,
error: reason
} satisfies GraphingFetchResult;
}));
setLoading(false);
}
void loadComparisonSet();
return () => {
cancelled = true;
};
}, [graphState, isAuthenticated, isPending, queryClient, refreshNonce]);
const metricOptions = useMemo(() => metricsForSurfaceAndCadence(graphState.surface, graphState.cadence), [graphState.cadence, graphState.surface]);
const selectedMetric = useMemo(
() => getGraphMetricDefinition(graphState.surface, graphState.cadence, graphState.metric),
[graphState.cadence, graphState.metric, graphState.surface]
);
const comparison = useMemo(() => buildGraphingComparisonData({
results,
surface: graphState.surface,
metric: graphState.metric
}), [graphState.metric, graphState.surface, results]);
const hasCurrencyScale = selectedMetric?.unit === 'currency';
if (isPending || !isAuthenticated) {
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading graphing desk...</div>;
}
return (
<AppShell
title="Graphing"
subtitle="Compare one normalized filing metric across multiple companies with shareable chart state."
activeTicker={graphState.tickers[0] ?? null}
actions={(
<div className="flex flex-wrap items-center gap-2">
<Button
variant="secondary"
onClick={() => setRefreshNonce((current) => current + 1)}
>
<RefreshCcw className="size-4" />
Refresh
</Button>
<Button
variant="secondary"
onClick={() => replaceGraphState(parseGraphingParams(new URLSearchParams()))}
>
<BarChart3 className="size-4" />
Reset View
</Button>
</div>
)}
>
<Panel title="Compare Set" subtitle="Enter up to five tickers. Duplicates are removed and the first ticker anchors research context.">
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
const nextTickers = normalizeGraphTickers(tickerInput);
replaceGraphState({ tickers: nextTickers.length > 0 ? nextTickers : [...graphState.tickers] });
}}
>
<div className="flex flex-wrap items-center gap-3">
<Input
aria-label="Compare tickers"
value={tickerInput}
onChange={(event) => setTickerInput(event.target.value.toUpperCase())}
placeholder="MSFT, AAPL, NVDA"
className="min-w-[260px] flex-1"
/>
<Button type="submit">
<Search className="size-4" />
Update Compare Set
</Button>
<Link
href={buildGraphingHref(graphState.tickers[0] ?? null)}
onMouseEnter={() => {
if (graphState.tickers[0]) {
prefetchResearchTicker(graphState.tickers[0]);
}
}}
onFocus={() => {
if (graphState.tickers[0]) {
prefetchResearchTicker(graphState.tickers[0]);
}
}}
className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
>
Open canonical graphing URL
</Link>
</div>
<div className="flex flex-wrap gap-2">
{graphState.tickers.map((ticker, index) => (
<span key={ticker} className={tickerPillClass(index === 0)}>
{ticker}
<button
type="button"
aria-label={`Remove ${ticker}`}
disabled={graphState.tickers.length === 1}
className="rounded-full p-0.5 text-[color:var(--terminal-muted)] transition hover:text-[color:var(--terminal-bright)] disabled:cursor-not-allowed disabled:opacity-40"
onClick={() => {
const nextTickers = graphState.tickers.filter((entry) => entry !== ticker);
if (nextTickers.length > 0) {
replaceGraphState({ tickers: nextTickers });
}
}}
>
<X className="size-3" />
</button>
</span>
))}
</div>
</form>
</Panel>
<Panel title="Chart Controls" subtitle="Surface, metric, cadence, chart style, and scale stay in the URL for deep-linking.">
<div className="grid gap-5 lg:grid-cols-[1.2fr_1fr]">
<div className="space-y-5">
<ControlSection
label="Surface"
ariaLabel="Graph surface"
value={graphState.surface}
options={Object.entries(GRAPH_SURFACE_LABELS).map(([value, label]) => ({ value: value as GraphingUrlState['surface'], label }))}
onChange={(value) => replaceGraphState({ surface: value })}
/>
<ControlSection
label="Cadence"
ariaLabel="Graph cadence"
value={graphState.cadence}
options={GRAPH_CADENCE_OPTIONS}
onChange={(value) => replaceGraphState({ cadence: value as FinancialCadence })}
/>
<ControlSection
label="Chart Type"
ariaLabel="Chart type"
value={graphState.chart}
options={GRAPH_CHART_OPTIONS}
onChange={(value) => replaceGraphState({ chart: value as GraphChartKind })}
/>
{hasCurrencyScale ? (
<ControlSection
label="Scale"
ariaLabel="Value scale"
value={graphState.scale}
options={GRAPH_SCALE_OPTIONS}
onChange={(value) => replaceGraphState({ scale: value as NumberScaleUnit })}
/>
) : null}
</div>
<div className="space-y-2">
<p className="panel-heading text-[11px] uppercase tracking-[0.18em] text-[color:var(--terminal-muted)]">Metric</p>
<select
aria-label="Metric selector"
value={graphState.metric}
onChange={(event) => replaceGraphState({ metric: event.target.value })}
className="w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2.5 text-sm text-[color:var(--terminal-bright)] outline-none transition focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_rgba(0,255,180,0.14)]"
>
{metricOptions.map((option) => (
<option key={option.key} value={option.key} className="bg-[#07161f]">
{option.label}
</option>
))}
</select>
{selectedMetric ? (
<div className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-3 text-sm text-[color:var(--terminal-muted)]">
<p className="text-[color:var(--terminal-bright)]">{selectedMetric.label}</p>
<p className="mt-1">
{GRAPH_SURFACE_LABELS[selectedMetric.surface]} · {selectedMetric.category.replace(/_/g, ' ')} · unit {selectedMetric.unit}
</p>
</div>
) : null}
</div>
</div>
</Panel>
<Panel title="Comparison Chart" subtitle="One metric, multiple companies, aligned by actual reported dates.">
{loading ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Loading comparison chart...</p>
) : !selectedMetric || !comparison.hasAnyData ? (
<div className="rounded-xl border border-dashed border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-4 py-6">
<p className="text-sm text-[color:var(--terminal-bright)]">No chart data available for the selected compare set.</p>
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">Try a different metric, cadence, or company basket.</p>
</div>
) : (
<>
{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.
</div>
) : null}
<div className="mb-4 flex flex-wrap gap-2">
{comparison.companies.map((company, index) => (
<span key={company.ticker} className={tickerPillClass(company.status !== 'ready')}>
<span
className="size-2 rounded-full"
style={{ backgroundColor: CHART_COLORS[index % CHART_COLORS.length] }}
aria-hidden="true"
/>
{company.ticker}
<span className="text-[color:var(--terminal-muted)]">{company.companyName}</span>
</span>
))}
</div>
<div className="h-[360px]">
<ResponsiveContainer width="100%" height="100%">
{graphState.chart === 'line' ? (
<LineChart data={comparison.chartData}>
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
<XAxis
dataKey="dateKey"
stroke={CHART_MUTED}
fontSize={12}
minTickGap={20}
tickFormatter={(value: string) => formatShortDate(value)}
/>
<YAxis
stroke={CHART_MUTED}
fontSize={12}
tickFormatter={(value: number) => formatMetricValue(value, selectedMetric.unit, graphState.scale)}
/>
<Tooltip content={<ComparisonTooltip metric={selectedMetric} scale={graphState.scale} />} />
{comparison.companies.map((company, index) => (
<Line
key={company.ticker}
type="monotone"
dataKey={company.ticker}
connectNulls={false}
stroke={CHART_COLORS[index % CHART_COLORS.length]}
strokeWidth={2.5}
dot={false}
name={company.ticker}
/>
))}
</LineChart>
) : (
<BarChart data={comparison.chartData}>
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
<XAxis
dataKey="dateKey"
stroke={CHART_MUTED}
fontSize={12}
minTickGap={20}
tickFormatter={(value: string) => formatShortDate(value)}
/>
<YAxis
stroke={CHART_MUTED}
fontSize={12}
tickFormatter={(value: number) => formatMetricValue(value, selectedMetric.unit, graphState.scale)}
/>
<Tooltip content={<ComparisonTooltip metric={selectedMetric} scale={graphState.scale} />} />
{comparison.companies.map((company, index) => (
<Bar
key={company.ticker}
dataKey={company.ticker}
fill={CHART_COLORS[index % CHART_COLORS.length]}
radius={[6, 6, 0, 0]}
name={company.ticker}
/>
))}
</BarChart>
)}
</ResponsiveContainer>
</div>
</>
)}
</Panel>
<Panel title="Latest Values" subtitle="Most recent reported point per company, plus the prior point and one-step change.">
<div className="overflow-x-auto">
<table className="data-table min-w-[920px]">
<thead>
<tr>
<th>Company</th>
<th>Latest</th>
<th>Prior</th>
<th>Change</th>
<th>Period</th>
<th>Filing</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{comparison.latestRows.map((row, index) => (
<tr key={row.ticker}>
<td>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span
className="size-2 rounded-full"
style={{ backgroundColor: CHART_COLORS[index % CHART_COLORS.length] }}
aria-hidden="true"
/>
<span>{row.ticker}</span>
</div>
<span className="text-xs text-[color:var(--terminal-muted)]">{row.companyName}</span>
</div>
</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)]')}>
{selectedMetric ? formatChangeValue(row.changeValue, selectedMetric.unit, graphState.scale) : 'n/a'}
</td>
<td>{formatLongDate(row.latestDateKey)} {row.latestPeriodLabel ? <span className="text-xs text-[color:var(--terminal-muted)]">· {row.latestPeriodLabel}</span> : null}</td>
<td>{row.latestFilingType ?? 'n/a'}</td>
<td>
{row.status === 'ready' ? (
<span className="text-[color:var(--accent)]">Ready</span>
) : row.status === 'no_metric_data' ? (
<span className="text-[color:var(--terminal-muted)]">No metric data</span>
) : (
<span className="text-[#ffb5b5]">{row.errorMessage ?? 'Load failed'}</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Panel>
</AppShell>
);
}