Exchange rates
One pair's closing rate over a week, a month, three months or a year, the latest rate ruled across the plot and the move over the period; reads rateSeries().
Preview
"use client"
import * as React from "react"
import { formatDate } from "@/lib/format"
import { type FxPair, type FxPoint } from "@/lib/sample-data"
import { ChartTimeRange } from "@/components/ui/chart-time-range"
import type { ChartSeries } from "@/components/ui/chart-core"
import { LineChart } from "@/components/ui/line-chart"
import { MetricDelta } from "@/components/ui/metric-delta"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { Widget } from "@/components/ui/widget"
import { quoteRate } from "./balances-vocabulary"
const PAIRS: FxPair[] = ["EUR/USD", "GBP/USD", "USD/JPY", "USD/CHF", "EUR/GBP"]
/** The periods the chips offer, each the number of daily points it draws. */
const PERIODS = [
{ value: "1w", label: "1W", days: 7, span: "the last week" },
{ value: "1m", label: "1M", days: 30, span: "the last month" },
{ value: "3m", label: "3M", days: 90, span: "the last three months" },
{ value: "1y", label: "1Y", days: 365, span: "the last year" },
] as const
const SERIES: ChartSeries[] = [{ key: "rate", label: "Rate", color: "chart-1" }]
// Fixed to UTC so the axis reads the same wherever the page is rendered.
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
/** A pair's rate as the desk quotes it — the same rule the converter's rate line follows. */
const quote = (pair: FxPair, rate: number) => {
const [base, counter] = pair.split("/")
return quoteRate(rate, base, counter)
}
/**
* One pair's rate over a chosen period. The line is drawn in the palette's
* first grey — one series, so no hue — and the accent marks only the latest
* rate, as a rule across the plot at the level the pair closed today. The
* chips are the kit's time-range toggles, the chosen one on the kit's
* selected fill; the pill is the move across the period on show.
*
* A rate is a level, not a quantity, so the chart's axis is fitted to the
* period's own range (`baseline="auto"`) rather than counted up from zero —
* from zero, a year of EUR/USD is a flat line along the top of the plot.
*/
export function ExchangeRates({ series }: { series: Record<FxPair, FxPoint[]> }) {
const [pair, setPair] = React.useState<FxPair>("EUR/USD")
const [period, setPeriod] = React.useState<(typeof PERIODS)[number]["value"]>("1m")
const id = React.useId()
const chosen = PERIODS.find((entry) => entry.value === period) ?? PERIODS[1]
const points = series[pair].slice(-chosen.days)
const first = points[0]
const last = points[points.length - 1]
return (
<Widget
data-widget="widget-finance-balances-exchange-rates"
title="Exchange rates"
description={`Closing rate on ${formatDate(`${last.date}T00:00:00Z`, "medium", { timeZone: "UTC" })}`}
className="h-full"
>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<label htmlFor={`${id}-pair`} className="sr-only">
Currency pair
</label>
<NativeSelect id={`${id}-pair`} size="sm" value={pair} onChange={(event) => setPair(event.target.value as FxPair)}>
{PAIRS.map((entry) => (
<NativeSelectOption key={entry} value={entry}>
{entry}
</NativeSelectOption>
))}
</NativeSelect>
<ChartTimeRange
size="sm"
value={period}
onValueChange={(next) => setPeriod(next as typeof period)}
options={PERIODS.map(({ value, label }) => ({ value, label }))}
/>
</div>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="type-numeral text-2xl">{quote(pair, last.rate)}</span>
<MetricDelta value={last.rate / first.rate - 1} variant="pill" size="sm" />
<span className="text-xs text-muted-foreground">over {chosen.span}</span>
</div>
<LineChart
data={points}
index="date"
series={SERIES}
height={200}
curve="linear"
baseline="auto"
showLegend={false}
annotations={[{ kind: "line", axis: "y", value: last.rate, label: quote(pair, last.rate), tone: "brand" }]}
valueFormatter={(value) => quote(pair, value)}
indexFormatter={(value) => DAY.format(new Date(`${value}T00:00:00Z`))}
/>
</div>
</Widget>
)
}Install
$
npx shadcn@latest add @vibra/widget-finance-balances-exchange-ratesNeeds the @vibra registry in your components.json — set it up once.
Source
"use client"
import * as React from "react"
import { formatDate } from "@/lib/format"
import { type FxPair, type FxPoint } from "@/lib/sample-data"
import { ChartTimeRange } from "@/components/ui/chart-time-range"
import type { ChartSeries } from "@/components/ui/chart-core"
import { LineChart } from "@/components/ui/line-chart"
import { MetricDelta } from "@/components/ui/metric-delta"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { Widget } from "@/components/ui/widget"
import { quoteRate } from "./balances-vocabulary"
const PAIRS: FxPair[] = ["EUR/USD", "GBP/USD", "USD/JPY", "USD/CHF", "EUR/GBP"]
/** The periods the chips offer, each the number of daily points it draws. */
const PERIODS = [
{ value: "1w", label: "1W", days: 7, span: "the last week" },
{ value: "1m", label: "1M", days: 30, span: "the last month" },
{ value: "3m", label: "3M", days: 90, span: "the last three months" },
{ value: "1y", label: "1Y", days: 365, span: "the last year" },
] as const
const SERIES: ChartSeries[] = [{ key: "rate", label: "Rate", color: "chart-1" }]
// Fixed to UTC so the axis reads the same wherever the page is rendered.
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
/** A pair's rate as the desk quotes it — the same rule the converter's rate line follows. */
const quote = (pair: FxPair, rate: number) => {
const [base, counter] = pair.split("/")
return quoteRate(rate, base, counter)
}
/**
* One pair's rate over a chosen period. The line is drawn in the palette's
* first grey — one series, so no hue — and the accent marks only the latest
* rate, as a rule across the plot at the level the pair closed today. The
* chips are the kit's time-range toggles, the chosen one on the kit's
* selected fill; the pill is the move across the period on show.
*
* A rate is a level, not a quantity, so the chart's axis is fitted to the
* period's own range (`baseline="auto"`) rather than counted up from zero —
* from zero, a year of EUR/USD is a flat line along the top of the plot.
*/
export function ExchangeRates({ series }: { series: Record<FxPair, FxPoint[]> }) {
const [pair, setPair] = React.useState<FxPair>("EUR/USD")
const [period, setPeriod] = React.useState<(typeof PERIODS)[number]["value"]>("1m")
const id = React.useId()
const chosen = PERIODS.find((entry) => entry.value === period) ?? PERIODS[1]
const points = series[pair].slice(-chosen.days)
const first = points[0]
const last = points[points.length - 1]
return (
<Widget
data-widget="widget-finance-balances-exchange-rates"
title="Exchange rates"
description={`Closing rate on ${formatDate(`${last.date}T00:00:00Z`, "medium", { timeZone: "UTC" })}`}
className="h-full"
>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<label htmlFor={`${id}-pair`} className="sr-only">
Currency pair
</label>
<NativeSelect id={`${id}-pair`} size="sm" value={pair} onChange={(event) => setPair(event.target.value as FxPair)}>
{PAIRS.map((entry) => (
<NativeSelectOption key={entry} value={entry}>
{entry}
</NativeSelectOption>
))}
</NativeSelect>
<ChartTimeRange
size="sm"
value={period}
onValueChange={(next) => setPeriod(next as typeof period)}
options={PERIODS.map(({ value, label }) => ({ value, label }))}
/>
</div>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="type-numeral text-2xl">{quote(pair, last.rate)}</span>
<MetricDelta value={last.rate / first.rate - 1} variant="pill" size="sm" />
<span className="text-xs text-muted-foreground">over {chosen.span}</span>
</div>
<LineChart
data={points}
index="date"
series={SERIES}
height={200}
curve="linear"
baseline="auto"
showLegend={false}
annotations={[{ kind: "line", axis: "y", value: last.rate, label: quote(pair, last.rate), tone: "brand" }]}
valueFormatter={(value) => quote(pair, value)}
indexFormatter={(value) => DAY.format(new Date(`${value}T00:00:00Z`))}
/>
</div>
</Widget>
)
}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