Skip to contentVibraUI
Foundation

Metric

A metric type with kind-aware formatting, period comparison, and the provenance behind a number.

A Metric carries its kind next to its value, so one number formats the same way everywhere it is rendered. compareMetric returns relative: null when the previous value was 0 — there is no percentage change from nothing — and favorable: null when the number did not move, which is what makes a delta chip neutral rather than green; the ratio is taken against Math.abs(previous), so a metric that crosses zero never reads "up −300%". A percent metric reports its change in percentage points, so a chip beside "3.59%" reads "−0.08 pt" and can never be mistaken for a relative change. Every period is half-open, [start, end), and computed in UTC: trailingPeriod covers whole days, so a partly-collected today never drags a number down. Nothing here reads a clock — trailingPeriod takes the instant its window ends at as a required argument, so the page owns what "now" means (a fixed REFERENCE_DATE, a report's as-of date, or new Date()) and a server render and a client render cannot disagree about today. traced runs the computation over every row in full and keeps only the first sample of them, narrowed to the named columns, so what crosses to the client is a handful of rows, the fields a reader was going to see, and a count — a field the explanation does not name never leaves the server.

Install

npx shadcn@latest add @vibra/metric

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

Examples

Props

PropTypeDefaultDescription
Metric{ kind: "money" | "percent" | "duration" | "count"; value: number; currency?: string; unit?: "ms" | "s" | "min" | "h"; precision?: number }—A number and how to read it. percent takes a 0..1 ratio; duration takes an amount in unit, defaulting to milliseconds.
formatMetric(metric: Metric, opts?: { locale?: string; compact?: boolean }) => stringlocale: "en-US"Writes a metric the way its kind is read — "$1,234.50", "12.3%", "1h 30m", "44,392". compact abbreviates money and counts.
compareMetric(current: Metric, previous: Metric, opts?: { positiveIsGood?: boolean }) => DeltapositiveIsGood: trueThe change between two periods: { absolute, relative, direction, favorable }. Pass positiveIsGood: false for churn, latency, or cost. Throws when the two metrics are not the same measurement — a different kind, currency, or unit — rather than converting or comparing them raw.
Delta{ absolute: number; relative: number | null; direction: "up" | "down" | "flat"; favorable: boolean | null }—absolute is in the metric's own units, except for a percent metric, where it is in percentage points (3.67% → 3.59% carries −0.08). relative is the change over the *size* of the previous value — measured against Math.abs, so a metric that crosses zero never reports a rise as a negative percentage — and is null when that value was 0. favorable is null when the number did not move.
formatDelta(delta: Delta, kind: MetricKind, opts?: { locale?: string; currency?: string; unit?: DurationUnit; precision?: number }) => string—A percent metric always reports points — "−0.08 pt", two decimals, trimmed. Every other kind gives "+12.4%", or the signed absolute change in its own units — "−$1,200", "+1m 30s" — when there is no previous value to divide by. Negatives use U+2212.
Period{ label: string; start: Date; end: Date }—A window a metric is measured over. Half-open: end is exclusive.
trailingPeriod(days: number, end: Date) => Period—The days whole UTC days before the day end falls in, so a partly-collected today is left out. end is required — the lib keeps no clock of its own.
previousPeriod(period: Period) => Period—The window of the same length that ends exactly where period starts.
samePeriodLastYear(period: Period) => Period—The same calendar window one year earlier, in UTC. 29 February rolls forward to 1 March.
Provenance{ source: string; formula: string; columns: string[]; rows: ProvenanceRow[]; total: number }—Where a number came from: the rows kept for display — narrowed to columns — how many there were in total, and the formula.
traced(source: string, rows: readonly Row[], formula: string, compute: (rows: readonly Row[]) => number, opts?: { columns?: string[]; sample?: number }) => { value: number; provenance: Provenance }sample: 8Computes over every row in full and records the first sample of them, narrowed to columns, plus the total. The kept rows are a projection: a field the explanation does not name never crosses to the client.
isSampled(provenance: Provenance) => boolean—Whether fewer rows are on show than the number was computed from.

Dependencies

Source

lib/metric.ts
/**
 * A metric that carries its own meaning: what kind of number it is, what it is
 * measured against, and which rows it was computed from.
 *
 * A dashboard number is usually a bare `number` plus a formatting call written
 * at the point of render, which is why the same figure ends up rounded three
 * ways across a page and why "+12%" never says what it is 12% of. `Metric`
 * moves the kind next to the value, `compareMetric` derives the change and
 * whether that change is good news, and `traced` records the rows behind the
 * number so a component can show its work.
 *
 * Every period is computed in UTC, and none of them reads a clock: the instant a
 * window ends at is always passed in, so a server render and a client render
 * agree, a test run in any zone reads the same days, and a page that has its own
 * fixed "now" — REFERENCE_DATE, a report's as-of date — stays the only thing that
 * decides what today is.
 */
import {
  formatCompact,
  formatCurrency,
  formatDelta as formatSignedNumber,
  formatDuration,
  formatNumber,
  formatPercent,
} from "@/lib/format"

const DAY_MS = 86_400_000

/** How a number should be read: as money, a ratio, a span of time, or a tally. */
export type MetricKind = "money" | "percent" | "duration" | "count"

/** The unit a `duration` metric's value is counted in. Defaults to milliseconds. */
export type DurationUnit = "ms" | "s" | "min" | "h"

export type Metric = {
  kind: MetricKind
  /** A 0..1 ratio for "percent"; an amount in `unit` for "duration". */
  value: number
  /** ISO 4217 code for "money"; defaults to USD. */
  currency?: string
  unit?: DurationUnit
  /** Fraction digits. Defaults to 2 for money, 1 for percent, 0 for count. */
  precision?: number
}

export type FormatMetricOptions = {
  locale?: string
  /** Abbreviates money and counts — 1234567 → "1.2M". Percents and durations are already short. */
  compact?: boolean
}

const UNIT_MS: Record<DurationUnit, number> = { ms: 1, s: 1_000, min: 60_000, h: 3_600_000 }

/** How many milliseconds a duration metric is worth. */
function durationMs(metric: Pick<Metric, "value" | "unit">): number {
  return metric.value * UNIT_MS[metric.unit ?? "ms"]
}

// formatDuration reads any value under a second as milliseconds, so a negative
// span would come back as "-5400000ms"; the sign is peeled off first and put back.
function signedDuration(ms: number): string {
  return ms < 0 ? `-${formatDuration(-ms)}` : formatDuration(ms)
}

/** Writes a metric the way its kind is read: "$1,234.50", "12.3%", "1h 30m", "44,392". */
export function formatMetric(metric: Metric, opts: FormatMetricOptions = {}): string {
  const { locale, compact = false } = opts
  const { kind, value, currency = "USD", precision } = metric

  switch (kind) {
    case "money":
      return formatCurrency(value, currency, { locale, compact, maximumFractionDigits: precision })
    case "percent":
      return formatPercent(value, { locale, maximumFractionDigits: precision ?? 1 })
    case "duration":
      return signedDuration(durationMs(metric))
    case "count":
      return compact
        ? formatCompact(value, { locale, maximumFractionDigits: precision ?? 1 })
        : formatNumber(value, { locale, maximumFractionDigits: precision ?? 0 })
  }
}

/** Which way a number moved, with "flat" reserved for no movement at all. */
export type DeltaDirection = "up" | "down" | "flat"

export type Delta = {
  /**
   * current − previous, in the metric's own units — except for a `percent`
   * metric, where it is in **percentage points**: a rate that moves 3.67% →
   * 3.59% carries −0.08 here, not −0.0008.
   */
  absolute: number
  /**
   * The change as a ratio of the *size* of the previous value, or null when
   * that value was 0. Measured against `Math.abs(previous)`, so a metric that
   * crosses zero — net revenue, margin, net churn — never reports a rise as a
   * negative percentage.
   */
  relative: number | null
  direction: DeltaDirection
  /** Whether the move is good news, or null when the number did not move. */
  favorable: boolean | null
}

// Two metrics only compare if they are the same measurement. Nothing here
// converts between them: a silent conversion is a guess about which one the
// caller meant, and comparing 180 seconds with 240000 milliseconds unconverted
// reports a 99.9% fall. A mismatch is a mistake at the call site, so it is
// raised there rather than rendered.
function assertComparable(current: Metric, previous: Metric): void {
  if (current.kind !== previous.kind) {
    throw new Error(
      `compareMetric: a "${current.kind}" metric cannot be compared with a "${previous.kind}" one`
    )
  }
  if (current.kind === "money") {
    const now = current.currency ?? "USD"
    const before = previous.currency ?? "USD"
    if (now !== before) {
      throw new Error(`compareMetric: a metric in ${now} cannot be compared with one in ${before}`)
    }
  }
  if (current.kind === "duration") {
    const now = current.unit ?? "ms"
    const before = previous.unit ?? "ms"
    if (now !== before) {
      throw new Error(`compareMetric: a duration in ${now} cannot be compared with one in ${before}`)
    }
  }
}

/**
 * The change from `previous` to `current`. `relative` is null when the previous
 * value was zero — there is no percentage change from nothing, and a page that
 * prints "+∞%" or "+100%" there is inventing one. `favorable` is null when the
 * number did not move, because there is nothing to call good or bad.
 *
 * Throws when the two are not the same measurement — a different `kind`, a
 * different `currency`, a different `unit`.
 */
export function compareMetric(
  current: Metric,
  previous: Metric,
  opts: { positiveIsGood?: boolean } = {}
): Delta {
  const { positiveIsGood = true } = opts
  assertComparable(current, previous)

  const change = current.value - previous.value
  // A percent metric's own units are a 0..1 ratio, and nobody reads a change in
  // those: 3.67% → 3.59% is a fall of 0.08 percentage points, so points are what
  // `absolute` carries. `relative` stays a ratio of the ratio for whoever wants it.
  const absolute = current.kind === "percent" ? change * 100 : change
  // Divided by the *size* of the previous value: against a signed base, −50 → 100
  // comes out as −300%, a negative number under an up arrow.
  const relative = previous.value === 0 ? null : change / Math.abs(previous.value)
  const direction: DeltaDirection = change > 0 ? "up" : change < 0 ? "down" : "flat"
  const favorable = direction === "flat" ? null : (direction === "up") === positiveIsGood

  return { absolute, relative, direction, favorable }
}

export type FormatDeltaOptions = {
  locale?: string
  /** For a money delta with no relative change to show. Defaults to USD. */
  currency?: string
  /** The unit an absolute duration delta is counted in. Defaults to milliseconds. */
  unit?: DurationUnit
  /** Fraction digits for an absolute delta, and for a percent metric's points. Points default to 2. */
  precision?: number
}

/** "+" for a rise, U+2212 for a fall, nothing at all for no movement. */
function signOf(value: number): string {
  return value > 0 ? "+" : value < 0 ? "−" : ""
}

/**
 * The change as a reader wants it. A `percent` metric always reports points —
 * "−0.08 pt" — because a bare "−2.2%" beside a value that is itself "3.59%"
 * gives a reader no way to tell which of the two conventions is in force. Every
 * other kind reports "+12.4%" whenever there is a previous value to divide by,
 * and the signed absolute change in its own units — "−$1,200", "+1m 30s" — when
 * there is not. Negatives use U+2212, never a hyphen.
 */
export function formatDelta(
  delta: Delta,
  kind: MetricKind,
  opts: FormatDeltaOptions = {}
): string {
  const { locale, currency = "USD", unit = "ms", precision } = opts
  const { absolute } = delta

  if (kind === "percent") {
    // Points keep at least two decimals (trailing zeros trimmed) whatever the
    // metric's own precision, so a real move never reads as "−0 pt".
    const points = formatNumber(Math.abs(absolute), {
      locale,
      maximumFractionDigits: Math.max(2, precision ?? 2),
    })
    return `${signOf(absolute)}${points} pt`
  }

  if (delta.relative !== null) {
    return formatSignedNumber(delta.relative, { locale, style: "percent", maximumFractionDigits: 1 })
  }

  const sign = signOf(absolute)
  const magnitude = Math.abs(absolute)

  switch (kind) {
    case "money":
      return `${sign}${formatCurrency(magnitude, currency, { locale, maximumFractionDigits: precision })}`
    case "duration":
      return `${sign}${formatDuration(magnitude * UNIT_MS[unit])}`
    case "count":
      return formatSignedNumber(absolute, { locale, style: "number", maximumFractionDigits: precision ?? 0 })
  }
}

/** A window of time a metric is measured over. `[start, end)` — end is exclusive. */
export type Period = { label: string; start: Date; end: Date }

/** Midnight UTC on the day `at` falls in. */
function startOfUTCDay(at: Date): Date {
  return new Date(Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate()))
}

