Price
Sixty days of a coin's price as daily candles over their volume, with a switch between the coins the wallets hold and a readout of the day under the pointer; reads candles().
Preview
"use client"
import * as React from "react"
import { formatNumber } from "@/lib/format"
import { CandlestickChart } from "@/components/ui/candlestick-chart"
import { ChartCard } from "@/components/ui/chart-card"
import { SegmentedControl } from "@/components/ui/segmented-control"
import { type Candle, type Coin } from "../data"
// One date in one format, pinned to UTC, reads the same on the server and in the browser.
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
const day = (date: string) => DAY.format(new Date(`${date}T00:00:00Z`))
/**
* A coin's prices in dollars: whole dollars for a coin at $100 or more, where
* cents only lengthen the axis, and cents below it — the stablecoin keeps its
* $1.00. One format for the axis, the readout, the summary and the rows,
* written from plain numbers: ICU's currency format is not the same string in
* Node and in every browser, and this card is drawn in both.
*/
function dollars(price: number) {
const digits = price >= 100 ? 0 : 2
return (value: number) => `$${formatNumber(value, { minimumFractionDigits: digits, maximumFractionDigits: digits })}`
}
/**
* A day's volume in the coin's own units, shortened by hand for the same
* reason, to the tenth: the unit is the one the volume would not print a
* thousand of in the unit below, so 999,960 is 1M, not 1,000K.
*/
function units(symbol: string) {
return (value: number) => {
const [scaled, suffix] =
value >= 999_950_000
? [value / 1e9, "B"]
: value >= 999_950
? [value / 1e6, "M"]
: value >= 999.95
? [value / 1e3, "K"]
: [value, ""]
return `${formatNumber(scaled, { maximumFractionDigits: 1 })}${suffix} ${symbol}`
}
}
export type PriceChartProps = {
coins: Coin[]
/** Every coin's days, by symbol, so switching coin draws from what the server already read. */
candles: Record<string, Candle[]>
}
/** A coin's price a candle a day, with a switch between the coins the wallets hold. */
export function PriceChart({ coins, candles }: PriceChartProps) {
const [symbol, setSymbol] = React.useState(coins[0]?.symbol ?? "")
const coin = coins.find((entry) => entry.symbol === symbol) ?? coins[0]
const days = candles[symbol] ?? []
const formats = React.useMemo(
() => ({ price: dollars((coin?.priceCents ?? 0) / 100), volume: units(symbol) }),
[coin?.priceCents, symbol]
)
return (
<ChartCard
data-widget="widget-finance-crypto-price-chart"
title="Price"
description={coin ? `${coin.asset}, a candle a day for the last ${days.length} days` : undefined}
height={332}
className="h-full"
>
<div className="flex flex-col gap-4">
<SegmentedControl
// The trade form's own picker is "Coin": a name of its own, so a
// screen reader can tell the two apart.
aria-label="Coin to chart"
size="sm"
options={coins.map((entry) => ({ value: entry.symbol, label: entry.symbol }))}
value={symbol}
onValueChange={setSymbol}
/>
<CandlestickChart
data={days}
label={coin ? `${coin.asset} price` : "Price"}
height={280}
valueFormatter={formats.price}
volumeFormatter={formats.volume}
indexFormatter={day}
/>
</div>
</ChartCard>
)
}Install
$
npx shadcn@latest add @vibra/widget-finance-crypto-price-chartNeeds the @vibra registry in your components.json — set it up once.
Source
"use client"
import * as React from "react"
import { formatNumber } from "@/lib/format"
import { CandlestickChart } from "@/components/ui/candlestick-chart"
import { ChartCard } from "@/components/ui/chart-card"
import { SegmentedControl } from "@/components/ui/segmented-control"
import { type Candle, type Coin } from "../data"
// One date in one format, pinned to UTC, reads the same on the server and in the browser.
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
const day = (date: string) => DAY.format(new Date(`${date}T00:00:00Z`))
/**
* A coin's prices in dollars: whole dollars for a coin at $100 or more, where
* cents only lengthen the axis, and cents below it — the stablecoin keeps its
* $1.00. One format for the axis, the readout, the summary and the rows,
* written from plain numbers: ICU's currency format is not the same string in
* Node and in every browser, and this card is drawn in both.
*/
function dollars(price: number) {
const digits = price >= 100 ? 0 : 2
return (value: number) => `$${formatNumber(value, { minimumFractionDigits: digits, maximumFractionDigits: digits })}`
}
/**
* A day's volume in the coin's own units, shortened by hand for the same
* reason, to the tenth: the unit is the one the volume would not print a
* thousand of in the unit below, so 999,960 is 1M, not 1,000K.
*/
function units(symbol: string) {
return (value: number) => {
const [scaled, suffix] =
value >= 999_950_000
? [value / 1e9, "B"]
: value >= 999_950
? [value / 1e6, "M"]
: value >= 999.95
? [value / 1e3, "K"]
: [value, ""]
return `${formatNumber(scaled, { maximumFractionDigits: 1 })}${suffix} ${symbol}`
}
}
export type PriceChartProps = {
coins: Coin[]
/** Every coin's days, by symbol, so switching coin draws from what the server already read. */
candles: Record<string, Candle[]>
}
/** A coin's price a candle a day, with a switch between the coins the wallets hold. */
export function PriceChart({ coins, candles }: PriceChartProps) {
const [symbol, setSymbol] = React.useState(coins[0]?.symbol ?? "")
const coin = coins.find((entry) => entry.symbol === symbol) ?? coins[0]
const days = candles[symbol] ?? []
const formats = React.useMemo(
() => ({ price: dollars((coin?.priceCents ?? 0) / 100), volume: units(symbol) }),
[coin?.priceCents, symbol]
)
return (
<ChartCard
data-widget="widget-finance-crypto-price-chart"
title="Price"
description={coin ? `${coin.asset}, a candle a day for the last ${days.length} days` : undefined}
height={332}
className="h-full"
>
<div className="flex flex-col gap-4">
<SegmentedControl
// The trade form's own picker is "Coin": a name of its own, so a
// screen reader can tell the two apart.
aria-label="Coin to chart"
size="sm"
options={coins.map((entry) => ({ value: entry.symbol, label: entry.symbol }))}
value={symbol}
onValueChange={setSymbol}
/>
<CandlestickChart
data={days}
label={coin ? `${coin.asset} price` : "Price"}
height={280}
valueFormatter={formats.price}
volumeFormatter={formats.volume}
indexFormatter={day}
/>
</div>
</ChartCard>
)
}/**
* What this page reads. Every holding comes from `db.holdings` and every
* movement from `db.trades`; the balance, the wallet groups, the three
* totals and the seven-month flow are rules over those rows, so nothing here
* is a literal. "Now" is `REFERENCE_DATE`.
*
* The client islands import only the types below. The words the page puts on
* a trade's kind live beside them in `components/crypto-vocabulary.ts`, so
* nothing that reaches `db` is ever pulled into the browser.
*/
import { getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, seeded, type Holding, type Member, type Trade } from "@/lib/sample-data"
export type TradeKind = Trade["kind"]
export type Wallet = Holding["wallet"]
/** One holding as the holdings table prints it. */
export type HoldingRow = {
id: string
asset: string
symbol: string
/** The letter in the tinted circle, in place of the coin's mark. */
initial: string
amount: number
priceCents: number
change24h: number
wallet: Wallet
/** What the holding is worth now, in cents. */
valueCents: number
}
/** One movement as the activity list prints it. Exported because the order returns one. */
export type TradeRow = {
id: string
asset: string
symbol: string
initial: string
kind: TradeKind
amount: number
priceCents: number
valueCents: number
at: Date
status: Trade["status"]
}
// Read per call, never held at module scope: a holding bought into since the
// server started names its trades on the next render.
const assetsBySymbol = () => new Map(db.holdings.all().map((row) => [row.symbol, row.asset]))
const initialOf = (asset: string) => asset.charAt(0).toUpperCase()
export function toHoldingRow(row: Holding): HoldingRow {
return {
id: row.id,
asset: row.asset,
symbol: row.symbol,
initial: initialOf(row.asset),
amount: row.amount,
priceCents: row.priceCents,
change24h: row.change24h,
wallet: row.wallet,
valueCents: Math.round(row.amount * row.priceCents),
}
}
export function toTradeRow(row: Trade, assets = assetsBySymbol()): TradeRow {
const asset = assets.get(row.symbol) ?? row.symbol
return {
id: row.id,
asset,
symbol: row.symbol,
initial: initialOf(asset),
kind: row.kind,
amount: row.amount,
priceCents: row.priceCents,
valueCents: Math.round(row.amount * row.priceCents),
at: row.at,
status: row.status,
}
}
export type Overview = {
transactions: number
wallets: number
balanceCents: number
/** What the last day's prices did to the balance, in cents: each holding's value times its own move. */
move24hCents: number
/** The same move as a share of the balance — the last day's change, weighted by value. */
change24h: number
}
/** The headline card: how many movements, how many wallets, and what it all adds up to. */
export function overview(): Overview {
const holdings = db.holdings.all()
const balanceCents = holdings.reduce((sum, row) => sum + row.amount * row.priceCents, 0)
const move24hCents = holdings.reduce((sum, row) => sum + row.amount * row.priceCents * row.change24h, 0)
return {
transactions: db.trades.all().length,
wallets: new Set(holdings.map((row) => row.wallet)).size,
balanceCents,
move24hCents,
change24h: balanceCents === 0 ? 0 : move24hCents / balanceCents,
}
}
export type WalletGroup = { wallet: Wallet; rows: HoldingRow[]; totalCents: number }
const WALLET_ORDER: Wallet[] = ["Main", "Savings", "Trading"]
/** Every holding under its wallet, the largest holding first inside each. */
export function walletGroups(): WalletGroup[] {
const rows = db.holdings.all().map(toHoldingRow)
return WALLET_ORDER.filter((wallet) => rows.some((row) => row.wallet === wallet)).map((wallet) => {
const inWallet = rows.filter((row) => row.wallet === wallet).sort((a, b) => b.valueCents - a.valueCents)
return { wallet, rows: inWallet, totalCents: inWallet.reduce((sum, row) => sum + row.valueCents, 0) }
})
}
export type WalletSummary = {
wallet: Wallet
/** How many assets the wallet holds. */
assets: number
totalCents: number
/** The wallet's last-day move, weighted by value. */
change24h: number
/** The largest holding in the wallet, and its share of the wallet. */
top: { asset: string; share: number }
}
/** Each wallet on one line: what it holds, what that is worth, and how the day treated it. */
export function walletSummaries(): WalletSummary[] {
return walletGroups().map((group) => {
const [top] = group.rows
const weighted = group.rows.reduce((sum, row) => sum + row.valueCents * row.change24h, 0)
return {
wallet: group.wallet,
assets: group.rows.length,
totalCents: group.totalCents,
change24h: group.totalCents === 0 ? 0 : weighted / group.totalCents,
top: {
asset: top?.asset ?? "",
share: top && group.totalCents > 0 ? top.valueCents / group.totalCents : 0,
},
}
})
}
/** Every holding in every wallet, the largest first — the table under the desk. */
export function holdings(): HoldingRow[] {
return db.holdings
.all()
.map(toHoldingRow)
.sort((a, b) => b.valueCents - a.valueCents)
}
export type Coin = { symbol: string; asset: string; priceCents: number }
/** The assets the trade form offers: one entry per symbol, at today's price. */
export function coins(): Coin[] {
const seen = new Map<string, Coin>()
for (const row of db.holdings.all()) {
if (!seen.has(row.symbol)) seen.set(row.symbol, { symbol: row.symbol, asset: row.asset, priceCents: row.priceCents })
}
return [...seen.values()]
}
/**
* One day of a coin's price, in dollars, the way `CandlestickChart` reads it.
* Written out rather than imported from the chart: every card of the page
* installs this file, and an import of the chart's types would install the
* chart with each.
*/
export type Candle = { date: string; open: number; high: number; low: number; close: number; volume: number }
/** How many days of price the card draws. */
const CANDLE_DAYS = 60
/**
* How far each coin moves in a typical day, as a share of its price: the
* largest coin least, the smallest most. The stablecoin is held at a dollar
* (`holdings.ts`): its wobble is well under the cent a price is kept to, so
* every day of it reads $1.00. A coin not listed moves like the smallest.
*/
const DAILY_VOLATILITY: Record<string, number> = {
BTC: 0.025,
ETH: 0.035,
SOL: 0.045,
AVAX: 0.05,
TON: 0.06,
USDT: 0.0004,
}
const SMALLEST_VOLATILITY = 0.06
/** A coin's price is kept to the cent. */
const toCents = (value: number) => Math.round(value * 100) / 100
/** A standard normal draw, from two of the stream's uniforms (Box–Muller). */
function normal(rand: () => number): number {
return Math.sqrt(-2 * Math.log(1 - rand())) * Math.cos(2 * Math.PI * rand())
}
/** What a day turns over in dollars, for a coin at a price: the larger the coin, the more. */
const turnover = (price: number) => 4_000_000 * Math.sqrt(price)
/**
* Sixty days of each coin's price, oldest first. No row records a price
* history, so each coin's is a walk drawn backwards from the price it trades
* at today, from a stream of its own (`finance-crypto:candles:<symbol>`): the
* last day closes at today's price and opens where the holding's own 24-hour
* move says it did, and each day before closes where the next one opened,
* moved by the coin's daily volatility. Each day's high and low reach past
* its body, and its volume swells on the days that moved most. The prices
* are read per request and the draws are the same every time, so a trade
* that moves a price redraws the walk from the new one.
*/
export function candles(): Record<string, Candle[]> {
const today = Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth(), REFERENCE_DATE.getUTCDate())
const moves = new Map(db.holdings.all().map((row) => [row.symbol, row.change24h]))
return Object.fromEntries(
coins().map((coin) => {
const rand = seeded(`finance-crypto:candles:${coin.symbol}`)
const volatility = DAILY_VOLATILITY[coin.symbol] ?? SMALLEST_VOLATILITY
const days: Candle[] = []
let close = coin.priceCents / 100
for (let back = 0; back < CANDLE_DAYS; back++) {
const move = back === 0 ? Math.log(1 + (moves.get(coin.symbol) ?? 0)) : volatility * normal(rand)
const open = toCents(close / Math.exp(move))
const reach = volatility * 0.6
const high = toCents(Math.max(open, close) * (1 + reach * rand()))
const low = toCents(Math.min(open, close) * (1 - reach * rand()))
const swing = Math.abs(Math.log(close / open)) / volatility
const volume = Math.round((turnover(close) / close) * (0.6 + 0.8 * rand()) * (1 + 0.3 * swing))
days.unshift({
date: new Date(today - back * 86_400_000).toISOString().slice(0, 10),
open,
high,
low,
close,
volume,
})
close = open
}
return [coin.symbol, days]
})
)
}
/** Every movement, newest first. */
export function recentTrades(): TradeRow[] {
const assets = assetsBySymbol()
return db.trades
.all()
.sort((a, b) => b.at.getTime() - a.at.getTime())
.map((row) => toTradeRow(row, assets))
}
export type BalanceSummary = { receivedCents: number; sentCents: number; withdrawnCents: number }
const valueOf = (rows: Trade[], kind: TradeKind) =>
rows.filter((row) => row.kind === kind).reduce((sum, row) => sum + row.amount * row.priceCents, 0)
/** What came in from other wallets, what went out to them, and what was sold for cash. */
export function balanceSummary(): BalanceSummary {
const rows = db.trades.all()
return {
receivedCents: valueOf(rows, "receive"),
sentCents: valueOf(rows, "send"),
withdrawnCents: valueOf(rows, "sell"),
}
}
const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" })
/** The first of the month `back` months before the reference month. */
function monthStart(back: number): Date {
return new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() - back, 1))
}
export type FlowPoint = { month: string; received: number; sent: number; withdrawn: number }
/** The three flows by month over the last seven months, in dollars, oldest first. */
export function flowByMonth(): FlowPoint[] {
const rows = db.trades.all()
return Array.from({ length: 7 }, (_, index) => 6 - index).map((back) => {
const start = monthStart(back)
const end = monthStart(back - 1)
const inMonth = rows.filter((row) => row.at >= start && row.at < end)
return {
month: MONTH.format(start),
received: Math.round(valueOf(inMonth, "receive") / 100),
sent: Math.round(valueOf(inMonth, "send") / 100),
withdrawn: Math.round(valueOf(inMonth, "sell") / 100),
}
})
}
/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
return db.notifications
.all()
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 6)
.map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
function ownerRow(): Member {
return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}
/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
const owner = ownerRow()
return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}Its page
On its page the card sits among the rest of the dashboard and shares its range and its data with them.
From the Crypto dashboard page