diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 4efd249..452ffe5 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -109,6 +109,10 @@ h1 { min-width: 180px; } +.status-paused { + background: #fff3e4; +} + .status-dot { width: 12px; height: 12px; @@ -135,6 +139,29 @@ h1 { color: #6f5b39; } +.pause-button { + border: 1px solid rgba(47, 109, 79, 0.3); + border-radius: 999px; + padding: 6px 12px; + background: rgba(47, 109, 79, 0.12); + color: #2f6d4f; + font-size: 0.75rem; + letter-spacing: 0.12em; + text-transform: uppercase; + cursor: pointer; +} + +.status-paused .pause-button { + border-color: rgba(196, 111, 42, 0.4); + background: rgba(196, 111, 42, 0.16); + color: #8c4a16; +} + +.pause-button:focus-visible { + outline: 2px solid rgba(47, 109, 79, 0.4); + outline-offset: 2px; +} + .card { border: 1px solid var(--panel-border); border-radius: 24px; diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 2fff99e..782f2d7 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { EquityPrint, OptionPrint } from "@islandflow/types"; const MAX_ITEMS = 60; @@ -19,6 +19,9 @@ type TapeState = { status: WsStatus; items: T[]; lastUpdate: number | null; + paused: boolean; + dropped: number; + togglePause: () => void; }; const buildWsUrl = (path: string): string => { @@ -53,7 +56,11 @@ const formatTime = (ts: number): string => { return new Date(ts).toLocaleTimeString(); }; -const statusLabel = (status: WsStatus): string => { +const statusLabel = (status: WsStatus, paused: boolean): string => { + if (paused) { + return "Paused"; + } + switch (status) { case "connected": return "Live"; @@ -69,9 +76,21 @@ const useTape = (path: string, expectedType: MessageType): TapeState => { const [status, setStatus] = useState("connecting"); const [items, setItems] = useState([]); const [lastUpdate, setLastUpdate] = useState(null); + const [paused, setPaused] = useState(false); + const [dropped, setDropped] = useState(0); const reconnectRef = useRef(null); const socketRef = useRef(null); + const togglePause = useCallback(() => { + setPaused((prev) => { + const next = !prev; + if (!next) { + setDropped(0); + } + return next; + }); + }, []); + useEffect(() => { let active = true; @@ -103,6 +122,12 @@ const useTape = (path: string, expectedType: MessageType): TapeState => { return; } + if (paused) { + setDropped((prev) => prev + 1); + setLastUpdate(Date.now()); + return; + } + setItems((prev) => { const next = [message.payload, ...prev]; return next.slice(0, MAX_ITEMS); @@ -145,26 +170,35 @@ const useTape = (path: string, expectedType: MessageType): TapeState => { socketRef.current.close(); } }; - }, [path, expectedType]); + }, [path, expectedType, paused]); - return { status, items, lastUpdate }; + return { status, items, lastUpdate, paused, dropped, togglePause }; }; type TapeStatusProps = { status: WsStatus; lastUpdate: number | null; + paused: boolean; + dropped: number; + onTogglePause: () => void; }; -const TapeStatus = ({ status, lastUpdate }: TapeStatusProps) => { +const TapeStatus = ({ status, lastUpdate, paused, dropped, onTogglePause }: TapeStatusProps) => { return ( -
+
- {statusLabel(status)} + {statusLabel(status, paused)} {lastUpdate ? ( Updated {formatTime(lastUpdate)} ) : ( Waiting for data )} + {paused && dropped > 0 ? ( + {dropped} new while paused + ) : null} +
); }; @@ -204,7 +238,13 @@ export default function HomePage() {

Options Tape

Newest prints first (max {MAX_ITEMS}).

- +
@@ -237,7 +277,13 @@ export default function HomePage() {

Equities Tape

Off-exchange flag highlighted.

- +