665 lines
25 KiB
TypeScript
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 = ['#d9dee5', '#c7cdd5', '#b5bcc5', '#a4acb6', '#939ca7'] as const;
|
|
const CHART_MUTED = '#a1a9b3';
|
|
const CHART_GRID = 'rgba(196, 202, 211, 0.18)';
|
|
const CHART_TOOLTIP_BG = 'rgba(31, 34, 39, 0.96)';
|
|
const CHART_TOOLTIP_BORDER = 'rgba(220, 226, 234, 0.24)';
|
|
|
|
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(45,49,55,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." variant="surface">
|
|
<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_var(--focus-ring)]"
|
|
>
|
|
{metricOptions.map((option) => (
|
|
<option key={option.key} value={option.key} className="bg-[#1f2227]">
|
|
{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." variant="surface">
|
|
{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." variant="surface">
|
|
<div className="data-table-wrap">
|
|
<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>
|
|
);
|
|
}
|