From ad4109791cc3fdf8c0bf15969610741281caaa8f Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Sun, 14 Jun 2026 02:41:34 -0700 Subject: feat: add FilingsPage component --- frontend/components/prism/FilingsPage.tsx | 733 ++++++++++++++++++++++++++++++ 1 file changed, 733 insertions(+) create mode 100644 frontend/components/prism/FilingsPage.tsx (limited to 'frontend/components/prism/FilingsPage.tsx') diff --git a/frontend/components/prism/FilingsPage.tsx b/frontend/components/prism/FilingsPage.tsx new file mode 100644 index 0000000..6b3a9f8 --- /dev/null +++ b/frontend/components/prism/FilingsPage.tsx @@ -0,0 +1,733 @@ +"use client"; +import { useEffect, useState } from "react"; +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; +import { api } from "@/lib/api"; +import { buildKpis } from "@/lib/overview"; +import { KPIStrip } from "@/components/prism/KPIStrip"; +import { TickerHeader } from "@/components/prism/TickerHeader"; +import type { + FilingsResponse, + FilingItem, + CadencePoint, + FormMixRow, + TickerOverview, +} from "@/types/api"; + +type FilingsState = "loading" | "ready" | "error" | "empty"; +type FormFilter = "all" | "10-K" | "10-Q" | "8-K"; +type DateRange = "6m" | "1y" | "all"; + +const PAGE_SIZE = 25; + +type Props = { + ticker: string; + overview: TickerOverview; + isSaved: boolean; + onToggleWatchlist: () => void; +}; + +// ── Skeleton ────────────────────────────────────────────────────────────────── + +function SkeletonBlock({ height, className = "" }: { height: number; className?: string }) { + return ( +
+ ); +} + +function FilingsSkeleton() { + return ( + <> +
+ {[0, 1, 2, 3, 4].map((i) => ( + + ))} +
+
+ +
+
+ +
+
+ +
+ {[0, 1, 2, 3, 4].map((i) => ( + + ))} +
+
+ + ); +} + +// ── KPI strip ───────────────────────────────────────────────────────────────── + +function FilingKPIs({ kpis }: { kpis: FilingsResponse["kpis"] }) { + const items = [ + { label: "TOTAL FILINGS", value: String(kpis.total) }, + { label: "10-K ANNUAL", value: String(kpis.count_10k) }, + { label: "10-Q QUARTERLY", value: String(kpis.count_10q) }, + { label: "8-K EVENTS", value: String(kpis.count_8k) }, + { label: "DISTINCT FORMS", value: String(kpis.distinct_forms) }, + ]; + + return ( +
+ {items.map((item) => ( +
+
+ {item.label} +
+
+ {item.value} +
+
+ ))} +
+ ); +} + +// ── Cadence chart (Recharts stacked bars) ───────────────────────────────────── + +const FORM_COLORS: Record = { + "10-K": "var(--info, #4A78B5)", + "10-Q": "#5B9BD5", + "8-K": "var(--caution, #C49545)", + other: "var(--fg-3, #8A9AAF)", +}; + +function CadenceChart({ data }: { data: CadencePoint[] }) { + if (!data.length) { + return ( +
+ No filing history available +
+ ); + } + + const chartData = data.map((d) => ({ + month: d.month.slice(5), // "MM" + "10-K": d.count_10k, + "10-Q": d.count_10q, + "8-K": d.count_8k, + Other: d.count_other, + })); + + return ( + + + + + + + + + + + + + ); +} + +// ── Form mix table ───────────────────────────────────────────────────────────── + +function FormMixTable({ rows }: { rows: FormMixRow[] }) { + if (!rows.length) return null; + + return ( +
+
+ + Form + + + Count + + + Mix + +
+ {rows.map((row) => ( +
+ + {row.form} + + + {row.count} + + + {row.pct != null ? `${row.pct.toFixed(1)}%` : "—"} + +
+ ))} +
+ ); +} + +// ── Filing badge ─────────────────────────────────────────────────────────────── + +function FormBadge({ form }: { form: string }) { + let bg = "var(--ink-2, #181d26)"; + let color = "var(--fg-3)"; + let border = "1px solid var(--line-1, #232934)"; + + if (form === "10-K" || form === "10-Q") { + bg = "rgba(74, 120, 181, 0.15)"; + color = "var(--info, #4A78B5)"; + border = "1px solid rgba(74,120,181,0.3)"; + } else if (form === "8-K") { + bg = "rgba(196, 149, 69, 0.15)"; + color = "var(--caution, #C49545)"; + border = "1px solid rgba(196,149,69,0.3)"; + } + + return ( + + {form} + + ); +} + +// ── Filing table row ─────────────────────────────────────────────────────────── + +function FilingRow({ filing }: { filing: FilingItem }) { + return ( +
+ + {filing.date} + + + + {filing.title} + + {filing.url ? ( + + ↗ + + ) : ( + + )} +
+ ); +} + +// ── Filing table with filters + pagination ───────────────────────────────────── + +function FilingTable({ filings }: { filings: FilingItem[] }) { + const [formFilter, setFormFilter] = useState("all"); + const [dateRange, setDateRange] = useState("all"); + const [page, setPage] = useState(0); + + const FORM_FILTERS: { key: FormFilter; label: string }[] = [ + { key: "all", label: "ALL" }, + { key: "10-K", label: "10-K" }, + { key: "10-Q", label: "10-Q" }, + { key: "8-K", label: "8-K" }, + ]; + + const DATE_FILTERS: { key: DateRange; label: string }[] = [ + { key: "6m", label: "6M" }, + { key: "1y", label: "1Y" }, + { key: "all", label: "ALL" }, + ]; + + const cutoff: Record = { + "6m": new Date(Date.now() - 180 * 86400 * 1000).toISOString().slice(0, 10), + "1y": new Date(Date.now() - 365 * 86400 * 1000).toISOString().slice(0, 10), + "all": null, + }; + + const filtered = filings.filter((f) => { + if (formFilter !== "all" && f.form !== formFilter) return false; + const cut = cutoff[dateRange]; + if (cut && f.date < cut) return false; + return true; + }); + + const totalPages = Math.ceil(filtered.length / PAGE_SIZE); + const pageItems = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); + + // Reset to page 0 when filters change + const setFormFilterAndReset = (f: FormFilter) => { setFormFilter(f); setPage(0); }; + const setDateRangeAndReset = (r: DateRange) => { setDateRange(r); setPage(0); }; + + return ( +
+ {/* Controls row */} +
+
+ {FORM_FILTERS.map(({ key, label }) => ( + + ))} +
+
+ {DATE_FILTERS.map(({ key, label }) => ( + + ))} +
+
+ + {/* Table header */} +
+ {["DATE", "FORM", "TITLE", ""].map((h) => ( + + {h} + + ))} +
+ + {/* Rows */} + {pageItems.length === 0 ? ( +
+ No filings match selected filters +
+ ) : ( + pageItems.map((f, i) => ) + )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, filtered.length)} of {filtered.length} + +
+ + +
+
+ )} +
+ ); +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +export function FilingsPage({ ticker, overview, isSaved, onToggleWatchlist }: Props) { + const [data, setData] = useState(null); + const [state, setState] = useState("loading"); + const kpis = buildKpis(overview); + + useEffect(() => { + let cancelled = false; + setState("loading"); + setData(null); + + api + .filings(ticker) + .then((res) => { + if (cancelled) return; + if (!res.filings.length) { + setState("empty"); + } else { + setData(res); + setState("ready"); + } + }) + .catch(() => { + if (!cancelled) setState("error"); + }); + + return () => { + cancelled = true; + }; + }, [ticker]); + + return ( + <> + + + +
+ {state === "loading" && } + + {state === "error" && ( +
+
+ Failed to load filings data +
+
+ )} + + {state === "empty" && ( +
+
+ No SEC filings found for this ticker +
+
+ )} + + {state === "ready" && data && ( + <> + + + {/* Two-column: cadence chart + form mix */} +
+
+
+ Filing Cadence — Monthly +
+ +
+
+
+ Form Mix +
+ +
+
+ + {/* Readout bar */} +
+ {data.readout} +
+ + {/* Filing table */} +
+
+ All Filings +
+ +
+ + )} +
+ + + + ); +} -- cgit v1.3-2-g0d8e