Upcoming transactions
What is booked ahead, soonest first, each with who it is to or from, its kind and date, and how many days off it is; reads upcomingTransactions().
Preview
import { cn } from "@/lib/utils"
import { formatDate } from "@/lib/format"
import { Widget } from "@/components/ui/widget"
import { type UpcomingRow } from "../data"
import { KIND_LABELS, signed } from "./balances-vocabulary"
import { Counterparty } from "./counterparty"
/**
* What is booked ahead, soonest first, each with how far off it is — the
* payouts and transfers the balances above are about to feel.
*/
export function UpcomingTransactions({ rows }: { rows: UpcomingRow[] }) {
return (
<Widget
data-widget="widget-finance-balances-upcoming-transactions"
title="Upcoming"
description="Booked ahead, soonest first"
className="h-full"
>
{rows.length === 0 ? (
<p className="text-sm text-muted-foreground">Nothing is booked ahead.</p>
) : (
<ul aria-label="Upcoming transactions" className="flex flex-col">
{rows.map((row) => (
<li key={row.id} className="flex items-center justify-between gap-3 border-b py-2.5 first:pt-0 last:border-b-0 last:pb-0">
<Counterparty
row={row}
detail={`${KIND_LABELS[row.kind]} · ${formatDate(row.at, "medium", { timeZone: "UTC" })}`}
className="min-w-0"
/>
<div className="flex shrink-0 flex-col items-end">
<span className={cn("text-sm tabular-nums", row.amountCents > 0 && "text-success")}>
{signed(row.amountCents, row.currency)}
</span>
<span className="text-xs text-muted-foreground tabular-nums">
in {row.daysAway} day{row.daysAway === 1 ? "" : "s"}
</span>
</div>
</li>
))}
</ul>
)}
</Widget>
)
}Install
$
npx shadcn@latest add @vibra/widget-finance-balances-upcoming-transactionsNeeds the @vibra registry in your components.json — set it up once.
Source
import { cn } from "@/lib/utils"
import { formatDate } from "@/lib/format"
import { Widget } from "@/components/ui/widget"
import { type UpcomingRow } from "../data"
import { KIND_LABELS, signed } from "./balances-vocabulary"
import { Counterparty } from "./counterparty"
/**
* What is booked ahead, soonest first, each with how far off it is — the
* payouts and transfers the balances above are about to feel.
*/
export function UpcomingTransactions({ rows }: { rows: UpcomingRow[] }) {
return (
<Widget
data-widget="widget-finance-balances-upcoming-transactions"
title="Upcoming"
description="Booked ahead, soonest first"
className="h-full"
>
{rows.length === 0 ? (
<p className="text-sm text-muted-foreground">Nothing is booked ahead.</p>
) : (
<ul aria-label="Upcoming transactions" className="flex flex-col">
{rows.map((row) => (
<li key={row.id} className="flex items-center justify-between gap-3 border-b py-2.5 first:pt-0 last:border-b-0 last:pb-0">
<Counterparty
row={row}
detail={`${KIND_LABELS[row.kind]} · ${formatDate(row.at, "medium", { timeZone: "UTC" })}`}
className="min-w-0"
/>
<div className="flex shrink-0 flex-col items-end">
<span className={cn("text-sm tabular-nums", row.amountCents > 0 && "text-success")}>
{signed(row.amountCents, row.currency)}
</span>
<span className="text-xs text-muted-foreground tabular-nums">
in {row.daysAway} day{row.daysAway === 1 ? "" : "s"}
</span>
</div>
</li>
))}
</ul>
)}
</Widget>
)
}/**
* 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",
}import type * as React from "react"
import { cn } from "@/lib/utils"
import { type Transaction } from "@/lib/sample-data"
import { UserCell } from "@/components/ui/user-cell"
import { KIND_ICONS, KIND_LABELS } from "./balances-vocabulary"
/**
* Who a row's money came from or went to. A customer is drawn with their
* face; a bank account, a fee or another balance with its kind's glyph on a
* quiet tile, because a bank has no face and inventing one would be a lie.
*/
export function Counterparty({
row,
detail,
className,
}: {
row: Transaction
/** The quieter line under the name; the row's kind when left out. */
detail?: React.ReactNode
className?: string
}) {
const description = detail ?? KIND_LABELS[row.kind]
if (row.avatarUrl) {
return <UserCell size="sm" name={row.counterparty} description={description} src={row.avatarUrl} className={className} />
}
const Icon = KIND_ICONS[row.kind] ?? KIND_ICONS.fee
return (
<div className={cn("flex min-w-0 items-center gap-2", className)}>
<span
data-slot="kind-glyph"
aria-hidden="true"
className="grid size-6 shrink-0 place-items-center rounded-full bg-secondary text-muted-foreground [&_svg]:size-3.5"
>
<Icon />
</span>
<span className="flex min-w-0 flex-col leading-tight">
<span className="truncate text-xs font-medium">{row.counterparty}</span>
<span className="truncate text-2xs text-muted-foreground">{description}</span>
</span>
</div>
)
}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