function daysLabel(prefix: string, days: number): string {
  if (!Number.isInteger(days)) return `${prefix} period`
  return days === 1 ? `${prefix} day` : `${prefix} ${days} days`
}

/**
 * The `days` whole UTC days before the day `end` falls in — `trailingPeriod(30, now)`
 * is the 30 complete days ending at midnight on `now`'s own day, so a
 * partly-collected today never drags the number down. `end` is required: the lib
 * has no "now" of its own, and the caller's is the only correct one.
 */
export function trailingPeriod(days: number, end: Date): Period {
  const endsAt = startOfUTCDay(end)
  return {
    label: daysLabel("Last", days),
    start: new Date(endsAt.getTime() - days * DAY_MS),
    end: endsAt,
  }
}

/** The window of the same length that ends where `period` starts. */
export function previousPeriod(period: Period): Period {
  const length = period.end.getTime() - period.start.getTime()
  return {
    label: daysLabel("Previous", length / DAY_MS),
    start: new Date(period.start.getTime() - length),
    end: new Date(period.start.getTime()),
  }
}

// The same calendar dates a year earlier rather than 365 days back, so a
// comparison lands on the same weeks of the year across a leap year. There is no
// 29 February in most years, and Date rolls that day forward to 1 March.
/** The same calendar window one year earlier, in UTC. */
export function samePeriodLastYear(period: Period): Period {
  return {
    label: "Same period last year",
    start: shiftYear(period.start),
    end: shiftYear(period.end),
  }
}

