data.ts5.8 KBView on GitHub
/**
 * Pure helpers for the dashboard renderer: server/client-shared computations,
 * value formatting, `{{row.*}}` interpolation, and team-aggregate derivation.
 * Kept free of React so the same logic can back tests and (for computations)
 * the server execute path.
 */
import type { DashboardComputation, DashboardRow } from '../types/dashboard';

// ── Computations (mirrors the server-side apply for query sources) ────────────

function secondsToDuration(seconds: number): string {
  if (!Number.isFinite(seconds)) return '—';
  const d = Math.floor(seconds / 86400);
  if (d >= 1) return `${d}d`;
  const h = Math.floor(seconds / 3600);
  if (h >= 1) return `${h}h`;
  const m = Math.floor(seconds / 60);
  if (m >= 1) return `${m}m`;
  return `${Math.round(seconds)}s`;
}

/** Apply a source's computations, writing each transform's `label` field onto every row. */
export function applyComputations(
  rows: DashboardRow[],
  computations: DashboardComputation[] | undefined,
): DashboardRow[] {
  if (!computations?.length) return rows;
  return rows.map((row) => {
    const next: DashboardRow = { ...row };
    for (const comp of computations) {
      const raw = row[comp.field];
      const value = typeof raw === 'number' ? raw : Number(raw);
      for (const t of comp.transforms) {
        const key=[redacted] ?? comp.field;
        if (!Number.isFinite(value)) {
          next[key] = null;
          continue;
        }
        if (t.type === 'seconds_to_duration') next[key] = secondsToDuration(value);
        else if (t.type === 'ratio_to_percent') next[key] = Math.round(value * 100);
        else if (t.type === 'round') next[key] = Number(value.toFixed(t.precision ?? 0));
      }
    }
    return next;
  });
}

// ── Value formatting (display layer) ──────────────────────────────────────────

export type NumberFormat = 'percent' | 'duration' | 'number' | 'currency' | 'currency-compact';

export function formatValue(value: unknown, format?: NumberFormat): string {
  if (value === null || value === undefined || value === '') return '—';
  if (typeof value === 'string' && format !== 'duration') {
    const n = Number(value);
    if (!Number.isFinite(n)) return value;
    return formatValue(n, format);
  }
  const n = typeof value === 'number' ? value : Number(value);
  switch (format) {
    case 'percent':
      return `${Math.round(n)}%`;
    case 'duration':
      return typeof value === 'string' ? value : secondsToDuration(n);
    case 'currency':
      return n.toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
    case 'currency-compact':
      return n.toLocaleString(undefined, {
        style: 'currency',
        currency: 'USD',
        notation: 'compact',
        maximumFractionDigits: 2,
      });
    case 'number':
      return n.toLocaleString();
    default:
      return String(value);
  }
}

// ── `{{path}}` interpolation ──────────────────────────────────────────────────

const TEMPLATE = /\{\{\s*([\w.[\]]+)\s*\}\}/g;

function lookup(path: string, scope: Record<string, unknown>): unknown {
  return path.split('.').reduce<unknown>((acc, key) => {
    if (acc && typeof acc === 'object') return (acc as Record<string, unknown>)[key];
    return undefined;
  }, scope);
}

/** Replace `{{row.name}}`-style tokens in a string against the given scope. */
export function interpolate(input: string, scope: Record<string, unknown>): string {
  return input.replace(TEMPLATE, (_, path: string) => {
    const value = lookup(path, scope);
    return value === undefined || value === null ? '' : String(value);
  });
}

/** True if the string contains at least one `{{…}}` token. */
export function hasTemplate(input: string): boolean {
  return /\{\{\s*[\w.[\]]+\s*\}\}/.test(input);
}

// ── Team aggregate ────────────────────────────────────────────────────────────

/**
 * Column-wise mean of numeric fields across rows — the "team average" a
 * per-user detail view compares a single row against. Non-numeric columns are
 * dropped; nested numeric arrays (e.g. funnel `steps`) are averaged element-wise.
 */
export function teamAggregate(rows: DashboardRow[]): DashboardRow {
  const out: DashboardRow = {};
  if (!rows.length) return out;
  const keys = new Set<string>();
  rows.forEach((r) => Object.keys(r).forEach((k) => keys.add(k)));

  for (const key of keys) {
    const values = rows.map((r) => r[key]);
    const numbers = values.filter((v): v is number => typeof v === 'number');
    if (numbers.length === rows.length && numbers.length > 0) {
      out[key] = numbers.reduce((a, b) => a + b, 0) / numbers.length;
      continue;
    }
    // Average arrays of {label, count} element-wise (funnel steps).
    if (Array.isArray(values[0]) && isStepArray(values[0])) {
      const template = values[0] as { label: string; count: number }[];
      out[key] = template.map((step, i) => {
        const counts = rows
          .map((r) => (Array.isArray(r[key]) ? (r[key] as { count?: number }[])[i]?.count : undefined))
          .filter((c): c is number => typeof c === 'number');
        const avg = counts.length ? counts.reduce((a, b) => a + b, 0) / counts.length : 0;
        return { label: step.label, count: Math.round(avg) };
      });
    }
  }
  return out;
}

function isStepArray(v: unknown): v is { label: string; count: number }[] {
  return (
    Array.isArray(v) &&
    v.length > 0 &&
    typeof v[0] === 'object' &&
    v[0] !== null &&
    'count' in (v[0] as object) &&
    'label' in (v[0] as object)
  );
}