summaryrefslogtreecommitdiff
path: root/frontend
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-06-09 19:19:44 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-06-09 19:19:44 -0700
commitd5e321089b6b82f5425c2b3fae142db5223ce599 (patch)
treeafdb1e7753938d00bb0a7adb2275525c76482dd9 /frontend
parent9e2a23855d29d9763044b8a0a9e5f326cfcad878 (diff)
Add Insiders tab with diverging bar chart and transaction table
- Backend: get_insider_transactions() in data_service (yfinance, 1hr TTL) - Backend: InsidersResponse schema + GET /api/tickers/{symbol}/insiders - Frontend: InsidersPage with 4-KPI strip, SVG diverging bar chart (), psm-insider transaction rows, pill filter toggles, shimmer skeleton, empty/error states - Enable Insiders nav item (remove disabled flag)
Diffstat (limited to 'frontend')
-rw-r--r--frontend/app/page.tsx8
-rw-r--r--frontend/components/prism/InsidersPage.tsx507
-rw-r--r--frontend/lib/api.ts9
-rw-r--r--frontend/lib/overview.ts2
-rw-r--r--frontend/types/api.ts28
5 files changed, 551 insertions, 3 deletions
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index 175f072..e1f16ee 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -5,6 +5,7 @@ import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { AppShell } from "@/components/prism/AppShell";
import { FinancialsPage } from "@/components/prism/FinancialsPage";
+import { InsidersPage } from "@/components/prism/InsidersPage";
import { ValuationPage } from "@/components/prism/ValuationPage";
import { OptionsPage } from "@/components/prism/options/OptionsPage";
import { ChartCard } from "@/components/prism/ChartCard";
@@ -346,6 +347,13 @@ function OverviewClient() {
isSaved={isSaved}
onToggleWatchlist={addOrRemoveCurrentTicker}
/>
+ ) : tab === "insiders" ? (
+ <InsidersPage
+ ticker={selectedTicker}
+ overview={overview}
+ isSaved={isSaved}
+ onToggleWatchlist={addOrRemoveCurrentTicker}
+ />
) : (
<>
<TickerHeader overview={overview} onToggleWatchlist={addOrRemoveCurrentTicker} isSaved={isSaved} />
diff --git a/frontend/components/prism/InsidersPage.tsx b/frontend/components/prism/InsidersPage.tsx
new file mode 100644
index 0000000..c0d1363
--- /dev/null
+++ b/frontend/components/prism/InsidersPage.tsx
@@ -0,0 +1,507 @@
+"use client";
+import { useEffect, useRef, useState } from "react";
+import { api } from "@/lib/api";
+import { buildKpis } from "@/lib/overview";
+import { fmtLarge, fmtNumber } from "@/lib/format";
+import { KPIStrip } from "@/components/prism/KPIStrip";
+import { TickerHeader } from "@/components/prism/TickerHeader";
+import type { InsidersResponse, InsiderTransaction, TickerOverview } from "@/types/api";
+
+type InsidersState = "loading" | "ready" | "error" | "empty";
+type Filter = "all" | "buy" | "sell";
+
+type Props = {
+ ticker: string;
+ overview: TickerOverview;
+ isSaved: boolean;
+ onToggleWatchlist: () => void;
+};
+
+// ── Skeleton ──────────────────────────────────────────────────────────────────
+
+function SkeletonBlock({ height, className = "" }: { height: number; className?: string }) {
+ return (
+ <div
+ className={className}
+ style={{
+ height,
+ borderRadius: "var(--r-3)",
+ background: "linear-gradient(90deg, var(--ink-2, #181d26) 25%, var(--ink-3, #1e232e) 50%, var(--ink-2, #181d26) 75%)",
+ backgroundSize: "200% 100%",
+ animation: "psm-shimmer 1.4s ease infinite",
+ }}
+ />
+ );
+}
+
+function InsidersSkeleton() {
+ return (
+ <>
+ {/* KPI row */}
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "var(--sp-4)", marginBottom: "var(--sp-5)" }}>
+ {[0, 1, 2, 3].map((i) => <SkeletonBlock key={i} height={64} />)}
+ </div>
+ {/* Chart card */}
+ <div className="psm-card" style={{ marginBottom: "var(--sp-4)" }}>
+ <SkeletonBlock height={220} />
+ </div>
+ {/* Table card */}
+ <div className="psm-card">
+ <SkeletonBlock height={32} className="" />
+ <div style={{ marginTop: "var(--sp-4)", display: "flex", flexDirection: "column", gap: "var(--sp-3)" }}>
+ {[0, 1, 2, 3, 4].map((i) => <SkeletonBlock key={i} height={44} />)}
+ </div>
+ </div>
+ </>
+ );
+}
+
+// ── Summary KPI strip ─────────────────────────────────────────────────────────
+
+function InsiderKPIs({ summary }: { summary: InsidersResponse["summary"] }) {
+ const items = [
+ { label: "BUY TRANSACTIONS", value: String(summary.buy_count), tone: "pos" as const },
+ { label: "TOTAL BOUGHT", value: fmtLarge(summary.buy_value), tone: "pos" as const },
+ { label: "SELL TRANSACTIONS", value: String(summary.sell_count), tone: "neg" as const },
+ { label: "TOTAL SOLD", value: fmtLarge(summary.sell_value), tone: "neg" as const },
+ ];
+
+ return (
+ <div
+ style={{
+ display: "grid",
+ gridTemplateColumns: "repeat(4, 1fr)",
+ gap: "var(--sp-4)",
+ marginBottom: "var(--sp-5)",
+ }}
+ >
+ {items.map((item) => (
+ <div
+ key={item.label}
+ className="psm-card"
+ style={{ padding: "var(--sp-4) var(--sp-5)" }}
+ >
+ <div
+ style={{
+ fontFamily: "var(--font-sans)",
+ fontSize: "var(--fs-11)",
+ color: "var(--fg-3)",
+ letterSpacing: "0.08em",
+ textTransform: "uppercase",
+ marginBottom: "var(--sp-2)",
+ }}
+ >
+ {item.label}
+ </div>
+ <div
+ style={{
+ fontFamily: "var(--font-mono)",
+ fontSize: "var(--fs-20)",
+ fontVariantNumeric: "tabular-nums",
+ color: item.tone === "pos" ? "var(--positive)" : "var(--negative)",
+ }}
+ >
+ {item.value || "—"}
+ </div>
+ </div>
+ ))}
+ </div>
+ );
+}
+
+// ── Monthly chart (SVG diverging bars) ───────────────────────────────────────
+
+function MonthlyChart({ data }: { data: InsidersResponse["monthly_chart"] }) {
+ const containerRef = useRef<HTMLDivElement>(null);
+ const [width, setWidth] = useState(600);
+
+ useEffect(() => {
+ if (!containerRef.current) return;
+ const ro = new ResizeObserver((entries) => {
+ setWidth(entries[0].contentRect.width);
+ });
+ ro.observe(containerRef.current);
+ setWidth(containerRef.current.offsetWidth);
+ return () => ro.disconnect();
+ }, []);
+
+ if (!data.length) {
+ return (
+ <div
+ style={{
+ textAlign: "center",
+ padding: "var(--sp-8) 0",
+ fontFamily: "var(--font-sans)",
+ fontSize: "var(--fs-13)",
+ color: "var(--fg-3)",
+ }}
+ >
+ No activity in the last 6 months
+ </div>
+ );
+ }
+
+ const PAD_L = 52;
+ const PAD_R = 16;
+ const PAD_T = 16;
+ const PAD_B = 28;
+ const H = 220;
+ const chartW = Math.max(width - PAD_L - PAD_R, 10);
+ const chartH = H - PAD_T - PAD_B;
+
+ // max absolute value for symmetric axis
+ const maxVal = Math.max(...data.map((d) => Math.max(d.buy, d.sell)), 0.01);
+ const axisMax = Math.ceil(maxVal * 1.15 * 10) / 10 || 1;
+
+ const barW = Math.max(Math.floor((chartW / data.length) * 0.6), 4);
+ const barGap = chartW / data.length;
+
+ const yScale = (v: number) => (v / axisMax) * (chartH / 2);
+ const midY = PAD_T + chartH / 2;
+
+ // Y axis ticks
+ const ticks = [axisMax, axisMax / 2, 0, -axisMax / 2, -axisMax];
+
+ return (
+ <div ref={containerRef} style={{ width: "100%" }}>
+ <svg
+ width={width}
+ height={H}
+ style={{ display: "block", overflow: "visible" }}
+ >
+ {/* Y axis ticks + labels */}
+ {ticks.map((t) => {
+ const y = midY - yScale(t);
+ const label = t === 0 ? "0" : `${t > 0 ? "+" : ""}${Math.abs(t).toFixed(1)}M`;
+ return (
+ <g key={t}>
+ <line
+ x1={PAD_L - 4}
+ x2={PAD_L + chartW}
+ y1={y}
+ y2={y}
+ stroke={t === 0 ? "var(--line-1)" : "var(--line-1)"}
+ strokeWidth={t === 0 ? 1.5 : 0.5}
+ strokeDasharray={t === 0 ? undefined : "2 3"}
+ opacity={t === 0 ? 1 : 0.5}
+ />
+ <text
+ x={PAD_L - 8}
+ y={y + 4}
+ textAnchor="end"
+ fontFamily="var(--font-mono)"
+ fontSize={10}
+ fill="var(--fg-3)"
+ >
+ {label}
+ </text>
+ </g>
+ );
+ })}
+
+ {/* Bars */}
+ {data.map((d, i) => {
+ const cx = PAD_L + i * barGap + barGap / 2;
+ const buyH = yScale(d.buy);
+ const sellH = yScale(d.sell);
+
+ return (
+ <g key={d.month}>
+ {/* Buy bar (up) */}
+ {d.buy > 0 && (
+ <rect
+ x={cx - barW / 2}
+ y={midY - buyH}
+ width={barW}
+ height={buyH}
+ fill="var(--positive)"
+ opacity={0.85}
+ rx={1}
+ />
+ )}
+ {/* Sell bar (down) */}
+ {d.sell > 0 && (
+ <rect
+ x={cx - barW / 2}
+ y={midY}
+ width={barW}
+ height={sellH}
+ fill="var(--negative)"
+ opacity={0.85}
+ rx={1}
+ />
+ )}
+ {/* Month label */}
+ <text
+ x={cx}
+ y={H - 6}
+ textAnchor="middle"
+ fontFamily="var(--font-mono)"
+ fontSize={10}
+ fill="var(--fg-3)"
+ >
+ {d.month.slice(5)}
+ </text>
+ </g>
+ );
+ })}
+ </svg>
+
+ {/* Legend */}
+ <div
+ style={{
+ display: "flex",
+ gap: "var(--sp-5)",
+ justifyContent: "flex-end",
+ marginTop: "var(--sp-2)",
+ }}
+ >
+ {[
+ { color: "var(--positive)", label: "Buys" },
+ { color: "var(--negative)", label: "Sells" },
+ ].map(({ color, label }) => (
+ <div
+ key={label}
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: "var(--sp-2)",
+ fontFamily: "var(--font-sans)",
+ fontSize: "var(--fs-12)",
+ color: "var(--fg-3)",
+ }}
+ >
+ <span
+ style={{
+ display: "inline-block",
+ width: 8,
+ height: 8,
+ borderRadius: 1,
+ background: color,
+ }}
+ />
+ {label}
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+}
+
+// ── Transaction table ─────────────────────────────────────────────────────────
+
+function TransactionRow({ tx }: { tx: InsiderTransaction }) {
+ const isBuy = tx.direction === "buy";
+ const isSell = tx.direction === "sell";
+
+ return (
+ <div className="psm-insider">
+ <div className="who">
+ <span className="name">{tx.insider || "—"}</span>
+ <span className="role">{tx.position || "—"}</span>
+ </div>
+ <span
+ className={`pill ${isBuy ? "buy" : isSell ? "sell" : ""}`}
+ style={
+ !isBuy && !isSell
+ ? { background: "var(--ink-2, #181d26)", color: "var(--fg-3)", border: "1px solid var(--line-1)" }
+ : undefined
+ }
+ >
+ {tx.direction.toUpperCase()}
+ </span>
+ <div style={{ textAlign: "right" }}>
+ <div className="val">{tx.value ? fmtLarge(tx.value) : "—"}</div>
+ <div
+ style={{
+ fontFamily: "var(--font-mono)",
+ fontSize: "var(--fs-11)",
+ color: "var(--fg-3)",
+ fontVariantNumeric: "tabular-nums",
+ }}
+ >
+ {tx.shares ? `${fmtNumber(tx.shares, 0)} sh` : ""}
+ {tx.date ? ` · ${tx.date}` : ""}
+ </div>
+ </div>
+ </div>
+ );
+}
+
+function TransactionTable({ transactions }: { transactions: InsiderTransaction[] }) {
+ const [filter, setFilter] = useState<Filter>("all");
+
+ const filtered =
+ filter === "all" ? transactions : transactions.filter((t) => t.direction === filter);
+
+ const FILTERS: { key: Filter; label: string }[] = [
+ { key: "all", label: "ALL" },
+ { key: "buy", label: "BUYS" },
+ { key: "sell", label: "SELLS" },
+ ];
+
+ return (
+ <div>
+ {/* Filter toggles */}
+ <div
+ style={{
+ display: "flex",
+ gap: "var(--sp-2)",
+ marginBottom: "var(--sp-4)",
+ }}
+ >
+ {FILTERS.map(({ key, label }) => (
+ <button
+ key={key}
+ onClick={() => setFilter(key)}
+ className={`psm-tab${filter === key ? " active" : ""}`}
+ style={{ cursor: "pointer" }}
+ >
+ {label}
+ </button>
+ ))}
+ </div>
+
+ {filtered.length === 0 ? (
+ <div
+ style={{
+ textAlign: "center",
+ padding: "var(--sp-8) 0",
+ fontFamily: "var(--font-sans)",
+ fontSize: "var(--fs-13)",
+ color: "var(--fg-3)",
+ }}
+ >
+ No transactions match filter
+ </div>
+ ) : (
+ filtered.map((tx, i) => <TransactionRow key={i} tx={tx} />)
+ )}
+ </div>
+ );
+}
+
+// ── Page ──────────────────────────────────────────────────────────────────────
+
+export function InsidersPage({ ticker, overview, isSaved, onToggleWatchlist }: Props) {
+ const [data, setData] = useState<InsidersResponse | null>(null);
+ const [state, setState] = useState<InsidersState>("loading");
+ const kpis = buildKpis(overview);
+
+ useEffect(() => {
+ let cancelled = false;
+ setState("loading");
+ setData(null);
+
+ api
+ .insiders(ticker)
+ .then((res) => {
+ if (cancelled) return;
+ if (!res.transactions.length) {
+ setState("empty");
+ } else {
+ setData(res);
+ setState("ready");
+ }
+ })
+ .catch(() => {
+ if (!cancelled) setState("error");
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [ticker]);
+
+ return (
+ <>
+ <TickerHeader overview={overview} onToggleWatchlist={onToggleWatchlist} isSaved={isSaved} />
+ <KPIStrip items={kpis} />
+
+ <div style={{ padding: "0 var(--sp-1)" }}>
+ {state === "loading" && <InsidersSkeleton />}
+
+ {state === "error" && (
+ <div className="psm-card" style={{ textAlign: "center", padding: "var(--sp-8)" }}>
+ <div
+ style={{
+ fontFamily: "var(--font-sans)",
+ fontSize: "var(--fs-14)",
+ color: "var(--fg-3)",
+ }}
+ >
+ Failed to load insider data
+ </div>
+ </div>
+ )}
+
+ {state === "empty" && (
+ <div className="psm-card" style={{ textAlign: "center", padding: "var(--sp-8)" }}>
+ <div
+ style={{
+ fontFamily: "var(--font-sans)",
+ fontSize: "var(--fs-14)",
+ color: "var(--fg-3)",
+ }}
+ >
+ No insider activity reported
+ </div>
+ </div>
+ )}
+
+ {state === "ready" && data && (
+ <>
+ <InsiderKPIs summary={data.summary} />
+
+ <div className="psm-card" style={{ marginBottom: "var(--sp-4)" }}>
+ <div
+ style={{
+ display: "flex",
+ justifyContent: "space-between",
+ alignItems: "baseline",
+ marginBottom: "var(--sp-4)",
+ }}
+ >
+ <span
+ style={{
+ fontFamily: "var(--font-sans)",
+ fontSize: "var(--fs-11)",
+ color: "var(--fg-3)",
+ letterSpacing: "0.08em",
+ textTransform: "uppercase",
+ }}
+ >
+ Monthly Insider Activity — Last 6 Months ($M)
+ </span>
+ </div>
+ <MonthlyChart data={data.monthly_chart} />
+ </div>
+
+ <div className="psm-card">
+ <div
+ style={{
+ fontFamily: "var(--font-sans)",
+ fontSize: "var(--fs-11)",
+ color: "var(--fg-3)",
+ letterSpacing: "0.08em",
+ textTransform: "uppercase",
+ marginBottom: "var(--sp-4)",
+ }}
+ >
+ All Transactions
+ </div>
+ <TransactionTable transactions={data.transactions} />
+ </div>
+ </>
+ )}
+ </div>
+
+ <style>{`
+ @keyframes psm-shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+ }
+ `}</style>
+ </>
+ );
+}
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
index 2c65ef2..84df997 100644
--- a/frontend/lib/api.ts
+++ b/frontend/lib/api.ts
@@ -1,4 +1,4 @@
-import type { FinancialsResponse, HistoryPoint, MarketIndex, RatiosResponse, SearchResult, TickerOverview, ValuationResponse, WatchlistResponse } from "@/types/api";
+import type { FinancialsResponse, HistoryPoint, InsidersResponse, MarketIndex, RatiosResponse, SearchResult, TickerOverview, ValuationResponse, WatchlistResponse } from "@/types/api";
const API_BASE = (process.env.NEXT_PUBLIC_API_BASE_URL || "").trim().replace(/\/$/, "");
@@ -63,5 +63,10 @@ export const api = {
return request<RatiosResponse>(
`/api/tickers/${encodeURIComponent(symbol)}/ratios`
);
- }
+ },
+ insiders(symbol: string) {
+ return request<InsidersResponse>(
+ `/api/tickers/${encodeURIComponent(symbol)}/insiders`
+ );
+ },
};
diff --git a/frontend/lib/overview.ts b/frontend/lib/overview.ts
index bb63e83..04f9c64 100644
--- a/frontend/lib/overview.ts
+++ b/frontend/lib/overview.ts
@@ -20,7 +20,7 @@ export const OVERVIEW_NAV_ITEMS: NavItem[] = [
{ key: "financials", label: "Financials", icon: "ledger" },
{ key: "valuation", label: "Valuation", icon: "dollar" },
{ key: "options", label: "Options", icon: "window" },
- { key: "insiders", label: "Insiders", icon: "pulse", disabled: true },
+{ key: "insiders", label: "Insiders", icon: "pulse" },
{ key: "filings", label: "Filings", icon: "folder", disabled: true },
{ key: "news", label: "News", icon: "terminal", disabled: true }
];
diff --git a/frontend/types/api.ts b/frontend/types/api.ts
index c8fc26b..c65d3d2 100644
--- a/frontend/types/api.ts
+++ b/frontend/types/api.ts
@@ -164,6 +164,34 @@ export type RatioPoint = {
vs_sector: number | null;
};
+export type InsiderTransaction = {
+ date: string | null;
+ insider: string;
+ position: string;
+ direction: "buy" | "sell" | "other";
+ shares: number | null;
+ value: number | null;
+};
+
+export type InsiderMonthPoint = {
+ month: string;
+ buy: number;
+ sell: number;
+};
+
+export type InsiderSummary = {
+ buy_count: number;
+ sell_count: number;
+ buy_value: number;
+ sell_value: number;
+};
+
+export type InsidersResponse = {
+ summary: InsiderSummary;
+ monthly_chart: InsiderMonthPoint[];
+ transactions: InsiderTransaction[];
+};
+
export type RatiosResponse = {
pe_ttm: RatioPoint;
ev_ebitda: RatioPoint;