feat(monitor/frontend): ETH panel (mirror of BTC, separate file pre-abstraction)

Mirrors BTCPanel structure 1:1 with SYMBOL='ETH-USD' and the Coinbase
ethereum price page as the raw link. Per Plan 5 Task 19: don't refactor
into a shared <PricePanel symbol="..." /> until Phase 6 — 50 lines of
duplication beats locking ourselves into a shared contract before BTC
and ETH might diverge in display (gas-fee tile for ETH only, etc.).

Same monochrome discipline: --fg-1 hero price, △/▽ glyph + sign for
direction, no green/red, no animated count-up. 24H change deferred for
the same reason as BTC (60-min history cap can't carry 24h yet).
This commit is contained in:
CarterPerez-dev 2026-05-03 09:35:52 -04:00
parent 71ea453b82
commit b67729ae13
3 changed files with 183 additions and 0 deletions

View File

@ -5,6 +5,7 @@ import { Globe } from '@/pages/globe/Globe'
import { BTCPanel } from '@/pages/panels/BTCPanel'
import { CVEVelocityPanel } from '@/pages/panels/CVEVelocityPanel'
import { DShieldPanel } from '@/pages/panels/DShieldPanel'
import { ETHPanel } from '@/pages/panels/ETHPanel'
import { KEVPanel } from '@/pages/panels/KEVPanel'
import { RansomwarePanel } from '@/pages/panels/RansomwarePanel'
import { useUIStore } from '@/stores/ui'
@ -29,6 +30,7 @@ export function Dashboard(): React.ReactElement {
<main className={styles.grid}>
<aside className={styles.left}>
<BTCPanel />
<ETHPanel />
</aside>
<section className={styles.center}>
<Globe />

View File

@ -0,0 +1,59 @@
// ©AngelaMos | 2026
// ETHPanel.module.scss
.hero {
display: flex;
align-items: baseline;
gap: 6px;
padding: 8px;
}
.price {
font-family: var(--font-mono);
font-size: var(--type-num-xl);
font-variant-numeric: tabular-nums;
color: var(--fg-1);
line-height: 1;
}
.unit {
font-size: var(--type-label);
letter-spacing: var(--letter-spacing-label);
color: var(--fg-3);
text-transform: uppercase;
}
.changes {
padding: 0 8px;
}
.change {
display: grid;
grid-template-columns: 36px auto;
gap: 8px;
align-items: center;
font-family: var(--font-mono);
font-size: var(--type-body);
padding: var(--row-py) 0;
}
.changeLabel {
font-family: var(--font-sans);
font-size: var(--type-label);
letter-spacing: var(--letter-spacing-label);
text-transform: uppercase;
color: var(--fg-3);
}
.changeValue {
color: var(--fg-1);
font-variant-numeric: tabular-nums;
}
.spark {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
color: var(--fg-2);
}

View File

@ -0,0 +1,122 @@
// ©AngelaMos | 2026
// ETHPanel.tsx
import { useEffect } from 'react'
import { useSnapshot } from '@/api/snapshot'
import { usePrices } from '@/stores/prices'
import styles from './ETHPanel.module.scss'
import { Panel } from './Panel'
import { Sparkline } from './shared/Sparkline'
const SYMBOL = 'ETH-USD'
const SPARKLINE_WIDTH = 280
const SPARKLINE_HEIGHT = 22
const PERCENT_DECIMALS = 2
const PRICE_DECIMALS = 2
const STALE_AFTER_MS = 60_000
interface CoinbaseSnapshotTick {
symbol: string
ts: string
price: string
volume_24h?: string
}
export function ETHPanel(): React.ReactElement {
const { data } = useSnapshot()
const latest = usePrices((s) => s.latest[SYMBOL])
const history = usePrices((s) => s.history[SYMBOL])
const pushTick = usePrices((s) => s.pushTick)
const seed = data?.coinbase_price as CoinbaseSnapshotTick | undefined
useEffect(() => {
if (!seed || seed.symbol !== SYMBOL) return
pushTick({
symbol: seed.symbol,
ts: new Date(seed.ts).getTime(),
price: seed.price,
volume24h: seed.volume_24h,
})
}, [seed, pushTick])
const priceNum = latest ? Number(latest.price) : null
const closes = (history ?? []).map((b) => Number(b.close))
const pct1h = computeChangePct(closes)
const lastTickAt = latest?.ts
const isStale =
latest === undefined ? undefined : Date.now() - latest.ts > STALE_AFTER_MS
return (
<Panel
title="ETH"
subtitle="USD"
rawHref="https://www.coinbase.com/price/ethereum"
rawLabel="Coinbase ETH"
isStale={isStale}
lastTickAt={lastTickAt}
>
<div className={styles.hero}>
<span className={styles.price}>{fmtPrice(priceNum)}</span>
<span className={styles.unit}>USD</span>
</div>
<div className={styles.changes}>
<ChangeRow label="1H" pct={pct1h} />
</div>
<div className={styles.spark}>
<Sparkline
data={closes}
width={SPARKLINE_WIDTH}
height={SPARKLINE_HEIGHT}
/>
</div>
</Panel>
)
}
ETHPanel.displayName = 'ETHPanel'
function ChangeRow({
label,
pct,
}: {
label: string
pct: number | null
}): React.ReactElement {
return (
<div className={styles.change}>
<span className={styles.changeLabel}>{label}</span>
<span className={styles.changeValue}>{fmtPct(pct)}</span>
</div>
)
}
function computeChangePct(closes: number[]): number | null {
if (closes.length < 2) return null
const first = closes[0]
const last = closes[closes.length - 1]
if (
first === undefined ||
last === undefined ||
!Number.isFinite(first) ||
!Number.isFinite(last) ||
first === 0
) {
return null
}
return ((last - first) / first) * 100
}
function fmtPct(pct: number | null): string {
if (pct === null) return '—'
const glyph = pct >= 0 ? '△' : '▽'
return `${glyph} ${Math.abs(pct).toFixed(PERCENT_DECIMALS)} %`
}
function fmtPrice(n: number | null): string {
if (n === null || !Number.isFinite(n)) return '—'
return n.toLocaleString(undefined, {
minimumFractionDigits: PRICE_DECIMALS,
maximumFractionDigits: PRICE_DECIMALS,
})
}