Skip to contentVibraUI

Holdings

Every asset in every wallet, largest first, with its wallet, amount, price, the day's move and its value at today's price; reads holdings().

Preview

Install

npx shadcn@latest add @vibra/widget-finance-crypto-holdings-table

Needs the @vibra registry in your components.json — set it up once.

Source

app/finance/crypto/components/holdings-table.tsx
import { formatCurrency, formatNumber } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
import { MetricDelta } from "@/components/ui/metric-delta"
import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"
import { Widget } from "@/components/ui/widget"

import { type HoldingRow } from "../data"

import { AssetInitial } from "./asset-initial"

const money = (cents: number, digits = 0) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: digits })

const COLUMNS: SimpleTableColumn<HoldingRow>[] = [
  {
    key: "asset",
    header: "Asset",
    cell: (row) => (
      <span className="flex items-center gap-3">
        <AssetInitial initial={row.initial} asset={row.asset} />
        <span className="flex min-w-0 flex-col">
          <span className="truncate font-medium">{row.asset}</span>
          <span className="text-xs text-muted-foreground">{row.symbol}</span>
        </span>
      </span>
    ),
  },
  {
    key: "wallet",
    header: "Wallet",
    cell: (row) => <Badge variant="outline">{row.wallet}</Badge>,
  },
  {
    key: "amount",
    header: "Amount",
    align: "right",
    cell: (row) => `${formatNumber(row.amount, { maximumFractionDigits: 6 })} ${row.symbol}`,
  },
  {
    key: "priceCents",
    header: "Price",
    align: "right",
    cell: (row) => money(row.priceCents, 2),
  },
  {
    key: "change24h",
    header: "24h",
    align: "right",
    cell: (row) => <MetricDelta value={row.change24h} size="sm" />,
  },
  {
    key: "valueCents",
    header: "Value",
    align: "right",
    className: "font-medium",
    cell: (row) => money(row.valueCents),
  },
]

/** Every holding in every wallet, largest first — the rows the wallet card adds up. */
export function HoldingsTable({ rows }: { rows: HoldingRow[] }) {
  return (
    <Widget
      data-widget="widget-finance-crypto-holdings-table"
      title="Holdings"
      description="Every asset in every wallet, largest first"
      contentClassName="px-0"
      footer="Value is the amount at today's price; a price is quoted once per asset, whichever wallet holds it."
    >
      <SimpleTable columns={COLUMNS} rows={rows} rowKey="id" size="sm" hoverable />
    </Widget>
  )
}
app/finance/crypto/data.ts
/**
 * 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, 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()]
}

/** 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 }
}
app/finance/crypto/components/asset-initial.tsx
/** The letter that stands for an asset, in an ink circle, in place of the coin's mark. */
export function AssetInitial({ initial, asset }: { initial: string; asset: string }) {
  return (
    <span
      data-slot="asset-initial"
      aria-hidden="true"
      title={asset}
      className="flex size-8 shrink-0 items-center justify-center rounded-full bg-foreground text-sm font-semibold text-background"
    >
      {initial}
    </span>
  )
}

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