/**
* 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)
);
}