From d5e321089b6b82f5425c2b3fae142db5223ce599 Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Tue, 9 Jun 2026 19:19:44 -0700 Subject: 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) --- frontend/components/prism/InsidersPage.tsx | 507 +++++++++++++++++++++++++++++ 1 file changed, 507 insertions(+) create mode 100644 frontend/components/prism/InsidersPage.tsx (limited to 'frontend/components/prism') 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 ( +
+ ); +} + +function InsidersSkeleton() { + return ( + <> + {/* KPI row */} +
+ {[0, 1, 2, 3].map((i) => )} +
+ {/* Chart card */} +
+ +
+ {/* Table card */} +
+ +
+ {[0, 1, 2, 3, 4].map((i) => )} +
+
+ + ); +} + +// ── 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 ( +
+ {items.map((item) => ( +
+
+ {item.label} +
+
+ {item.value || "—"} +
+
+ ))} +
+ ); +} + +// ── Monthly chart (SVG diverging bars) ─────────────────────────────────────── + +function MonthlyChart({ data }: { data: InsidersResponse["monthly_chart"] }) { + const containerRef = useRef(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 ( +
+ No activity in the last 6 months +
+ ); + } + + 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 ( +
+ + {/* 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 ( + + + + {label} + + + ); + })} + + {/* Bars */} + {data.map((d, i) => { + const cx = PAD_L + i * barGap + barGap / 2; + const buyH = yScale(d.buy); + const sellH = yScale(d.sell); + + return ( + + {/* Buy bar (up) */} + {d.buy > 0 && ( + + )} + {/* Sell bar (down) */} + {d.sell > 0 && ( + + )} + {/* Month label */} + + {d.month.slice(5)} + + + ); + })} + + + {/* Legend */} +
+ {[ + { color: "var(--positive)", label: "Buys" }, + { color: "var(--negative)", label: "Sells" }, + ].map(({ color, label }) => ( +
+ + {label} +
+ ))} +
+
+ ); +} + +// ── Transaction table ───────────────────────────────────────────────────────── + +function TransactionRow({ tx }: { tx: InsiderTransaction }) { + const isBuy = tx.direction === "buy"; + const isSell = tx.direction === "sell"; + + return ( +
+
+ {tx.insider || "—"} + {tx.position || "—"} +
+ + {tx.direction.toUpperCase()} + +
+
{tx.value ? fmtLarge(tx.value) : "—"}
+
+ {tx.shares ? `${fmtNumber(tx.shares, 0)} sh` : ""} + {tx.date ? ` · ${tx.date}` : ""} +
+
+
+ ); +} + +function TransactionTable({ transactions }: { transactions: InsiderTransaction[] }) { + const [filter, setFilter] = useState("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 ( +
+ {/* Filter toggles */} +
+ {FILTERS.map(({ key, label }) => ( + + ))} +
+ + {filtered.length === 0 ? ( +
+ No transactions match filter +
+ ) : ( + filtered.map((tx, i) => ) + )} +
+ ); +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +export function InsidersPage({ 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 + .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 ( + <> + + + +
+ {state === "loading" && } + + {state === "error" && ( +
+
+ Failed to load insider data +
+
+ )} + + {state === "empty" && ( +
+
+ No insider activity reported +
+
+ )} + + {state === "ready" && data && ( + <> + + +
+
+ + Monthly Insider Activity — Last 6 Months ($M) + +
+ +
+ +
+
+ All Transactions +
+ +
+ + )} +
+ + + + ); +} -- cgit v1.3-2-g0d8e