function shiftYear(at: Date): Date {
  return new Date(
    Date.UTC(
      at.getUTCFullYear() - 1,
      at.getUTCMonth(),
      at.getUTCDate(),
      at.getUTCHours(),
      at.getUTCMinutes(),
      at.getUTCSeconds(),
      at.getUTCMilliseconds()
    )
  )
}

/** What a provenance table can print in a cell. */
export type ProvenanceValue = string | number | boolean | null

/** One row behind a number: a flat record, because it is rendered as a table. */
export type ProvenanceRow = Record<string, ProvenanceValue>

export type Provenance = {
  /** Where the rows came from, in a reader's words — "daily traffic", "invoices". */
  source: string
  /** How the number was derived — "sum(visitors)", "signups ÷ visitors". */
  formula: string
  /** The row keys worth showing, in the order to show them. */
  columns: string[]
  /** The kept rows — the head of the input, narrowed to `columns`, never all of it. */
  rows: ProvenanceRow[]
  /** How many rows the number was actually computed from. */
  total: number
}

/**
 * Computes a number and records where it came from. `compute` sees every row in
 * full; the provenance keeps only the first `sample` of them, narrowed to
 * `columns` — so what travels to the client is a handful of rows, the fields a
 * reader was going to be shown, and a count rather than the whole table. A
 * field the explanation does not name never leaves the server, which matters
 * the moment the rows come out of a real repository rather than a generator.
 */
export function traced<Row extends ProvenanceRow>(
  source: string,
  rows: readonly Row[],
  formula: string,
  compute: (rows: readonly Row[]) => number,
  opts: { columns?: (keyof Row & string)[]; sample?: number } = {}
): { value: number; provenance: Provenance } {
  const { columns, sample = 8 } = opts
  const kept = columns ?? (rows[0] ? Object.keys(rows[0]) : [])

  return {
    value: compute(rows),
    provenance: {
      source,
      formula,
      columns: kept,
      // A projection, not a display filter: whatever else the row carries stays
      // behind. A missing key reads as null, which is what the table prints as "—".
      rows: rows
        .slice(0, sample)
        .map((row) => Object.fromEntries(kept.map((column) => [column, row[column] ?? null]))),
      total: rows.length,
    },
  }
}

/** Whether a provenance is showing fewer rows than the number was computed from. */
export function isSampled(provenance: Provenance): boolean {
  return provenance.rows.length < provenance.total
}