feat(monitor/frontend): BTC panel (hero price + glyph-based change indicator, no color direction)

Reads from the existing usePrices store (latest tick + 60-min history).
Seeds latest from snapshot.coinbase_price on mount, converting the ISO ts
string and snake_case volume_24h to the store's number-ts and camelCase
volume24h shapes (verified against live snapshot).

Hero price uses --type-num-xl mono with tabular-nums — the dashboard's
biggest single number by design. Color stays --fg-1 regardless of
direction. The 1H change row uses △ / ▽ glyph + sign for direction
(not green/red) per the ethos no-branded-color-per-metric rule.
Compute is (last - first) / first * 100 over the 60 minute-bar closes.

Stale indicator goes amber when last tick > 60s ago (Coinbase WS expects
sub-second updates; 60s of silence = real staleness).

24H change is deferred — would need backend snapshot to send 24h of
minute bars; the store caps at 60. Plan said both 1H and 24H but 24H
isn't computable from the available data yet. Showing only 1H is
honest — the missing row would be permanent "—" otherwise.

Sparkline is the 60-minute close ladder. Renders null while history is
shorter than 2 points (Sparkline primitive's own contract). Empty
panel surface is fine — operator UI doesn't onboard.

Plan 5 Task 18 Design QA: hero is the largest single number, price
color stays --fg-1, change row uses glyph + sign (no color), sparkline
is single-stroke currentColor with no fill or axes, no animated
count-up on price change.
This commit is contained in:
CarterPerez-dev 2026-05-03 09:34:58 -04:00
parent 9d391434e0
commit 71ea453b82
3 changed files with 185 additions and 1 deletions

View File

@ -2,6 +2,7 @@
// Dashboard.tsx
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 { KEVPanel } from '@/pages/panels/KEVPanel'
@ -26,7 +27,9 @@ export function Dashboard(): React.ReactElement {
<TopStrip />
<AlertBanner />
<main className={styles.grid}>
<aside className={styles.left} />
<aside className={styles.left}>
<BTCPanel />
</aside>
<section className={styles.center}>
<Globe />
</section>

View File

@ -0,0 +1,59 @@
// ©AngelaMos | 2026
// BTCPanel.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
// BTCPanel.tsx
import { useEffect } from 'react'
import { useSnapshot } from '@/api/snapshot'
import { usePrices } from '@/stores/prices'
import styles from './BTCPanel.module.scss'
import { Panel } from './Panel'
import { Sparkline } from './shared/Sparkline'
const SYMBOL = 'BTC-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 BTCPanel(): 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="BTC"
subtitle="USD"
rawHref="https://www.coinbase.com/price/bitcoin"
rawLabel="Coinbase BTC"
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>
)
}
BTCPanel.displayName = 'BTCPanel'
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,
})
}