Skip to contentVibraUI

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

Install

npx shadcn@latest add @vibra/widget-finance-balances-upcoming-transactions

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

Source

app/finance/balances/components/upcoming-transactions.tsx
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>
  )
}
app/finance/balances/data.ts
/**
 * 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 }))
}
app/finance/balances/components/balances-vocabulary.ts
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",
}
app/finance/balances/components/counterparty.tsx
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