Format helpers
Seven helpers that turn a value into something a reader sees, in their language. Every formatter is memoized, because these run inside table and chart renders.
<script setup lang="ts">
import type { AvailableLocale } from '@core/i18n'
import {
activeLocale,
formatCompact,
formatCurrency,
formatCurrencyCompact,
formatDate,
formatNumber,
kpiCurrencyFormatter,
} from '@shared/utils/format'
import { Button } from '@shared/components/ui/button'
import { Input } from '@shared/components/ui/input'
import { Label } from '@shared/components/ui/label'
import { computed, ref } from 'vue'
const LOCALES: AvailableLocale[] = ['en', 'de', 'fr', 'ar', 'tr']
const ROW = [2_300_000, 572_092, 48_120]
const locale = ref<AvailableLocale>('en')
const amount = ref(1234.5)
const when = ref('2026-05-12T09:30:00Z')
const parsed = computed(() => new Date(when.value))
const readable = computed(() => !Number.isNaN(parsed.value.getTime()))
const kpi = computed(() => kpiCurrencyFormatter(ROW, 'USD', locale.value))
</script>
<template>
<div class="flex flex-col gap-5">
<div class="flex flex-wrap items-center gap-2">
<Button
v-for="code in LOCALES"
:key="code"
size="sm"
variant="outline"
:aria-pressed="locale === code"
@click="locale = code"
>
{{ code }}
</Button>
<span class="sa-text-sm text-muted-foreground">active: {{ activeLocale(locale) }}</span>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div class="grid gap-1">
<Label for="fmt-amount">Amount</Label>
<Input id="fmt-amount" v-model.number="amount" type="number" step="0.01" />
</div>
<div class="grid gap-1">
<Label for="fmt-date">Date</Label>
<Input id="fmt-date" v-model="when" />
</div>
</div>
<table class="w-full border-collapse sa-text-sm">
<tbody>
<tr class="border-b border-border">
<td class="py-2 pe-4"><code>formatNumber</code></td>
<td class="py-2 font-medium">{{ formatNumber(amount, {}, locale) }}</td>
</tr>
<tr class="border-b border-border">
<td class="py-2 pe-4"><code>formatCurrency(…, 'EUR')</code></td>
<td class="py-2 font-medium">{{ formatCurrency(amount, 'EUR', locale) }}</td>
</tr>
<tr class="border-b border-border">
<td class="py-2 pe-4"><code>formatCompact</code></td>
<td class="py-2 font-medium">{{ formatCompact(amount * 1000, locale) }}</td>
</tr>
<tr class="border-b border-border">
<td class="py-2 pe-4"><code>formatCurrencyCompact(…, 'USD')</code></td>
<td class="py-2 font-medium">{{ formatCurrencyCompact(amount * 1000, 'USD', locale) }}</td>
</tr>
<tr class="border-b border-border">
<td class="py-2 pe-4"><code>formatDate(…, 'long')</code></td>
<td class="py-2 font-medium">
<span v-if="readable">{{ formatDate(parsed, { format: 'long', locale }) }}</span>
<span v-else class="text-destructive">not a date — formatDate would throw</span>
</td>
</tr>
</tbody>
</table>
<section class="flex flex-col gap-2">
<span class="sa-text-sm font-semibold">kpiCurrencyFormatter — one row, one scale</span>
<div class="flex flex-wrap gap-3">
<div v-for="figure in ROW" :key="figure" class="rounded-lg border border-border px-4 py-3">
<p class="sa-text-xl font-semibold tabular-nums">
{{ kpi(figure) }}
</p>
<p class="sa-text-xs text-muted-foreground">
from {{ figure }}
</p>
</div>
</div>
</section>
</div>
</template>Import
import {
activeLocale,
formatCompact,
formatCurrency,
formatCurrencyCompact,
formatDate,
formatNumber,
kpiCurrencyFormatter,
} from '@shared/utils/format'Every function takes an optional trailing locale. Leave it out and you get the app's active one.
activeLocale
function activeLocale(override?: AvailableLocale): AvailableLocaleThe locale the others will format in. The override exists so one component can pin a language — the invoice module does, because its PDF fonts cannot draw Eastern Arabic numerals.
formatDate
function formatDate(date: DateInput, opts?: FormatDateOptions): string
type DateInput = Date | number | string
type DateFormat = 'short' | 'medium' | 'long' | 'full'format defaults to 'medium'. A string or a timestamp is accepted as well as a Date, which matters because a persisted date comes back as an ISO string.
It does not guard the input. An unparseable string reaches date-fns and throws RangeError: Invalid time value, which in a template takes the whole component down with it. Check the value before you format one a user typed — the example above does, which is why editing the date field there never blanks the preview.
formatNumber
function formatNumber(value: number, opts?: Intl.NumberFormatOptions, locale?: AvailableLocale): stringA plain number in the active locale: 1,234.56 in en, 1.234,56 in de, and 1,234.56 in ar — Western digits, because that is what the locale's Intl tag asks for.
opts is passed straight to Intl.NumberFormat, so { minimumFractionDigits: 2 } and friends work.
formatCurrency
function formatCurrency(value: number, currency: string, locale?: AvailableLocale): stringformatCurrency(1234.5, 'EUR') // '€1,234.50' in en, '1.234,50 €' in deThe currency is explicit and required. The locale decides grouping and where the symbol goes; it does not decide which money this is. Those are two different questions, and conflating them is how a German page ends up claiming a dollar figure is euros.
formatCompact
function formatCompact(value: number, locale?: AvailableLocale): string2.3M in en, 2,3 Mio. in de. For a KPI tile where the exact figure is not the point.
formatCurrencyCompact
function formatCurrencyCompact(value: number, currency: string, locale?: AvailableLocale): string$2.3M in en, 2.3 مليون US$ in ar.
One Intl call covers the whole figure. Writing a literal $ in front of a locale-compact number mixes two languages in one string, and puts the symbol on the wrong side in half of them.
kpiCurrencyFormatter
function kpiCurrencyFormatter(
rowValues: number[],
currency?: string,
locale?: AvailableLocale,
): (value: number) => stringReturns the formatter for one row of KPI tiles: compact when any figure in the row needs it, grouped when none does.
const format = kpiCurrencyFormatter([2_300_000, 572_092, 48_120], 'USD')
format(2_300_000) // '$2.3M'
format(572_092) // '$572.1K'That is the point of it. Formatting each tile independently puts $2.3M next to $572,092, and the row reads as though the two were measured differently. currency defaults to USD.
Counts and percentages stay on formatNumber — this is for money.
Related
- Number helpers — arithmetic, as opposed to rendering.
- Intl helpers —
localeTag, which all of these call. - Internationalization — the locale registry and its Intl tags.
- Stats and dashboard widgets — where the KPI formatter is used.