format.ts5.2 KBView on GitHub
import type { TimelineEntry } from './types';

function fmtTime(ts: number): string {
  const d = new Date(ts);
  const hh = String(d.getHours()).padStart(2, '0');
  const mm = String(d.getMinutes()).padStart(2, '0');
  const ss = String(d.getSeconds()).padStart(2, '0');
  const ms = String(d.getMilliseconds()).padStart(3, '0');
  return `${hh}:${mm}:${ss}.${ms}`;
}

function safeStringify(v: unknown, indent = 2): string {
  try {
    return JSON.stringify(v, null, indent);
  } catch (e) {
    return `[unserializable: ${e instanceof Error ? e.message : 'unknown'}]`;
  }
}

function isPlainObject(v: unknown): v is Record<string, unknown> {
  return typeof v === 'object' && v !== null && !Array.isArray(v);
}

function deepEqual(a: unknown, b: unknown): boolean {
  if (a === b) return true;
  if (Array.isArray(a) && Array.isArray(b)) {
    return a.length === b.length && a.every((v, i) => deepEqual(v, b[i]));
  }
  if (isPlainObject(a) && isPlainObject(b)) {
    const ak = Object.keys(a);
    const bk = Object.keys(b);
    return ak.length === bk.length && ak.every((k) => deepEqual(a[k], b[k]));
  }
  return false;
}

/**
 * Minimal recursive diff of two bounded-preview values: descends through
 * matching object shapes and emits only the leaves that actually changed,
 * so a setState touching one field of a 40-key slice yields one line, not
 * the whole snapshot.
 */
function minimalDiff(from: unknown, to: unknown): unknown {
  if (isPlainObject(from) && isPlainObject(to)) {
    const out: Record<string, unknown> = {};
    for (const k of new Set([...Object.keys(from), ...Object.keys(to)])) {
      if (deepEqual(from[k], to[k])) continue;
      out[k] = minimalDiff(from[k], to[k]);
    }
    return out;
  }
  return { from, to };
}

/** Per-key minimal diff for a zustand entry — the bulk-copy default. */
export function compactZustandDiff(
  diff: Record<string, { from?: unknown; to?: unknown }>,
): Record<string, unknown> {
  const out: Record<string, unknown> = {};
  for (const [k, v] of Object.entries(diff)) {
    out[k] = minimalDiff(v.from, v.to);
  }
  return out;
}

export function entryHeadline(e: TimelineEntry): string {
  switch (e.source) {
    case 'trpc.request':
      return `${e.type.toUpperCase()} ${e.path}  req=${e.requestId}${e.serverRequestId ? `  srv=${e.serverRequestId}` : ''}${e.traceId ? `  trace=${e.traceId}` : ''}`;
    case 'trpc.response':
      return `← ${e.path}  req=${e.requestId}  ${e.durationMs.toFixed(0)}ms${e.serverRequestId ? `  srv=${e.serverRequestId}` : ''}${e.traceId ? `  trace=${e.traceId}` : ''}`;
    case 'trpc.error':
      return `✕ ${e.path}  req=${e.requestId}  ${e.durationMs.toFixed(0)}ms${e.serverRequestId ? `  srv=${e.serverRequestId}` : ''}${e.traceId ? `  trace=${e.traceId}` : ''}`;
    case 'zustand':
      return e.changedKeys.join(', ');
    case 'error':
      return `[${e.kind}] ${e.message}`;
    case 'perf':
      return `${e.name} ${e.durationMs.toFixed(0)}ms`;
    case 'sse':
      return `${e.stream}/${e.eventType}`;
    case 'freeze':
      return `FREEZE ${e.reason} ${e.blockedMs.toFixed(0)}ms${e.snapshotId ? `  snap=${e.snapshotId}` : ''}`;
    case 'note':
      return e.text;
  }
}

export function entryBody(e: TimelineEntry, opts: { compact?: boolean } = {}): string {
  switch (e.source) {
    case 'trpc.request':
      return safeStringify(e.input);
    case 'trpc.response':
      return safeStringify(e.output);
    case 'trpc.error':
      return safeStringify(e.error);
    case 'zustand':
      // Compact: only the leaves that changed. Full: the before/after slice
      // snapshots (per-row copy button, for when you need surrounding context).
      return safeStringify(opts.compact ? compactZustandDiff(e.diff) : e.diff);
    case 'error':
      return e.stack ?? e.message;
    case 'perf':
      return safeStringify(e.detail ?? { durationMs: e.durationMs });
    case 'sse':
      return safeStringify(e.data);
    case 'freeze':
      return safeStringify({
        reason: e.reason,
        blockedMs: e.blockedMs,
        snapshotId: e.snapshotId,
      });
    case 'note':
      return e.text;
  }
}

export function formatAsMarkdown(entries: TimelineEntry[], opts: { mode?: string } = {}): string {
  if (entries.length === 0) return '_No timeline entries._';
  const first = entries[0]!.ts;
  const last = entries[entries.length - 1]!.ts;
  const dur = ((last - first) / 1000).toFixed(3);

  const lines: string[] = [];
  lines.push(`## Cedar Mail Debug Timeline${opts.mode ? ` (${opts.mode} mode)` : ''}`);
  lines.push(`Window: ${fmtTime(first)} — ${fmtTime(last)} (${dur}s, ${entries.length} entries)`);
  lines.push('');

  for (const e of entries) {
    lines.push(`### ${fmtTime(e.ts)} [${e.source}] ${entryHeadline(e)}`);
    const body = entryBody(e, { compact: true });
    if (body && body !== '{}' && body !== 'null') {
      lines.push('```json');
      lines.push(body);
      lines.push('```');
    }
    lines.push('');
  }

  return lines.join('\n');
}

export function formatAsJson(entries: TimelineEntry[]): string {
  // Mirror the markdown export: collapse zustand entries to their minimal diff.
  const compacted = entries.map((e) =>
    e.source === 'zustand' ? { ...e, diff: compactZustandDiff(e.diff) } : e,
  );
  return safeStringify(compacted);
}