spb/market-atlas
Public
TypeScript 96.7%
SQL 1.6%
CSS 0.8%
JavaScript 0.5%
1"use client";23import { Pause, Play } from "lucide-react";4import Link from "next/link";5import { useEffect, useMemo, useState } from "react";6import { EventRow } from "./event-row";7import { toneOf } from "@/components/ui/price";8import { Pill } from "@/components/ui/section";9import { cx, EVENT_TYPE_LABEL, formatClock, formatPercent, formatQuoteValue, instrumentHref } from "@/lib/format";10import { useLiveEvents, useMarketStream, useTape } from "@/lib/stream";11import type { MarketEvent, StreamQuote } from "@/lib/types";1213const CLASSES = ["CRYPTO", "EQUITY", "ETF", "INDEX", "FOREX", "TREASURY", "COMMODITY"];14const TYPES = ["PRICE_CHANGE", "SESSION_HIGH", "SESSION_LOW", "VOLATILITY_SPIKE", "TRADING_HALT", "TRADING_RESUME", "FILING_PUBLISHED", "MARKET_OPEN", "MARKET_CLOSE", "SOURCE_DIVERGENCE", "SOURCE_FAILURE", "SCHEMA_DRIFT", "DOCUMENT_CHANGED"];15const SEVERITIES = ["INFO", "NOTICE", "WARNING", "CRITICAL"];1617type Item = { kind: "event"; ts: number; e: MarketEvent } | { kind: "quote"; ts: number; q: StreamQuote };1819/** Flagship /live view: merged stream of events and price changes with client-side filters and pause. */20export function LiveFeed({ initial }: { initial: MarketEvent[] }) {21 const [paused, setPaused] = useState(false);22 const [showQuotes, setShowQuotes] = useState(true);23 const [cls, setCls] = useState<string | null>(null);24 const [country, setCountry] = useState("");25 const [types, setTypes] = useState<Set<string>>(new Set());26 const [sev, setSev] = useState<string | null>(null);27 const [minConf, setMinConf] = useState(0);28 const state = useMarketStream(showQuotes ? ["events:*", "tape"] : ["events:*"]);29 const liveEvents = useLiveEvents(300);30 const tape = useTape(120);31 const [frozen, setFrozen] = useState<Item[] | null>(null);3233 const items = useMemo<Item[]>(() => {34 const seen = new Set<string>();35 const out: Item[] = [];36 for (const e of [...liveEvents, ...initial]) {37 if (seen.has(e.id)) continue;38 seen.add(e.id);39 out.push({ kind: "event", ts: typeof e.timestamp === "number" ? e.timestamp : Date.parse(String(e.timestamp).replace(" ", "T").replace(/([+-]\d{2})$/, "$1:00")), e });40 }41 if (showQuotes) for (const q of tape) out.push({ kind: "quote", ts: q.received, q });42 return out43 .filter((it) => {44 if (it.kind === "event") {45 const e = it.e;46 if (types.size && !types.has(e.type)) return false;47 if (sev && SEVERITIES.indexOf(e.severity) < SEVERITIES.indexOf(sev)) return false;48 if (e.confidence < minConf) return false;49 if (cls && !e.instruments.some((i) => i.asset_class === cls) && e.instruments.length) return false;50 if (country && !e.instruments.some((i) => i.country === country.toUpperCase())) return false;51 return true;52 }53 if (types.size || sev) return false;54 if (it.q.confidence < minConf) return false;55 return true;56 })57 .sort((a, b) => b.ts - a.ts)58 .slice(0, 300);59 }, [liveEvents, initial, tape, showQuotes, types, sev, minConf, cls, country]);6061 useEffect(() => {62 if (paused && !frozen) setFrozen(items);63 if (!paused && frozen) setFrozen(null);64 // eslint-disable-next-line react-hooks/exhaustive-deps65 }, [paused]);66 const shown = paused && frozen ? frozen : items;67 const toggleType = (t: string) =>68 setTypes((s) => {69 const n = new Set(s);70 if (n.has(t)) n.delete(t);71 else n.add(t);72 return n;73 });7475 return (76 <div className="grid grid-cols-1 [&>*]:min-w-0 gap-5 lg:grid-cols-[260px_1fr]">77 <aside className="space-y-4 lg:sticky lg:top-[calc(var(--header-h)+40px)] lg:self-start">78 <div className="flex items-center gap-2">79 <button type="button" onClick={() => setPaused((p) => !p)} className={cx("inline-flex h-10 items-center gap-2 rounded-md border px-3 text-sm", paused ? "border-warning text-warning" : "border-rule text-ink-2 hover:text-ink")}>80 {paused ? <Play size={14} /> : <Pause size={14} />}81 {paused ? "Resume" : "Pause"}82 </button>83 <span className={cx("inline-flex items-center gap-1.5 text-xs", state === "open" ? "text-positive" : "text-warning")}>84 <span className={cx("inline-block h-1.5 w-1.5 rounded-full", state === "open" ? "bg-positive live-dot" : "bg-warning")} />85 {state === "open" ? "streaming" : state}86 </span>87 </div>88 <label className="flex items-center gap-2 text-sm text-ink-2">89 <input type="checkbox" checked={showQuotes} onChange={(e) => setShowQuotes(e.target.checked)} className="h-4 w-4" /> Include price changes90 </label>91 <Filter title="Asset class">92 <Pill active={!cls} onClick={() => setCls(null)}>93 All94 </Pill>95 {CLASSES.map((c) => (96 <Pill key={c} active={cls === c} onClick={() => setCls(cls === c ? null : c)}>97 {c.toLowerCase()}98 </Pill>99 ))}100 </Filter>101 <Filter title="Country (ISO-2)">102 <input value={country} onChange={(e) => setCountry(e.target.value.toUpperCase().slice(0, 2))} placeholder="US, CA, XX…" className="mono h-9 w-24 rounded-md border border-rule bg-surface px-2 text-sm uppercase outline-none focus:border-rule-strong" />103 </Filter>104 <Filter title="Event type">105 {TYPES.map((t) => (106 <Pill key={t} active={types.has(t)} onClick={() => toggleType(t)}>107 {EVENT_TYPE_LABEL[t] ?? t}108 </Pill>109 ))}110 </Filter>111 <Filter title="Minimum severity">112 <Pill active={!sev} onClick={() => setSev(null)}>113 Any114 </Pill>115 {SEVERITIES.map((s) => (116 <Pill key={s} active={sev === s} onClick={() => setSev(sev === s ? null : s)}>117 {s.toLowerCase()}118 </Pill>119 ))}120 </Filter>121 <Filter title={`Minimum confidence · ${Math.round(minConf * 100)}%`}>122 <input type="range" min={0} max={1} step={0.05} value={minConf} onChange={(e) => setMinConf(Number(e.target.value))} className="w-full" />123 </Filter>124 </aside>125 <div>126 <div className="mb-2 flex items-center justify-between text-xs text-ink-3">127 <span>128 {shown.length} items{paused ? " · paused" : ""}129 </span>130 <Link href="/events" className="text-accent hover:underline">131 Browse the event archive →132 </Link>133 </div>134 {shown.length === 0 ? (135 <div className="rounded-md border border-dashed border-rule px-4 py-12 text-center text-sm text-ink-3">Nothing matches these filters yet. The feed fills as sources emit changes.</div>136 ) : (137 <ul className="rounded-md border border-rule bg-surface px-3">138 {shown.map((it) =>139 it.kind === "event" ? (140 <EventRow key={it.e.id} e={it.e} />141 ) : (142 <li key={`${it.q.instrument_id}-${it.q.received}`} className="flex items-center gap-3 border-b border-rule py-1.5 text-sm last:border-0">143 <span className="mono w-[76px] shrink-0 text-right text-[11px] text-ink-3" suppressHydrationWarning>144 {formatClock(it.q.timestamp)}145 </span>146 <span className="text-[10.5px] font-medium uppercase tracking-wide text-ink-3">quote</span>147 <Link href={instrumentHref(it.q.instrument_id)} className="mono font-medium text-accent hover:underline">148 {it.q.symbol}149 </Link>150 <span className="mono ml-auto tnum">{formatQuoteValue(it.q.price, undefined, it.q.currency)}</span>151 <span className={cx("mono w-[70px] text-right tnum", toneOf(it.q.change_pct))}>{formatPercent(it.q.change_pct)}</span>152 <span className="mono hidden w-[110px] text-right text-[11px] text-ink-3 sm:inline">153 {it.q.sources} src · {Math.round(it.q.confidence * 100)}%154 </span>155 </li>156 ),157 )}158 </ul>159 )}160 </div>161 </div>162 );163}164165function Filter({ title, children }: { title: string; children: React.ReactNode }) {166 return (167 <div>168 <div className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-ink-3">{title}</div>169 <div className="flex flex-wrap gap-1.5">{children}</div>170 </div>171 );172}173