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:
parent
9d391434e0
commit
71ea453b82
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue