From 4036765a2297a1c7aa3211ee12eaa6d818271536 Mon Sep 17 00:00:00 2001 From: e35ventura Date: Sat, 13 Jun 2026 14:55:28 -0500 Subject: [PATCH 1/3] feat(dashboard): depth-of-market orderbook synced to chart direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the dashboard layout and wires in the Depth of Market orderbook: - Right column now shows the Depth of Market orderbook; the Transactions / Reservations / Events tabbed panel moves to a full-width row below. - The orderbook drops its own dropdown and is driven by the shared market-rate chart direction, so chart, rates table, and orderbook stay in sync. - The chart's BOTH view is lifted to the page; in BOTH the orderbook overlays both sides on one shared TAO/BTC price axis (asks above, bids below) with a Spread divider, like a traditional depth-of-market book. - Chart direction line colors now match the orderbook: BTC→TAO uses the BTC accent, TAO→BTC uses the TAO accent (both from theme tokens). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../dashboard/AllwaysMarketRate.tsx | 11 +- src/components/dashboard/MarketRateChart.tsx | 4 +- src/components/dashboard/OrderbookDepth.tsx | 622 ++++++++++-------- src/pages/DashboardPage.tsx | 117 ++-- 4 files changed, 413 insertions(+), 341 deletions(-) diff --git a/src/components/dashboard/AllwaysMarketRate.tsx b/src/components/dashboard/AllwaysMarketRate.tsx index 23b5ed5..913613e 100644 --- a/src/components/dashboard/AllwaysMarketRate.tsx +++ b/src/components/dashboard/AllwaysMarketRate.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React from 'react'; import { Box, IconButton, @@ -23,9 +23,10 @@ type View = Direction | 'BOTH'; const AllwaysMarketRate: React.FC<{ direction: Direction; onDirectionChange: (d: Direction) => void; -}> = ({ direction, onDirectionChange }) => { + showBoth: boolean; + onShowBothChange: (b: boolean) => void; +}> = ({ direction, onDirectionChange, showBoth, onShowBothChange }) => { const theme = useTheme(); - const [showBoth, setShowBoth] = useState(false); const view: View = showBoth ? 'BOTH' : direction; return ( @@ -84,9 +85,9 @@ const AllwaysMarketRate: React.FC<{ onChange={(_, v) => { if (!v) return; if (v === 'BOTH') { - setShowBoth(true); + onShowBothChange(true); } else { - setShowBoth(false); + onShowBothChange(false); onDirectionChange(v as Direction); } }} diff --git a/src/components/dashboard/MarketRateChart.tsx b/src/components/dashboard/MarketRateChart.tsx index d742206..ae26781 100644 --- a/src/components/dashboard/MarketRateChart.tsx +++ b/src/components/dashboard/MarketRateChart.tsx @@ -36,8 +36,10 @@ echarts.use([ CanvasRenderer, ]); +// Match the Depth of Market colors: BTC→TAO (bids) = BTC accent, +// TAO→BTC (asks) = TAO accent. const accentFor = (theme: Theme, dir: Direction) => - dir === 'BTC-TAO' ? theme.palette.asset.btc : theme.palette.primary.main; + dir === 'BTC-TAO' ? theme.palette.asset.btc : theme.palette.asset.tao; const labelFor = (dir: Direction) => dir === 'BTC-TAO' ? 'BTC → TAO' : 'TAO → BTC'; diff --git a/src/components/dashboard/OrderbookDepth.tsx b/src/components/dashboard/OrderbookDepth.tsx index b2cc36a..3f128fc 100644 --- a/src/components/dashboard/OrderbookDepth.tsx +++ b/src/components/dashboard/OrderbookDepth.tsx @@ -1,9 +1,7 @@ -import React, { useMemo, useState, useEffect } from 'react'; +import React, { useMemo } from 'react'; import { Box, IconButton, - MenuItem, - Select, Stack, Table, TableBody, @@ -17,10 +15,68 @@ import { } from '@mui/material'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { useMiners } from '../../api'; +import type { Direction } from '../../api/models/MinersDashboard'; +import type { Miner } from '../../api/models/Miners'; import { FONTS } from '../../theme'; import { OrderbookDepthSkeleton } from './Skeletons'; -const OrderbookDepth: React.FC<{ embedded?: boolean }> = ({ embedded }) => { +type Side = 'forward' | 'reverse'; +type DepthRow = { rate: string; capacity: number; cumCapacity: number }; + +const sideLabel = (side: Side) => (side === 'reverse' ? 'TAO → BTC' : 'BTC → TAO'); + +// Cumulative depth for one trade side: group hittable miner collateral by +// quoted rate, best rate first, accumulating capacity. Pure so it can be +// memoized once per side and reused by both the single and BOTH views. +const buildDepth = ( + miners: Miner[] | undefined, + asset: string, + side: Side, +): DepthRow[] => { + if (!miners?.length) return []; + const a = asset.toLowerCase(); + const groups: Record = {}; // key = rate, val = collateral TAO + + miners.forEach((m) => { + // Only miners whose collateral is hittable right now count as depth. + // Inactive miners still have a quote on-chain but no one can take it; + // exchanging miners have their collateral locked in a swap; reserved + // miners have it locked by a pending swap. This panel answers "what rate + // can I actually use right now?". + if (!m.isActive || m.hasActiveSwap || m.isReserved) return; + if (!m.collateralRao) return; + const s = m.sourceChain?.toLowerCase(); + const d = m.destChain?.toLowerCase(); + if (s !== a || d !== 'tao') return; + const capacityTao = parseInt(m.collateralRao, 10) / 1e9; + if (isNaN(capacityTao) || capacityTao <= 0) return; + const raw = side === 'forward' ? m.rate : m.counterRate; + const r = raw ? parseFloat(raw) : 0; + if (!isFinite(r) || r <= 0) return; + const key = r.toFixed(2); + groups[key] = (groups[key] || 0) + capacityTao; + }); + + // Best rate first: forward wants highest TAO/asset, reverse wants lowest. + const rates = Object.keys(groups).sort((x, y) => + side === 'forward' + ? parseFloat(y) - parseFloat(x) + : parseFloat(x) - parseFloat(y), + ); + + let cum = 0; + return rates.map((key) => { + const capacity = groups[key]; + cum += capacity; + return { rate: key, capacity, cumCapacity: cum }; + }); +}; + +const OrderbookDepth: React.FC<{ + embedded?: boolean; + direction?: Direction; + showBoth?: boolean; +}> = ({ embedded, direction = 'BTC-TAO', showBoth = false }) => { const theme = useTheme(); const TAO_COLOR = theme.palette.asset.tao; @@ -50,39 +106,6 @@ const OrderbookDepth: React.FC<{ embedded?: boolean }> = ({ embedded }) => { ); - const AssetIcon = ({ - asset, - size = 16, - }: { - asset: string; - size?: number; - }) => { - if (asset.toUpperCase() === 'BTC') return ; - return ( - - - {asset[0]?.toUpperCase()} - - - ); - }; - const headerSx = { fontFamily: FONTS.mono, fontSize: '0.65rem', @@ -100,104 +123,277 @@ const OrderbookDepth: React.FC<{ embedded?: boolean }> = ({ embedded }) => { }; const { data: miners, isLoading } = useMiners(); - type Direction = 'forward' | 'reverse'; - type DirectionOption = { - asset: string; - direction: Direction; - key: string; - label: string; - }; - const [selectedKey, setSelectedKey] = useState(''); - const directionOptions = useMemo(() => { - const seen = new Map(); - miners?.forEach((m) => { - const s = m.sourceChain?.toLowerCase(); - const d = m.destChain?.toLowerCase(); - if (!s || d !== 'tao' || s === 'tao') return; - const asset = s.toUpperCase(); - const fwd = m.rate ? parseFloat(m.rate) : 0; - const rev = m.counterRate ? parseFloat(m.counterRate) : 0; - if (fwd > 0) { - const key = `${asset}>forward`; - if (!seen.has(key)) - seen.set(key, { - asset, - direction: 'forward', - key, - label: `${asset} → TAO`, - }); - } - if (rev > 0) { - const key = `${asset}>reverse`; - if (!seen.has(key)) - seen.set(key, { - asset, - direction: 'reverse', - key, - label: `TAO → ${asset}`, - }); - } - }); - return Array.from(seen.values()).sort((a, b) => - a.label.localeCompare(b.label), - ); - }, [miners]); + // Driven by the shared dashboard direction so the orderbook mirrors the + // market-rate chart's BTC↔TAO toggle. 'BTC-TAO' → forward quote (m.rate); + // 'TAO-BTC' → reverse quote (m.counterRate). BOTH shows both sides stacked. + const activeSide: Side = direction === 'TAO-BTC' ? 'reverse' : 'forward'; - useEffect(() => { - if (directionOptions.length === 0) return; - if (!directionOptions.find((o) => o.key === selectedKey)) { - setSelectedKey(directionOptions[0].key); - } - }, [directionOptions, selectedKey]); + const forward = useMemo(() => buildDepth(miners, 'BTC', 'forward'), [miners]); + const reverse = useMemo(() => buildDepth(miners, 'BTC', 'reverse'), [miners]); - const selected = directionOptions.find((o) => o.key === selectedKey) ?? null; + // One depth ladder (header label + table) for a given side. Flexes to fill + // its share of the panel and scrolls internally. + const renderLadder = (side: Side, rows: DepthRow[]) => { + const maxCum = rows.reduce( + (m, r) => (r.cumCapacity > m ? r.cumCapacity : m), + 1, + ); + const fillColor = side === 'forward' ? BTC_COLOR : TAO_COLOR; - const depthData = useMemo(() => { - if (!miners?.length || !selected) return []; - const asset = selected.asset.toLowerCase(); - const groups: Record = {}; // key = rate, val = collateral TAO + return ( + + + + + + + + + Rate (TAO) + + + + + + + Capacity (TAO) + + + + + + + {side === 'reverse' ? ( + <> + {'→'} + + ) : ( + <> + {'→'} + + )} + + + + + + + {rows.map((row) => { + const pct = (row.cumCapacity / maxCum) * 100; + const gradColor = `color-mix(in srgb, ${fillColor} 14%, transparent)`; - miners.forEach((m) => { - // Only miners whose collateral is hittable right now count as - // depth. Inactive miners still have a quote on-chain but no one - // can take it; exchanging miners have their collateral locked - // in a swap; reserved miners have it locked by a pending swap. - // This panel answers "what rate can I actually use right now?". - if (!m.isActive || m.hasActiveSwap || m.isReserved) return; - if (!m.collateralRao) return; - const s = m.sourceChain?.toLowerCase(); - const d = m.destChain?.toLowerCase(); - if (s !== asset || d !== 'tao') return; - const capacityTao = parseInt(m.collateralRao, 10) / 1e9; - if (isNaN(capacityTao) || capacityTao <= 0) return; - const raw = selected.direction === 'forward' ? m.rate : m.counterRate; - const r = raw ? parseFloat(raw) : 0; - if (!isFinite(r) || r <= 0) return; - const key = r.toFixed(2); - groups[key] = (groups[key] || 0) + capacityTao; - }); + return ( + + + {row.rate} + + + {row.capacity.toFixed(2)} + + + {row.cumCapacity.toFixed(2)} + + + ); + })} - // Best rate first: forward wants highest TAO/asset, reverse wants lowest. - const rates = Object.keys(groups).sort((a, b) => - selected.direction === 'forward' - ? parseFloat(b) - parseFloat(a) - : parseFloat(a) - parseFloat(b), + {rows.length === 0 && ( + + + No depth data available + + + )} + +
+
+
); + }; - let cum = 0; - return rates.map((key) => { - const capacity = groups[key]; - cum += capacity; - return { rate: key, capacity, cumCapacity: cum }; - }); - }, [miners, selected]); + // BOTH view: overlay the two sides on one shared price (TAO/BTC) axis. + // Reverse quotes are asks (selling BTC) and sit above forward quotes, the + // bids (buying BTC); the gap between best ask and best bid is the spread. + const renderBook = () => { + const asks = reverse; // best (lowest) first, cumulative outward + const bids = forward; // best (highest) first, cumulative outward + const maxCum = Math.max( + asks.length ? asks[asks.length - 1].cumCapacity : 0, + bids.length ? bids[bids.length - 1].cumCapacity : 0, + 1, + ); + const bestAsk = asks.length ? parseFloat(asks[0].rate) : null; + const bestBid = bids.length ? parseFloat(bids[0].rate) : null; + const spread = + bestAsk != null && bestBid != null ? bestAsk - bestBid : null; + const mid = + bestAsk != null && bestBid != null ? (bestAsk + bestBid) / 2 : null; + const spreadPct = spread != null && mid ? (spread / mid) * 100 : null; + const asksDisplay = [...asks].reverse(); // highest price on top - const maxCum = useMemo( - () => - depthData.reduce((m, r) => (r.cumCapacity > m ? r.cumCapacity : m), 1), - [depthData], - ); + const bookRow = (side: Side, r: DepthRow) => { + const pct = (r.cumCapacity / maxCum) * 100; + const fillColor = side === 'forward' ? BTC_COLOR : TAO_COLOR; + const gradColor = `color-mix(in srgb, ${fillColor} 14%, transparent)`; + return ( + + {r.rate} + + {r.capacity.toFixed(2)} + + + {r.cumCapacity.toFixed(2)} + + + ); + }; + + return ( + + + + + Rate (TAO) + + Capacity (TAO) + + + Total (TAO) + + + + + {asksDisplay.map((r) => bookRow('reverse', r))} + + + + {spread != null + ? `Spread ${spread.toFixed(2)} TAO${ + spreadPct != null ? ` (${spreadPct.toFixed(2)}%)` : '' + }` + : 'Spread —'} + + + + {bids.map((r) => bookRow('forward', r))} + + {asks.length === 0 && bids.length === 0 && ( + + + No depth data available + + + )} + +
+
+ ); + }; return isLoading || !miners ? ( @@ -253,168 +449,22 @@ const OrderbookDepth: React.FC<{ embedded?: boolean }> = ({ embedded }) => { )} - {directionOptions.length > 0 && ( - - )} + + {showBoth ? 'BOTH' : sideLabel(activeSide)} + - - - - - - - - Rate (TAO) - - - - - - - Capacity (TAO) - - - - - - - {selected?.direction === 'reverse' ? ( - <> - {'→'} - - ) : selected ? ( - <> - {'→'} - - ) : ( - Cumulative - )} - - - - - - - {depthData.map((row) => { - const pct = (row.cumCapacity / maxCum) * 100; - const isBtc = selected?.asset.toUpperCase() === 'BTC'; - const assetThemeColor = isBtc - ? BTC_COLOR - : theme.palette.primary.main; - const fillColor = - selected?.direction === 'forward' ? assetThemeColor : TAO_COLOR; - const gradColor = `color-mix(in srgb, ${fillColor} 14%, transparent)`; - - return ( - - - {row.rate} - - - {row.capacity.toFixed(2)} - - - {row.cumCapacity.toFixed(2)} - - - ); - })} - - {depthData.length === 0 && ( - - - No depth data available - - - )} - -
-
+ {showBoth + ? renderBook() + : renderLadder(activeSide, activeSide === 'forward' ? forward : reverse)} ); }; diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index 1c20dd5..db9c73c 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -4,6 +4,7 @@ import { AllwaysMarketRate, EventFeed, MinerRatesTable, + OrderbookDepth, RatesTicker, ReservationsTracker, SwapTracker, @@ -24,9 +25,11 @@ const colSx = { } as const; const DashboardPage: React.FC = () => { - // Shared trade direction — the Market Rate toggle drives both the chart and - // the Active Rates table filter. + // Shared trade direction — the Market Rate toggle drives the chart, the + // Active Rates table filter, and the orderbook. `showBoth` is the chart's + // BOTH view, lifted here so the orderbook can show both sides too. const [direction, setDirection] = useState('BTC-TAO'); + const [showBoth, setShowBoth] = useState(false); // Below md the layout stacks into one column — treat as "mobile": lead with // the chart and drop the Events tab. @@ -94,11 +97,13 @@ const DashboardPage: React.FC = () => { - {/* Right column; last on mobile (its list is unbounded): - transactions, reservations, and (desktop only) the event tape. */} + {/* Right column: depth-of-market orderbook — cumulative miner + liquidity available at each rate. Last on mobile. */} { order: { xs: 3, md: 0 }, }} > - - Every transaction in chronological order with its - lifecycle progress: Initiated → Fulfilled → Completed (or - Timed Out). Click a row for the full timeline. - - ), - node: , - }, - { - key: 'reservations', - label: 'Reservations', - info: ( - - Short holds a user places on a miner's quoted rate before - sending funds — locks the rate and prevents others from - claiming the same miner mid-swap. - - ), - node: , - }, - // Events tape is desktop-only — too much for the mobile view. - ...(isStacked - ? [] - : [ - { - key: 'events', - label: 'Events', - info: ( - - Real-time stream of contract and chain events — swap - lifecycle, collateral changes, votes, reservations. - Newest first. - - ), - node: , - }, - ]), - ]} - /> + + + {/* Full-width activity panel below the terminal: transactions, + reservations, and (desktop only) the event tape. */} + + + Every transaction in chronological order with its lifecycle + progress: Initiated → Fulfilled → Completed (or Timed Out). + Click a row for the full timeline. + + ), + node: , + }, + { + key: 'reservations', + label: 'Reservations', + info: ( + + Short holds a user places on a miner's quoted rate before + sending funds — locks the rate and prevents others from + claiming the same miner mid-swap. + + ), + node: , + }, + // Events tape is desktop-only — too much for the mobile view. + ...(isStacked + ? [] + : [ + { + key: 'events', + label: 'Events', + info: ( + + Real-time stream of contract and chain events — swap + lifecycle, collateral changes, votes, reservations. + Newest first. + + ), + node: , + }, + ]), + ]} + /> + ); From 40f7018e07d310549eae834edad5672a839e3e28 Mon Sep 17 00:00:00 2001 From: e35ventura Date: Sat, 13 Jun 2026 15:15:12 -0500 Subject: [PATCH 2/3] feat(dashboard): split activity feeds into 3 columns on desktop Transactions, Reservations, and Events now render as three side-by-side columns on desktop, each with its own mono-eyebrow header + info tooltip. Below the md breakpoint they collapse back into a single toggled TabbedPanel (now including Events in the toggle). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/pages/DashboardPage.tsx | 178 ++++++++++++++++++++++++------------ 1 file changed, 119 insertions(+), 59 deletions(-) diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index db9c73c..25f6b63 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; -import { Box, Stack, useMediaQuery, useTheme } from '@mui/material'; +import { Box, Stack, Tooltip, useMediaQuery, useTheme } from '@mui/material'; +import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { AllwaysMarketRate, EventFeed, @@ -13,6 +14,7 @@ import { SEO, } from '../components'; import type { Direction } from '../api/models/MinersDashboard'; +import { FONTS } from '../theme'; // Card-less column: no surface/border box (cohesive taostats-style); columns // are separated by thin dividers + padding instead. Flex column so children @@ -24,6 +26,57 @@ const colSx = { minWidth: 0, } as const; +// A single titled activity column: a mono-eyebrow header (matching the tab +// labels) over content that fills the remaining height and scrolls internally. +const ColumnPanel: React.FC<{ + label: string; + info?: React.ReactNode; + children: React.ReactNode; +}> = ({ label, info, children }) => ( + + + + {label} + + {info && ( + + + + + + )} + + + {children} + + +); + const DashboardPage: React.FC = () => { // Shared trade direction — the Market Rate toggle drives the chart, the // Active Rates table filter, and the orderbook. `showBoth` is the chart's @@ -32,10 +85,50 @@ const DashboardPage: React.FC = () => { const [showBoth, setShowBoth] = useState(false); // Below md the layout stacks into one column — treat as "mobile": lead with - // the chart and drop the Events tab. + // the chart and collapse the activity columns into a single toggled panel. const theme = useTheme(); const isStacked = useMediaQuery(theme.breakpoints.down('md')); + // Activity panels — rendered as 3 side-by-side columns on desktop and a + // single toggled panel on small screens. + const activityPanels = [ + { + key: 'tx', + label: 'Transactions', + info: ( + + Every transaction in chronological order with its lifecycle progress: + Initiated → Fulfilled → Completed (or Timed Out). Click a row for the + full timeline. + + ), + node: , + }, + { + key: 'reservations', + label: 'Reservations', + info: ( + + Short holds a user places on a miner's quoted rate before sending + funds — locks the rate and prevents others from claiming the same + miner mid-swap. + + ), + node: , + }, + { + key: 'events', + label: 'Events', + info: ( + + Real-time stream of contract and chain events — swap lifecycle, + collateral changes, votes, reservations. Newest first. + + ), + node: , + }, + ]; + return ( { - {/* Full-width activity panel below the terminal: transactions, - reservations, and (desktop only) the event tape. */} - - - Every transaction in chronological order with its lifecycle - progress: Initiated → Fulfilled → Completed (or Timed Out). - Click a row for the full timeline. - - ), - node: , - }, - { - key: 'reservations', - label: 'Reservations', - info: ( - - Short holds a user places on a miner's quoted rate before - sending funds — locks the rate and prevents others from - claiming the same miner mid-swap. - - ), - node: , - }, - // Events tape is desktop-only — too much for the mobile view. - ...(isStacked - ? [] - : [ - { - key: 'events', - label: 'Events', - info: ( - - Real-time stream of contract and chain events — swap - lifecycle, collateral changes, votes, reservations. - Newest first. - - ), - node: , - }, - ]), - ]} - /> - + {/* Activity feeds: 3 side-by-side columns on desktop; a single toggled + panel on small screens. */} + {isStacked ? ( + + + + ) : ( + + {activityPanels.map((p) => ( + + {p.node} + + ))} + + )} ); From fc4e175b9baec35a119cea1ab171d27c3518c4ee Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 13 Jun 2026 20:54:47 +0000 Subject: [PATCH 3/3] style: auto-format code with prettier/eslint --- src/components/dashboard/OrderbookDepth.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/dashboard/OrderbookDepth.tsx b/src/components/dashboard/OrderbookDepth.tsx index 3f128fc..9d59cba 100644 --- a/src/components/dashboard/OrderbookDepth.tsx +++ b/src/components/dashboard/OrderbookDepth.tsx @@ -23,7 +23,8 @@ import { OrderbookDepthSkeleton } from './Skeletons'; type Side = 'forward' | 'reverse'; type DepthRow = { rate: string; capacity: number; cumCapacity: number }; -const sideLabel = (side: Side) => (side === 'reverse' ? 'TAO → BTC' : 'BTC → TAO'); +const sideLabel = (side: Side) => + side === 'reverse' ? 'TAO → BTC' : 'BTC → TAO'; // Cumulative depth for one trade side: group hittable miner collateral by // quoted rate, best rate first, accumulating capacity. Pure so it can be @@ -464,7 +465,10 @@ const OrderbookDepth: React.FC<{ {showBoth ? renderBook() - : renderLadder(activeSide, activeSide === 'forward' ? forward : reverse)} + : renderLadder( + activeSide, + activeSide === 'forward' ? forward : reverse, + )} ); };