Balances by currency
A card per currency the store holds — the balance, what of it is available and what is pending, and its move on thirty days ago; reads balanceCards().
Preview
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type BalanceCard } from "../data"
import { CURRENCY_NAMES, money, signed } from "./balances-vocabulary"
/**
* One card per currency the store holds: the balance, what of it is available
* and what is still pending under it, and a pill against where it stood
* thirty days ago. The currency's code is set in the mono face, as every code.
*/
export function BalanceCards({ cards }: { cards: BalanceCard[] }) {
return (
<StatCardGroup data-widget="widget-finance-balances-balance-cards" columns={4}>
{cards.map((card) => (
<StatCard
key={card.currency}
data-currency={card.currency}
label={
<span className="flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground">{card.currency}</span>
{CURRENCY_NAMES[card.currency]}
</span>
}
value={money(card.balance, card.currency)}
delta={card.delta}
description={`${money(card.available, card.currency)} available · ${signed(card.pending, card.currency)} pending`}
/>
))}
</StatCardGroup>
)
}Install
$
npx shadcn@latest add @vibra/widget-finance-balances-balance-cardsNeeds the @vibra registry in your components.json — set it up once.
Source
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { type BalanceCard } from "../data"
import { CURRENCY_NAMES, money, signed } from "./balances-vocabulary"
/**
* One card per currency the store holds: the balance, what of it is available
* and what is still pending under it, and a pill against where it stood
* thirty days ago. The currency's code is set in the mono face, as every code.
*/
export function BalanceCards({ cards }: { cards: BalanceCard[] }) {
return (
<StatCardGroup data-widget="widget-finance-balances-balance-cards" columns={4}>
{cards.map((card) => (
<StatCard
key={card.currency}
data-currency={card.currency}
label={
<span className="flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground">{card.currency}</span>
{CURRENCY_NAMES[card.currency]}
</span>
}
value={money(card.balance, card.currency)}
delta={card.delta}
description={`${money(card.available, card.currency)} available · ${signed(card.pending, card.currency)} pending`}
/>
))}
</StatCardGroup>
)
}/**
* What the balances page reads. A balance is never typed in: it is the
* ledger's opening entry for the currency plus every completed row of
* `db.transactions` in it. Pending rows have not settled, so they are not in
* the balance — they are said beside it — and what is available is the
* balance less the money already on its way out. Rates are `db.fxRates`; a
* conversion between two currencies goes through the dollar, so the page's
* figures and its converter can never disagree. "Now" is `REFERENCE_DATE`.
*/
import { formatCurrency, formatDate, getInitials } from "@/lib/format"
import {
db,
REFERENCE_DATE,
type Currency,
type FxPair,
type FxPoint,
type Member,
type Transaction,
} from "@/lib/sample-data"
const DAY_MS = 86_400_000
/**
* The ledger's opening entry per currency, in minor units — cents, pence and
* whole yen — as it stood before the first row `db.transactions` holds. The
* one figure on the page that is not a row: every balance is this plus what
* has completed since.
*/
export const OPENING_BALANCES: Record<Currency, number> = {
USD: 14_260_000,
EUR: 5_840_000,
GBP: 3_675_000,
JPY: 6_400_000,
}
/** The currencies in the order the cards stand. */
const CURRENCIES: Currency[] = ["USD", "EUR", "GBP", "JPY"]
/** Minor units in a major one: a hundred cents, or one yen. */
export const MINOR: Record<Currency, number> = { USD: 100, EUR: 100, GBP: 100, JPY: 1 }
export type BalanceCard = {
currency: Currency
/** Opening plus every completed row, in minor units. */
balance: number
/** The balance less the payouts, refunds and fees already pending — what can still be spent. */
available: number
/** Every pending row, signed: money that has not landed yet, or has not left yet. */
pending: number
/** The change on thirty days ago, as a ratio; absent when there was nothing then to measure against. */
delta?: number
}
const completed = (row: Transaction) => row.status === "completed"
const sum = (rows: Transaction[]) => rows.reduce((total, row) => total + row.amountCents, 0)
/** One card per currency, each computed from the ledger. */
export function balanceCards(): BalanceCard[] {
const ledger = db.transactions.all()
const monthAgo = REFERENCE_DATE.getTime() - 30 * DAY_MS
return CURRENCIES.map((currency) => {
const rows = ledger.filter((row) => row.currency === currency)
const balance = OPENING_BALANCES[currency] + sum(rows.filter(completed))
const then = OPENING_BALANCES[currency] + sum(rows.filter((row) => completed(row) && row.at.getTime() <= monthAgo))
const pending = rows.filter((row) => row.status === "pending")
return {
currency,
balance,
available: balance + sum(pending.filter((row) => row.amountCents < 0)),
pending: sum(pending),
...(then === 0 ? {} : { delta: balance / then - 1 }),
}
})
}
/** The last eight rows that settled or failed, newest first. */
export function latestTransactions(): Transaction[] {
return db.transactions
.all()
.filter((row) => row.status === "completed" || row.status === "failed")
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 8)
}
/** A scheduled row still ahead, and how many days off it is. */
export type UpcomingRow = Transaction & { daysAway: number }
/** Every scheduled row after "now", soonest first. */
export function upcomingTransactions(): UpcomingRow[] {
const now = REFERENCE_DATE.getTime()
return db.transactions
.all()
.filter((row) => row.status === "scheduled" && row.at.getTime() > now)
.sort((a, b) => a.at.getTime() - b.at.getTime())
.map((row) => ({ ...row, daysAway: Math.ceil((row.at.getTime() - now) / DAY_MS) }))
}
/** Every pair's year of daily rates, for the chart. */
export function rateSeries(): Record<FxPair, FxPoint[]> {
return db.fxRates
}
/** Dollars per unit of each currency the converter offers, from the latest rates. */
export function dollarRates(): Record<string, number> {
const latest = (pair: FxPair) => db.fxRates[pair][db.fxRates[pair].length - 1].rate
return {
USD: 1,
EUR: latest("EUR/USD"),
GBP: latest("GBP/USD"),
JPY: 1 / latest("USD/JPY"),
CHF: 1 / latest("USD/CHF"),
}
}
/** Every balance in dollars at the latest rates, for the line under the title. */
export function totalInDollars(): string {
const rates = dollarRates()
const total = balanceCards().reduce(
(sum, card) => sum + (card.balance / MINOR[card.currency]) * rates[card.currency],
0
)
return formatCurrency(total, "USD", { maximumFractionDigits: 0 })
}
/** The freshness line under the title, measured against REFERENCE_DATE. */
export function lastUpdated(): string {
return `Synced ${formatDate(REFERENCE_DATE, "medium", { timeZone: "UTC" })}`
}
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 }
}
/** 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 }))
}import { ArrowLeftRightIcon, LandmarkIcon, ReceiptIcon, type LucideIcon } from "lucide-react"
import { formatCurrency } from "@/lib/format"
import { type StatusVariant } from "@/components/ui/status-badge"
/**
* The words, marks and money formatting this page uses. Vocabulary, not
* data: it lives beside the islands that print it, because `data.ts` reads
* `db` and must never reach the browser.
*/
export const CURRENCY_NAMES: Record<string, string> = {
USD: "US dollar",
EUR: "Euro",
GBP: "British pound",
JPY: "Japanese yen",
CHF: "Swiss franc",
}
/** Minor units in a major one: a hundred cents, or one yen — which has no minor unit. */
const MINOR: Record<string, number> = { USD: 100, EUR: 100, GBP: 100, JPY: 1, CHF: 100 }
/** An amount held in minor units, in its own currency: 1234567 USD cents → "$12,345.67"; 480000 yen → "¥480,000". */
export function money(minor: number, currency: string): string {
return formatCurrency(minor / (MINOR[currency] ?? 100), currency)
}
/** A movement with its direction: "+$1.00" in, "−$1.00" out (a true minus), "$0.00" for none. */
export function signed(minor: number, currency: string): string {
if (minor === 0) return money(0, currency)
return `${minor < 0 ? "−" : "+"}${money(Math.abs(minor), currency)}`
}
/**
* A rate as a desk quotes it: to four places, or to two when it is a price
* in yen — 144.21 yen to the dollar — and to four figures when a single yen
* is the unit, which two places would round away to nothing.
*/
export function quoteRate(rate: number, base: string, quote: string): string {
if (quote === "JPY") return rate.toFixed(2)
if (base === "JPY") return rate.toPrecision(4)
return rate.toFixed(4)
}
export const KIND_LABELS: Record<string, string> = {
charge: "Charge",
payout: "Payout",
refund: "Refund",
transfer: "Transfer",
fee: "Fee",
}
/** The glyph for a counterparty that is not a person: a bank for a payout, a receipt for a fee, arrows for a transfer. */
export const KIND_ICONS: Record<string, LucideIcon> = {
payout: LandmarkIcon,
fee: ReceiptIcon,
transfer: ArrowLeftRightIcon,
}
export const STATUS_LABELS: Record<string, string> = {
completed: "Completed",
failed: "Failed",
pending: "Pending",
scheduled: "Scheduled",
}
export const STATUS_MAP: Record<string, StatusVariant> = {
completed: "success",
failed: "danger",
pending: "warning",
scheduled: "primary",
}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 Balances page