format.ts2.1 KBView on GitHub
/**
 * Formatters shared by the metric tiles. Rail-sized on purpose: the numbers here sit in a
 * 21rem column at a glance, so "2h 14m" beats "2 hours 14 minutes" and "$740k" beats
 * "$740,000" — /statistics is where exact figures live.
 */

/** Seconds → the coarsest unit that still says something useful. `null` stays `null`. */
export function formatDuration(seconds: number | null | undefined): string | null {
  if (seconds == null || !Number.isFinite(seconds)) return null;
  if (seconds < 60) return `${Math.round(seconds)}s`;
  const minutes = Math.round(seconds / 60);
  if (minutes < 60) return `${minutes}m`;
  const hours = Math.floor(minutes / 60);
  const remainder = minutes % 60;
  if (hours < 24) return remainder > 0 ? `${hours}h ${remainder}m` : `${hours}h`;
  const days = Math.floor(hours / 24);
  const leftoverHours = hours % 24;
  return leftoverHours > 0 ? `${days}d ${leftoverHours}h` : `${days}d`;
}

/** Compact currency: $740k, $1.2M. Whole dollars below 1k. */
export function formatCurrency(amount: number | null | undefined): string | null {
  if (amount == null || !Number.isFinite(amount)) return null;
  const sign = amount < 0 ? '-' : '';
  const value = Math.abs(amount);
  if (value >= 1_000_000) return `${sign}$${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1)}M`;
  if (value >= 1_000) return `${sign}$${Math.round(value / 1_000)}k`;
  return `${sign}$${Math.round(value)}`;
}

/** Thousands separators, no decimals. */
export function formatCount(count: number | null | undefined): string | null {
  if (count == null || !Number.isFinite(count)) return null;
  return count.toLocaleString('en-US');
}

/**
 * A CRM status slug as a human label: `closed_won` → `Closed won`.
 * Statuses arrive as free text from the user's own pipeline, so this only tidies casing and
 * separators rather than mapping against a fixed vocabulary it cannot know.
 */
export function formatStageLabel(status: string): string {
  const cleaned = status.replace(/[_-]+/g, ' ').trim();
  if (!cleaned) return 'Unknown';
  return cleaned.charAt(0).toUpperCase() + cleaned.slice(1).toLowerCase();
}