From 053e8e6ceaf07492b6c1079a4ba5be9ef4f0db3b Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Sat, 27 Dec 2025 19:58:58 -0500 Subject: [PATCH] Add equities tape to UI --- apps/web/app/globals.css | 60 +++++++++--- apps/web/app/page.tsx | 202 +++++++++++++++++++++++++++------------ apps/web/next-env.d.ts | 3 +- 3 files changed, 190 insertions(+), 75 deletions(-) diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 640912b..4efd249 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -67,6 +67,33 @@ h1 { color: #4e3e25; } +.summary { + display: grid; + gap: 8px; + padding: 16px 20px; + border-radius: 16px; + border: 1px solid var(--panel-border); + background: #fffdf7; + min-width: 220px; +} + +.summary-title { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.28em; + color: #6f5b39; +} + +.summary-value { + font-size: 1rem; +} + +.cards { + display: grid; + gap: 28px; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); +} + .status { display: grid; gap: 8px; @@ -77,6 +104,11 @@ h1 { min-width: 220px; } +.status-compact { + padding: 12px 16px; + min-width: 180px; +} + .status-dot { width: 12px; height: 12px; @@ -130,17 +162,6 @@ h1 { color: #5b4c34; } -.badge { - text-transform: uppercase; - letter-spacing: 0.2em; - font-size: 0.65rem; - padding: 8px 14px; - border-radius: 999px; - border: 1px solid var(--accent); - color: var(--accent); - background: rgba(47, 109, 79, 0.08); -} - .list { display: grid; gap: 14px; @@ -172,6 +193,23 @@ h1 { color: #5b4c34; } +.flag { + padding: 2px 8px; + border-radius: 999px; + font-size: 0.7rem; + letter-spacing: 0.1em; + text-transform: uppercase; + border: 1px solid rgba(47, 109, 79, 0.4); + color: #2f6d4f; + background: rgba(47, 109, 79, 0.12); +} + +.flag-muted { + border-color: rgba(111, 91, 57, 0.4); + color: #6f5b39; + background: rgba(111, 91, 57, 0.12); +} + .time { font-size: 0.85rem; color: #6f5b39; diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 22ea46e..2fff99e 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,25 +1,33 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; -import type { OptionPrint } from "@islandflow/types"; +import type { EquityPrint, OptionPrint } from "@islandflow/types"; const MAX_ITEMS = 60; const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1"]); type WsStatus = "connecting" | "connected" | "disconnected"; -type OptionMessage = { - type: "option-print"; - payload: OptionPrint; +type MessageType = "option-print" | "equity-print"; + +type StreamMessage = { + type: MessageType; + payload: T; }; -const buildWsUrl = (): string => { +type TapeState = { + status: WsStatus; + items: T[]; + lastUpdate: number | null; +}; + +const buildWsUrl = (path: string): string => { const envBase = process.env.NEXT_PUBLIC_API_URL; if (envBase) { const url = new URL(envBase); url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; - url.pathname = "/ws/options"; + url.pathname = path; url.search = ""; url.hash = ""; return url.toString(); @@ -30,7 +38,7 @@ const buildWsUrl = (): string => { const isLocal = LOCAL_HOSTS.has(hostname); const host = isLocal ? `${hostname}:4000` : window.location.host; - return `${wsProtocol}://${host}/ws/options`; + return `${wsProtocol}://${host}${path}`; }; const formatPrice = (price: number): string => { @@ -45,25 +53,25 @@ const formatTime = (ts: number): string => { return new Date(ts).toLocaleTimeString(); }; -export default function HomePage() { +const statusLabel = (status: WsStatus): string => { + switch (status) { + case "connected": + return "Live"; + case "connecting": + return "Connecting"; + case "disconnected": + default: + return "Disconnected"; + } +}; + +const useTape = (path: string, expectedType: MessageType): TapeState => { const [status, setStatus] = useState("connecting"); - const [prints, setPrints] = useState([]); + const [items, setItems] = useState([]); const [lastUpdate, setLastUpdate] = useState(null); const reconnectRef = useRef(null); const socketRef = useRef(null); - const statusLabel = useMemo(() => { - switch (status) { - case "connected": - return "Live"; - case "connecting": - return "Connecting"; - case "disconnected": - default: - return "Disconnected"; - } - }, [status]); - useEffect(() => { let active = true; @@ -74,7 +82,7 @@ export default function HomePage() { setStatus("connecting"); - const socket = new WebSocket(buildWsUrl()); + const socket = new WebSocket(buildWsUrl(path)); socketRef.current = socket; socket.onopen = () => { @@ -90,12 +98,12 @@ export default function HomePage() { } try { - const message = JSON.parse(event.data) as OptionMessage; - if (message.type !== "option-print") { + const message = JSON.parse(event.data) as StreamMessage; + if (!message || message.type !== expectedType) { return; } - setPrints((prev) => { + setItems((prev) => { const next = [message.payload, ...prev]; return next.slice(0, MAX_ITEMS); }); @@ -137,7 +145,39 @@ export default function HomePage() { socketRef.current.close(); } }; - }, []); + }, [path, expectedType]); + + return { status, items, lastUpdate }; +}; + +type TapeStatusProps = { + status: WsStatus; + lastUpdate: number | null; +}; + +const TapeStatus = ({ status, lastUpdate }: TapeStatusProps) => { + return ( +
+ + {statusLabel(status)} + {lastUpdate ? ( + Updated {formatTime(lastUpdate)} + ) : ( + Waiting for data + )} +
+ ); +}; + +export default function HomePage() { + const options = useTape("/ws/options", "option-print"); + const equities = useTape("/ws/equities", "equity-print"); + + const lastSeen = useMemo(() => { + return [options.lastUpdate, equities.lastUpdate] + .filter((value): value is number => value !== null) + .sort((a, b) => b - a)[0] ?? null; + }, [options.lastUpdate, equities.lastUpdate]); return (
@@ -145,51 +185,87 @@ export default function HomePage() {

Realtime flow workspace

Islandflow

-

Live option prints streaming from /ws/options.

+

+ Options + equities streaming over WebSocket from the local API gateway. +

-
- - {statusLabel} - {lastUpdate ? ( - Updated {formatTime(lastUpdate)} - ) : ( - Waiting for data - )} +
+ Last update + + {lastSeen ? formatTime(lastSeen) : "Waiting for data"} +
-
-
-
-

Options Tape

-

Newest prints first (max {MAX_ITEMS}).

+
+
+
+
+

Options Tape

+

Newest prints first (max {MAX_ITEMS}).

+
+
- Live -
-
- {prints.length === 0 ? ( -
No prints yet. Start ingest-options to populate the tape.
- ) : ( - prints.map((print) => ( -
-
-
{print.option_contract_id}
-
- ${formatPrice(print.price)} - {formatSize(print.size)}x - {print.exchange} - {print.conditions?.length ? ( - {print.conditions.join(", ")} - ) : null} +
+ {options.items.length === 0 ? ( +
No option prints yet. Start ingest-options.
+ ) : ( + options.items.map((print) => ( +
+
+
{print.option_contract_id}
+
+ ${formatPrice(print.price)} + {formatSize(print.size)}x + {print.exchange} + {print.conditions?.length ? ( + {print.conditions.join(", ")} + ) : null} +
+
{formatTime(print.ts)}
-
{formatTime(print.ts)}
-
- )) - )} -
-
+ )) + )} +
+ + +
+
+
+

Equities Tape

+

Off-exchange flag highlighted.

+
+ +
+ +
+ {equities.items.length === 0 ? ( +
No equity prints yet. Start ingest-equities.
+ ) : ( + equities.items.map((print) => ( +
+
+
{print.underlying_id}
+
+ ${formatPrice(print.price)} + {formatSize(print.size)}x + {print.exchange} + {print.offExchangeFlag ? ( + Off-Ex + ) : ( + Lit + )} +
+
+
{formatTime(print.ts)}
+
+ )) + )} +
+
+
); } diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 8b0a849..40c3d68 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,4 +1,5 @@ /// /// -// Note: This file is normally generated by Next.js. +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.