SPB Git forge

spb/market-atlas

Public
12commits 1branches 0releases
1.1 MBsize
maindefault branch
10 days agolast push
TypeScript 96.7% SQL 1.6% CSS 0.8% JavaScript 0.5%
8.3 KB · 173 lines tsx
Raw Blame History
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