From c0219233d34c50626caea381081df616b7af1964 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Mon, 4 May 2026 13:42:47 -0400 Subject: [PATCH] Simplify web terminal routes and home layout - Redirect legacy signals/charts/replay pages to home - Trim nav and update live subscriptions for home and tape - Refresh terminal topbar and layout behavior --- AGENTS.md | 24 +++++ apps/web/app/charts/page.tsx | 4 +- apps/web/app/globals.css | 102 +++++++++------------ apps/web/app/replay/page.tsx | 4 +- apps/web/app/routes.test.ts | 31 +++++++ apps/web/app/signals/page.tsx | 4 +- apps/web/app/terminal.test.ts | 41 +++++++-- apps/web/app/terminal.tsx | 163 ++++++++++++---------------------- 8 files changed, 197 insertions(+), 176 deletions(-) create mode 100644 apps/web/app/routes.test.ts diff --git a/AGENTS.md b/AGENTS.md index 2899947..ecf3a15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,3 +44,27 @@ bd close # Complete work - NEVER say "ready to push when you are" - YOU must push - If push fails, resolve and retry until it succeeds + +## Minimal Repo Operating Instructions + +This is a Bun + TypeScript monorepo for an event-sourced market-data pipeline: +- Flow: ingest services publish to NATS/JetStream, compute/candles derive events, API serves REST/WS, web consumes live/replay streams. +- Main folders: `services/*` (runtime services), `packages/*` (shared libs/types/storage), `apps/web` (Next.js UI). +- Infra dependency: local dev assumes Docker services (NATS, ClickHouse, Redis) are available. + +Use these repo-specific commands: +- Install deps: `bun install` +- Start full stack: `bun run dev` +- Start infra only: `bun run dev:infra` +- Start backend services only: `bun run dev:services` +- Start web only: `bun run dev:web` + +Testing and validation in this repo are Bun-first: +- Run tests: `bun test` +- Run scoped tests: `bun test services/compute/tests` (or another package/service path) +- Validate web production build when UI code changes: `bun --cwd=apps/web run build` + +Working style that avoids common problems here: +- Prefer editing in the touched workspace (`services/`, `packages/`, `apps/web`) and keep shared contract changes in `packages/types`. +- Keep `.env` aligned with `.env.example`; adapters default to synthetic modes for local development. +- Dev runners persist child PID state in `.tmp/`; if a previous run crashed, restart via the standard `bun run dev*` commands so stale processes are cleaned up. diff --git a/apps/web/app/charts/page.tsx b/apps/web/app/charts/page.tsx index a2eb858..9d82bba 100644 --- a/apps/web/app/charts/page.tsx +++ b/apps/web/app/charts/page.tsx @@ -1,7 +1,7 @@ -import { ChartsRoute } from "../terminal"; +import { redirect } from "next/navigation"; export const dynamic = "force-dynamic"; export default function Page() { - return ; + redirect("/"); } diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index d8a7377..76a730f 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -19,7 +19,7 @@ --blue: #4da3ff; --blue-soft: rgba(77, 163, 255, 0.14); --rail-width: 236px; - --topbar-height: 76px; + --topbar-height: 64px; } * { @@ -166,38 +166,15 @@ input { position: sticky; top: 0; z-index: 20; - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: end; - gap: 18px 24px; - padding: 16px 24px 14px; + display: flex; + align-items: center; + gap: 12px; + padding: 10px 20px; background: rgba(7, 10, 14, 0.92); backdrop-filter: blur(12px); border-bottom: 1px solid var(--border); } -.feed-status-bar { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; - min-width: 0; -} - -.feed-status { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 8px 10px; - border-radius: 999px; - border: 1px solid var(--border); - background: rgba(255, 255, 255, 0.02); - color: var(--text-dim); - font-family: var(--font-mono), monospace; - font-size: 0.75rem; -} - -.feed-status-dot, .status-dot, .chart-dot { width: 8px; @@ -206,28 +183,24 @@ input { background: var(--text-faint); } -.feed-status-connected .feed-status-dot, .status-connected .status-dot, .chart-status-connected .chart-dot { background: var(--green); box-shadow: 0 0 0 4px rgba(37, 193, 122, 0.14); } -.feed-status-connecting .feed-status-dot, .chart-status-connecting .chart-dot { background: var(--accent); box-shadow: 0 0 0 4px rgba(245, 166, 35, 0.12); animation: pulse 1.3s ease-in-out infinite; } -.feed-status-stale .feed-status-dot, .status-stale .status-dot, .chart-status-stale .chart-dot { background: var(--accent); box-shadow: 0 0 0 4px rgba(245, 166, 35, 0.18); } -.feed-status-disconnected .feed-status-dot, .status-disconnected .status-dot, .chart-status-disconnected .chart-dot { background: var(--red); @@ -236,33 +209,35 @@ input { .terminal-topbar-actions { display: flex; - align-items: flex-end; - justify-content: flex-end; - gap: 20px; + align-items: center; + justify-content: space-between; + gap: 12px; min-width: 0; + width: 100%; } .terminal-topbar-controls { display: flex; - align-items: flex-end; - justify-content: flex-end; - gap: 12px; + align-items: center; + gap: 10px; min-width: 0; + flex: 1 1 auto; } .terminal-topbar-mode { display: flex; - align-items: flex-end; + align-items: center; justify-content: flex-end; flex: 0 0 auto; + margin-left: auto; } .terminal-filter { display: flex; flex-direction: column; - gap: 6px; - min-width: clamp(280px, 26vw, 420px); - flex: 0 1 clamp(280px, 26vw, 420px); + gap: 4px; + min-width: clamp(220px, 24vw, 360px); + flex: 1 1 clamp(220px, 24vw, 360px); } .terminal-filter-label { @@ -274,7 +249,7 @@ input { position: relative; display: flex; align-items: center; - min-height: 36px; + min-height: 32px; } .terminal-filter-field::before, @@ -308,7 +283,7 @@ input { .terminal-input { min-width: 0; width: 100%; - padding: 0 0 8px; + padding: 0 0 6px; border: none; border-radius: 0; background: transparent; @@ -358,8 +333,8 @@ input { .overlay-toggle, .drawer-close { border: 1px solid var(--border); - border-radius: 10px; - padding: 10px 12px; + border-radius: 8px; + padding: 8px 10px; background: rgba(255, 255, 255, 0.03); color: var(--text); cursor: pointer; @@ -388,9 +363,9 @@ input { display: inline-flex; align-items: center; gap: 8px; - min-height: 34px; + min-height: 32px; max-width: min(360px, 32vw); - padding: 6px 8px 6px 10px; + padding: 5px 8px 5px 10px; border: 1px solid rgba(255, 216, 154, 0.34); border-radius: 8px; background: rgba(245, 166, 35, 0.08); @@ -444,7 +419,7 @@ input { .terminal-content { min-width: 0; - padding: 34px 24px 28px; + padding: 24px 24px 24px; } .page-shell { @@ -617,11 +592,10 @@ h3 { color: #ffe4b3; } -.overview-strip, .replay-matrix { display: grid; gap: 12px; - grid-template-columns: repeat(6, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); } .overview-cell { @@ -638,8 +612,8 @@ h3 { align-items: stretch; } -.page-grid-overview { - grid-template-columns: repeat(3, minmax(0, 1fr)); +.page-grid-home { + grid-template-columns: minmax(0, 2fr) minmax(320px, 1fr); } .page-grid-tape { @@ -661,7 +635,7 @@ h3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } -.page-grid-overview > :nth-child(1), +.page-grid-home > :nth-child(3), .page-grid-tape > :nth-child(1), .page-grid-replay > :nth-child(1) { grid-column: 1 / -1; @@ -888,7 +862,12 @@ h3 { max-height: 260px; } -.page-grid-overview > :not(:first-child), +.page-grid-home > :nth-child(1), +.page-grid-home > :nth-child(2) { + height: clamp(430px, 56vh, 760px); +} + +.page-grid-home > :nth-child(3), .page-grid-replay > :not(:first-child) { height: clamp(430px, 58vh, 760px); } @@ -1570,25 +1549,26 @@ h3 { } @media (max-width: 980px) { - .page-grid-overview, + .page-grid-home, .page-grid-tape, .page-grid-signals, .page-grid-charts, .page-grid-replay, - .overview-strip, .replay-matrix, .shell-metrics { grid-template-columns: minmax(0, 1fr); } - .page-grid-overview > :nth-child(1), + .page-grid-home > :nth-child(3), .page-grid-tape > :nth-child(1), .page-grid-replay > :nth-child(1) { grid-column: auto; grid-row: auto; } - .page-grid-overview > :not(:first-child), + .page-grid-home > :nth-child(1), + .page-grid-home > :nth-child(2), + .page-grid-home > :nth-child(3), .page-grid-signals > .terminal-pane, .page-grid-replay > :not(:first-child), .page-grid-tape > :first-child, @@ -1604,8 +1584,8 @@ h3 { .terminal-topbar { position: static; - grid-template-columns: minmax(0, 1fr); - align-items: stretch; + align-items: center; + padding: 10px 16px; } .terminal-topbar-actions { diff --git a/apps/web/app/replay/page.tsx b/apps/web/app/replay/page.tsx index fbf4635..9d82bba 100644 --- a/apps/web/app/replay/page.tsx +++ b/apps/web/app/replay/page.tsx @@ -1,7 +1,7 @@ -import { ReplayRoute } from "../terminal"; +import { redirect } from "next/navigation"; export const dynamic = "force-dynamic"; export default function Page() { - return ; + redirect("/"); } diff --git a/apps/web/app/routes.test.ts b/apps/web/app/routes.test.ts new file mode 100644 index 0000000..55b29e0 --- /dev/null +++ b/apps/web/app/routes.test.ts @@ -0,0 +1,31 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; + +const redirect = mock((path: string) => { + throw new Error(`NEXT_REDIRECT:${path}`); +}); + +mock.module("next/navigation", () => ({ redirect })); + +describe("legacy page redirects", () => { + beforeEach(() => { + redirect.mockClear(); + }); + + it("redirects /signals to home", async () => { + const mod = await import("./signals/page"); + expect(() => mod.default()).toThrow("NEXT_REDIRECT:/"); + expect(redirect).toHaveBeenCalledWith("/"); + }); + + it("redirects /charts to home", async () => { + const mod = await import("./charts/page"); + expect(() => mod.default()).toThrow("NEXT_REDIRECT:/"); + expect(redirect).toHaveBeenCalledWith("/"); + }); + + it("redirects /replay to home", async () => { + const mod = await import("./replay/page"); + expect(() => mod.default()).toThrow("NEXT_REDIRECT:/"); + expect(redirect).toHaveBeenCalledWith("/"); + }); +}); diff --git a/apps/web/app/signals/page.tsx b/apps/web/app/signals/page.tsx index a33ddfa..9d82bba 100644 --- a/apps/web/app/signals/page.tsx +++ b/apps/web/app/signals/page.tsx @@ -1,7 +1,7 @@ -import { SignalsRoute } from "../terminal"; +import { redirect } from "next/navigation"; export const dynamic = "force-dynamic"; export default function Page() { - return ; + redirect("/"); } diff --git a/apps/web/app/terminal.test.ts b/apps/web/app/terminal.test.ts index 36a231e..0c65741 100644 --- a/apps/web/app/terminal.test.ts +++ b/apps/web/app/terminal.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import { + NAV_ITEMS, buildDefaultFlowFilters, classifierToneForFamily, deriveAlertDirection, @@ -40,9 +41,9 @@ const makeAlert = (overrides: Record = {}) => }) as any; describe("live manifest", () => { - it("includes options on every live route", () => { + it("includes options on home and tape", () => { const filters = buildDefaultFlowFilters(); - for (const pathname of ["/", "/tape", "/signals", "/charts", "/replay"]) { + for (const pathname of ["/", "/tape"]) { expect( getLiveManifest(pathname, "SPY", 60000, filters).some( (subscription) => subscription.channel === "options" @@ -61,17 +62,38 @@ describe("live manifest", () => { expect(tapeOptionsSubscriptions).toHaveLength(1); }); - it("keeps option filters on baseline subscription", () => { + it("keeps option filters on baseline subscription across page changes", () => { const filters = { ...buildDefaultFlowFilters(), minNotional: 125_000 }; - const optionsSubscription = getLiveManifest("/signals", "SPY", 60000, filters).find( + const homeOptionsSubscription = getLiveManifest("/", "SPY", 60000, filters).find( + (subscription) => subscription.channel === "options" + ); + const tapeOptionsSubscription = getLiveManifest("/tape", "SPY", 60000, filters).find( (subscription) => subscription.channel === "options" ); - expect(optionsSubscription?.filters).toBe(filters); + expect(homeOptionsSubscription?.filters).toBe(filters); + expect(tapeOptionsSubscription?.filters).toBe(filters); + }); + + it("applies global flow filters to flow subscriptions on home and tape", () => { + const filters = { + ...buildDefaultFlowFilters(), + minNotional: 50_000 + }; + + const homeFlowSubscription = getLiveManifest("/", "SPY", 60000, filters).find( + (subscription) => subscription.channel === "flow" + ); + const tapeFlowSubscription = getLiveManifest("/tape", "SPY", 60000, filters).find( + (subscription) => subscription.channel === "flow" + ); + + expect(homeFlowSubscription?.filters).toBe(filters); + expect(tapeFlowSubscription?.filters).toBe(filters); }); it("includes scoped option and equity subscriptions", () => { @@ -101,6 +123,15 @@ describe("live manifest", () => { }); }); +describe("terminal navigation", () => { + it("exposes only Home and Tape as top-level destinations", () => { + expect(NAV_ITEMS).toEqual([ + { href: "/", label: "Home" }, + { href: "/tape", label: "Tape" } + ]); + }); +}); + describe("live tape pausable helpers", () => { it("queues new items while paused and flushes them on resume", () => { let state = reducePausableTapeData( diff --git a/apps/web/app/terminal.tsx b/apps/web/app/terminal.tsx index d3fa9c8..6835c73 100644 --- a/apps/web/app/terminal.tsx +++ b/apps/web/app/terminal.tsx @@ -2291,7 +2291,6 @@ export const getLiveManifest = ( if (pathname === "/tape") { return dedupeLiveSubscriptions([ ...baselineSubs, - { channel: "options", filters: flowFilters, ...optionScope }, { channel: "nbbo" }, { channel: "equities", ...equityScope }, { channel: "flow", filters: flowFilters }, @@ -2299,32 +2298,10 @@ export const getLiveManifest = ( ]); } - if (pathname === "/signals") { - return dedupeLiveSubscriptions([ - ...baselineSubs, - { channel: "alerts" }, - { channel: "classifier-hits" }, - { channel: "inferred-dark" } - ]); - } - - if (pathname === "/charts") { - return dedupeLiveSubscriptions([ - ...baselineSubs, - ...chartSubs, - { channel: "classifier-hits" }, - { channel: "inferred-dark" } - ]); - } - - if (pathname === "/replay") { - return baselineSubs; - } - return dedupeLiveSubscriptions([ ...baselineSubs, { channel: "equities", ...equityScope }, - { channel: "flow" }, + { channel: "flow", filters: flowFilters }, { channel: "alerts" }, { channel: "classifier-hits" }, { channel: "inferred-dark" }, @@ -4104,6 +4081,40 @@ const useTerminalState = () => { useEffect(() => { setReplaySource(null); }, [mode]); + + useEffect(() => { + if (!selectedAlert && !selectedClassifierHit && !selectedDarkEvent) { + return; + } + + const dismissDrawers = () => { + setSelectedAlert(null); + setSelectedClassifierHit(null); + setSelectedDarkEvent(null); + }; + + const handlePointerDown = (event: MouseEvent) => { + if ((event.target as Element | null)?.closest(".drawer")) { + return; + } + dismissDrawers(); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + dismissDrawers(); + } + }; + + document.addEventListener("mousedown", handlePointerDown); + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("mousedown", handlePointerDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [selectedAlert, selectedClassifierHit, selectedDarkEvent]); + const optionsScroll = useListScroll(); const equitiesScroll = useListScroll(); const flowScroll = useListScroll(); @@ -5174,13 +5185,10 @@ const useTerminal = (): TerminalState => { return value; }; -const NAV_ITEMS = [ - { href: "/", label: "Overview" }, - { href: "/tape", label: "Tape" }, - { href: "/signals", label: "Signals" }, - { href: "/charts", label: "Charts" }, - { href: "/replay", label: "Replay" } -]; +export const NAV_ITEMS = [ + { href: "/", label: "Home" }, + { href: "/tape", label: "Tape" } +] as const; type PageFrameProps = { title: string; @@ -5221,6 +5229,7 @@ const FlowFilterSection = ({ }; export const FlowFilterPopover = ({ filters, onChange }: FlowFilterPopoverProps) => { + const pathname = usePathname(); const [open, setOpen] = useState(false); const rootRef = useRef(null); const activeCount = countActiveFlowFilterGroups(filters); @@ -5279,6 +5288,10 @@ export const FlowFilterPopover = ({ filters, onChange }: FlowFilterPopoverProps) }; }, [open]); + useEffect(() => { + setOpen(false); + }, [pathname]); + return (
+ + ) : null} +