1"use client";2import { useEffect, useMemo, useState, type FormEvent } from "react";3import { Play } from "lucide-react";4import clsx from "clsx";5import { api, type Fact, type FundamentalsOut, type PeriodsOut } from "@/lib/api";6import { useQueryParam } from "@/lib/hooks";7import { fmtCompact, fmtInt, fmtNum, fmtTs, nowLocalInput } from "@/lib/format";8import { Banner, Card, Empty, ErrorState, Field, PageHeader, StatusBadge, Tabs } from "@/components/ui";9import { DataTable } from "@/components/DataTable";1011const DEFAULT_PIVOT = 6;1213export default function FundamentalsPage() {14 const qTicker = useQueryParam("ticker");15 const [ticker, setTicker] = useState("AAPL");16 const [asOf, setAsOf] = useState(nowLocalInput());17 const [periodType, setPeriodType] = useState<"" | "q" | "a">("q");18 const [metrics, setMetrics] = useState("");19 const [out, setOut] = useState<FundamentalsOut | null>(null);20 const [periods, setPeriods] = useState<PeriodsOut | null>(null);21 const [busy, setBusy] = useState(false);22 const [error, setError] = useState<Error | null>(null);23 const [tab, setTab] = useState<"pivot" | "facts" | "periods" | "earnings">("pivot");24 const [pivotMetrics, setPivotMetrics] = useState<string[]>([]);2526 useEffect(() => {27 if (qTicker) setTicker(qTicker.toUpperCase());28 }, [qTicker]);2930 async function run(e?: FormEvent) {31 e?.preventDefault();32 setBusy(true);33 setError(null);34 try {35 const asOfIso = asOf ? new Date(asOf).toISOString() : undefined;36 const t = ticker.trim();37 const [f, p] = await Promise.all([38 api.get<FundamentalsOut>(`/fundamentals/${encodeURIComponent(t)}`, { as_of: asOfIso, metrics: metrics.trim() || undefined, period_type: periodType || undefined, limit: 5000 }),39 api.get<PeriodsOut>(`/fundamentals/${encodeURIComponent(t)}/periods`).catch(() => null),40 ]);41 setOut(f);42 setPeriods(p);43 const all = Array.from(new Set(f.data.map((x) => x.metric)));44 setPivotMetrics((cur) => (cur.length ? cur.filter((m) => all.includes(m)) : all.slice(0, DEFAULT_PIVOT)));45 } catch (err) {46 setError(err instanceof Error ? err : new Error(String(err)));47 setOut(null);48 setPeriods(null);49 } finally {50 setBusy(false);51 }52 }5354 const allMetrics = useMemo(() => Array.from(new Set((out?.data ?? []).map((x) => x.metric))).sort(), [out]);55 const pivot = useMemo(() => {56 const rows = new Map<string, { period: string; type: string; fy: number | null; fp: string | null; filing: string | null; values: Record<string, Fact> }>();57 for (const f of out?.data ?? []) {58 const key = `${f.fiscal_period_end}|${f.fiscal_period_type}`;59 const r = rows.get(key) ?? { period: f.fiscal_period_end, type: f.fiscal_period_type, fy: f.fiscal_year, fp: f.fiscal_period, filing: f.filing_date, values: {} };60 r.values[f.metric] = f;61 rows.set(key, r);62 }63 return Array.from(rows.values()).sort((a, b) => b.period.localeCompare(a.period));64 }, [out]);6566 return (67 <>68 <PageHeader title="Fundamentals" subtitle="Point-in-time facts: only what was knowable at the chosen instant is returned." />69 <Card className="mb-4">70 <form onSubmit={run} className="grid grid-cols-2 gap-3 md:grid-cols-5 md:items-end">71 <Field label="Ticker or entity id">72 <input className="input font-mono uppercase" value={ticker} onChange={(e) => setTicker(e.target.value)} required placeholder="AAPL or frd:AAPL" />73 </Field>74 <Field label="As of (local time → UTC)">75 <input type="datetime-local" className="input" value={asOf} onChange={(e) => setAsOf(e.target.value)} />76 </Field>77 <Field label="Period type">78 <select className="input" value={periodType} onChange={(e) => setPeriodType(e.target.value as typeof periodType)}>79 <option value="q">quarterly</option>80 <option value="a">annual</option>81 <option value="">both</option>82 </select>83 </Field>84 <Field label="Metrics (comma-separated)">85 <input className="input font-mono text-xs" value={metrics} onChange={(e) => setMetrics(e.target.value)} placeholder="rev, net_inc, eps" />86 </Field>87 <button className="btn btn-primary" disabled={busy || !ticker.trim()}>88 <Play className="h-3.5 w-3.5" /> {busy ? "Loading…" : "Load"}89 </button>90 </form>91 </Card>9293 {error && <ErrorState error={error} retry={() => void run()} />}9495 {out && (96 <>97 <div className="mb-3 flex flex-wrap items-center gap-2 text-xs">98 <span className="pill bg-surface-2 text-ink-2 tnum">{fmtInt(out.meta.rows)} facts</span>99 <span className="pill bg-surface-2 text-ink-2 tnum">as of {fmtTs(out.meta.as_of)}</span>100 {out.meta.facts_hidden_by_as_of > 0 && <span className="pill bg-warn-soft text-warn-ink tnum">{fmtInt(out.meta.facts_hidden_by_as_of)} facts hidden by as_of</span>}101 {out.entity && <span className="pill bg-surface-2 text-ink-2">{String(out.entity.name ?? out.entity.entity_id ?? "")}</span>}102 <span className="ml-auto">103 <Tabs value={tab} onChange={setTab} items={[{ value: "pivot", label: "Pivot" }, { value: "facts", label: "Raw facts" }, { value: "periods", label: `Periods (${periods?.periods.length ?? 0})` }, { value: "earnings", label: `Earnings (${periods?.earnings.length ?? 0})` }]} />104 </span>105 </div>106 <Banner tone="accent" className="mb-4">107 <span className="text-xs">PIT policy: {out.meta.pit_policy}. Revision history exists only from the first ingestion by this platform.</span>108 </Banner>109110 {tab === "pivot" && (111 <Card title="Periods × metrics" actions={<span className="text-xs text-ink-3">{pivotMetrics.length} of {allMetrics.length} metrics</span>}>112 <div className="mb-3 flex flex-wrap gap-1">113 {allMetrics.map((m) => {114 const on = pivotMetrics.includes(m);115 return (116 <button key={m} className={clsx("pill border font-mono", on ? "border-accent bg-accent-soft text-accent-ink" : "border-border bg-surface-1 text-ink-2 hover:bg-surface-3")} onClick={() => setPivotMetrics((cur) => (on ? cur.filter((x) => x !== m) : [...cur, m]))} aria-pressed={on}>117 {m}118 </button>119 );120 })}121 </div>122 {pivot.length === 0 ? (123 <Empty>No facts visible at this as-of instant.</Empty>124 ) : (125 <div className="table-wrap" style={{ maxHeight: "60dvh", overflowY: "auto" }}>126 <table className="table">127 <thead>128 <tr>129 <th>Period end</th>130 <th>FY / FP</th>131 <th>Filed</th>132 {pivotMetrics.map((m) => (133 <th key={m} className="text-right font-mono">134 {m}135 </th>136 ))}137 </tr>138 </thead>139 <tbody>140 {pivot.map((r) => (141 <tr key={`${r.period}-${r.type}`}>142 <td className="tnum whitespace-nowrap">143 {r.period} <span className="text-ink-3">{r.type}</span>144 </td>145 <td className="tnum text-xs text-ink-2 whitespace-nowrap">146 {r.fy ?? "—"} / {r.fp ?? "—"}147 </td>148 <td className="tnum text-xs text-ink-2 whitespace-nowrap">{r.filing ?? "—"}</td>149 {pivotMetrics.map((m) => {150 const f = r.values[m];151 return (152 <td key={m} className="num whitespace-nowrap" title={f ? `${f.value} ${f.unit} · rev ${f.revision_id}${f.restatement_flag ? " · restated" : ""}` : undefined}>153 {f ? (154 <>155 {fmtValue(f)}156 {f.revision_id > 1 && <sup className="ml-0.5 text-[9px] text-warn-ink">r{f.revision_id}</sup>}157 </>158 ) : (159 <span className="text-ink-3">—</span>160 )}161 </td>162 );163 })}164 </tr>165 ))}166 </tbody>167 </table>168 </div>169 )}170 <p className="mt-2 text-[11px] text-ink-3">Values compacted (K/M/B); hover for exact value and unit. <sup>rN</sup> marks a revision observed before as-of.</p>171 </Card>172 )}173174 {tab === "facts" && (175 <Card title="Raw facts">176 <DataTable177 rows={out.data}178 rowKey={(f, i) => `${f.metric}-${f.fiscal_period_end}-${f.fiscal_period_type}-${f.revision_id}-${i}`}179 mobile="scroll"180 maxHeight="60dvh"181 columns={[182 { key: "metric", header: "Metric", mono: true },183 { key: "value", header: "Value", num: true, render: (f) => <span title={String(f.value)}>{fmtValue(f)}</span> },184 { key: "unit", header: "Unit", hideSm: true },185 { key: "fiscal_period_end", header: "Period end", render: (f) => <span className="tnum">{f.fiscal_period_end} <span className="text-ink-3">{f.fiscal_period_type}</span></span> },186 { key: "fy", header: "FY/FP", hideSm: true, render: (f) => `${f.fiscal_year ?? "—"}/${f.fiscal_period ?? "—"}` },187 { key: "filing_date", header: "Filed", render: (f) => <span className="tnum">{f.filing_date ?? "—"}</span> },188 { key: "available_at", header: "Available at", hideSm: true, render: (f) => <span className="tnum text-xs">{fmtTs(f.available_at)}</span> },189 { key: "observed_at", header: "Observed at", hideSm: true, render: (f) => <span className="tnum text-xs">{fmtTs(f.observed_at)}</span> },190 { key: "revision_id", header: "Rev", num: true },191 { key: "restatement_flag", header: "Restated", render: (f) => (f.restatement_flag ? <StatusBadge status="warning" label="yes" /> : <span className="text-ink-3">no</span>) },192 { key: "source_document", header: "Source", hideSm: true, render: (f) => <span className="text-xs text-ink-2">{f.source_document ?? f.source_id}</span> },193 ]}194 />195 </Card>196 )}197198 {tab === "periods" && (199 <Card title="Fiscal periods (all revisions, not PIT-filtered)">200 <DataTable201 rows={periods?.periods}202 rowKey={(p) => `${p.fiscal_period_end}-${p.fiscal_period_type}-${p.filing_date}`}203 mobile="scroll"204 maxHeight="60dvh"205 columns={[206 { key: "fiscal_period_end", header: "Period end", render: (p) => <span className="tnum">{p.fiscal_period_end} <span className="text-ink-3">{p.fiscal_period_type}</span></span> },207 { key: "fy", header: "FY/FP", render: (p) => `${p.fiscal_year ?? "—"}/${p.fiscal_period ?? "—"}` },208 { key: "filing_date", header: "Filed", render: (p) => <span className="tnum">{p.filing_date ?? "—"}</span> },209 { key: "available_at", header: "Available at", render: (p) => <span className="tnum text-xs">{fmtTs(p.available_at)}</span> },210 { key: "metrics", header: "Metrics", num: true, render: (p) => fmtInt(p.metrics) },211 { key: "max_revision", header: "Max rev", num: true },212 { key: "restated", header: "Restated", render: (p) => (p.restated ? <StatusBadge status="warning" label="yes" /> : <span className="text-ink-3">no</span>) },213 ]}214 empty="No periods."215 />216 </Card>217 )}218219 {tab === "earnings" && (220 <Card title="Earnings events">221 <DataTable222 rows={periods?.earnings}223 rowKey={(e) => e.event_date}224 mobile="scroll"225 maxHeight="60dvh"226 columns={[227 { key: "event_date", header: "Date", render: (e) => <span className="tnum">{e.event_date}</span> },228 { key: "release_time", header: "Release", render: (e) => e.release_time ?? "—" },229 { key: "eps_estimate", header: "EPS est.", num: true, render: (e) => fmtNum(e.eps_estimate, 2) },230 { key: "eps_reported", header: "EPS rep.", num: true, render: (e) => fmtNum(e.eps_reported, 2) },231 { key: "surprise", header: "Surprise", num: true, render: (e) => (e.eps_estimate !== null && e.eps_reported !== null ? fmtNum(e.eps_reported - e.eps_estimate, 2) : "—") },232 ]}233 empty="No earnings events."234 />235 </Card>236 )}237 </>238 )}239 </>240 );241}242243function fmtValue(f: Fact): string {244 if (f.value === null || f.value === undefined) return "—";245 const u = (f.unit ?? "").toLowerCase();246 if (u.includes("ratio") || u.includes("per_share") || u === "pct" || u === "percent" || Math.abs(f.value) < 1000) return fmtNum(f.value, 2);247 return fmtCompact(f.value);248}249