diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 8e0b786..640912b 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1,8 +1,15 @@ :root { color-scheme: light; - font-family: "IBM Plex Mono", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - background: #f4f3ef; - color: #1b1b1b; + font-family: "IBM Plex Mono", "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New", monospace; + background: #efece6; + color: #1d1d1b; + --panel: #fff6e8; + --panel-border: #d9cdb8; + --accent: #2f6d4f; + --accent-soft: rgba(47, 109, 79, 0.18); + --warning: #c46f2a; + --grid: rgba(82, 64, 36, 0.12); } * { @@ -14,31 +21,194 @@ body { min-height: 100vh; } -.page { - display: grid; - place-items: center; +.dashboard { min-height: 100vh; - padding: 48px 24px; - background: radial-gradient(circle at top, #fef7e4, #f4f3ef 60%); + padding: 48px 8vw 72px; + display: grid; + gap: 32px; + background: + radial-gradient(circle at top left, #fff7df 0%, #efece6 56%), + repeating-linear-gradient( + 90deg, + transparent, + transparent 44px, + var(--grid) 45px, + var(--grid) 46px + ); } -.panel { - max-width: 520px; - padding: 32px 36px; - border: 1px solid #dad2c2; - border-radius: 18px; - background: #fff9ee; - box-shadow: 0 20px 40px rgba(48, 32, 12, 0.12); +.header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + flex-wrap: wrap; +} + +.eyebrow { + text-transform: uppercase; + letter-spacing: 0.4em; + font-size: 0.7rem; + margin: 0 0 12px; + color: #6f5b39; } h1 { margin: 0 0 12px; - font-size: 2.25rem; - letter-spacing: 0.08em; + font-size: clamp(2.2rem, 4vw, 3.4rem); + letter-spacing: 0.15em; text-transform: uppercase; } -p { - margin: 8px 0; +.subtitle { + margin: 0; + max-width: 460px; line-height: 1.6; + color: #4e3e25; +} + +.status { + display: grid; + gap: 8px; + padding: 16px 20px; + border-radius: 16px; + border: 1px solid var(--panel-border); + background: #fffdf7; + min-width: 220px; +} + +.status-dot { + width: 12px; + height: 12px; + border-radius: 999px; + background: var(--warning); + box-shadow: 0 0 0 4px rgba(196, 111, 42, 0.2); +} + +.status-connected .status-dot { + background: var(--accent); + box-shadow: 0 0 0 4px var(--accent-soft); +} + +.status-connecting .status-dot { + animation: pulse 1.2s ease-in-out infinite; +} + +.status span { + font-size: 0.95rem; +} + +.timestamp { + font-size: 0.8rem; + color: #6f5b39; +} + +.card { + border: 1px solid var(--panel-border); + border-radius: 24px; + background: var(--panel); + box-shadow: 0 30px 60px rgba(66, 45, 18, 0.14); + padding: 28px; +} + +.card-header { + display: flex; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + align-items: center; + margin-bottom: 24px; +} + +.card-header h2 { + margin: 0 0 6px; + font-size: 1.4rem; +} + +.card-subtitle { + margin: 0; + 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; + max-height: 480px; + overflow: auto; + padding-right: 6px; +} + +.row { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 16px 18px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(217, 205, 184, 0.6); +} + +.contract { + font-weight: 600; + margin-bottom: 6px; +} + +.meta { + display: flex; + flex-wrap: wrap; + gap: 12px; + font-size: 0.85rem; + color: #5b4c34; +} + +.time { + font-size: 0.85rem; + color: #6f5b39; + text-align: right; +} + +.empty { + padding: 24px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.7); + border: 1px dashed rgba(217, 205, 184, 0.8); + color: #5b4c34; +} + +@keyframes pulse { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.25); + } + 100% { + transform: scale(1); + } +} + +@media (max-width: 720px) { + .dashboard { + padding: 36px 6vw 56px; + } + + .row { + flex-direction: column; + align-items: flex-start; + } + + .time { + text-align: left; + } } diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 55c2a76..22ea46e 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,10 +1,194 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import type { 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; +}; + +const buildWsUrl = (): 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.search = ""; + url.hash = ""; + return url.toString(); + } + + const { protocol, hostname } = window.location; + const wsProtocol = protocol === "https:" ? "wss" : "ws"; + const isLocal = LOCAL_HOSTS.has(hostname); + const host = isLocal ? `${hostname}:4000` : window.location.host; + + return `${wsProtocol}://${host}/ws/options`; +}; + +const formatPrice = (price: number): string => { + return price.toFixed(2); +}; + +const formatSize = (size: number): string => { + return size.toLocaleString(); +}; + +const formatTime = (ts: number): string => { + return new Date(ts).toLocaleTimeString(); +}; + export default function HomePage() { + const [status, setStatus] = useState("connecting"); + const [prints, setPrints] = 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; + + const connect = () => { + if (!active) { + return; + } + + setStatus("connecting"); + + const socket = new WebSocket(buildWsUrl()); + socketRef.current = socket; + + socket.onopen = () => { + if (!active) { + return; + } + setStatus("connected"); + }; + + socket.onmessage = (event) => { + if (!active) { + return; + } + + try { + const message = JSON.parse(event.data) as OptionMessage; + if (message.type !== "option-print") { + return; + } + + setPrints((prev) => { + const next = [message.payload, ...prev]; + return next.slice(0, MAX_ITEMS); + }); + setLastUpdate(Date.now()); + } catch (error) { + console.warn("Failed to parse websocket payload", error); + } + }; + + socket.onclose = () => { + if (!active) { + return; + } + + setStatus("disconnected"); + reconnectRef.current = window.setTimeout(() => { + connect(); + }, 1000); + }; + + socket.onerror = () => { + if (!active) { + return; + } + + setStatus("disconnected"); + socket.close(); + }; + }; + + connect(); + + return () => { + active = false; + if (reconnectRef.current !== null) { + window.clearTimeout(reconnectRef.current); + } + if (socketRef.current) { + socketRef.current.close(); + } + }; + }, []); + return ( -
-
-

Islandflow

-

Realtime options flow + off-exchange analysis.

-

UI scaffold is up; live data wiring next.

+
+
+
+

Realtime flow workspace

+

Islandflow

+

Live option prints streaming from /ws/options.

+
+
+ + {statusLabel} + {lastUpdate ? ( + Updated {formatTime(lastUpdate)} + ) : ( + Waiting for data + )} +
+
+ +
+
+
+

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} +
+
+
{formatTime(print.ts)}
+
+ )) + )} +
); diff --git a/apps/web/package.json b/apps/web/package.json index e206b83..25c42dc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,6 +8,7 @@ "start": "next start -p 3000" }, "dependencies": { + "@islandflow/types": "workspace:*", "next": "^14.2.4", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/bun.lock b/bun.lock index 4b2f3e0..e5e75b5 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "apps/web": { "name": "@islandflow/web", "dependencies": { + "@islandflow/types": "workspace:*", "next": "^14.2.4", "react": "^18.3.1", "react-dom": "^18.3.1",