CanvasMetricsBar.tsx5.5 KBView on GitHub
/**
 * CanvasMetricsBar — the roll-up tiles above a conversation canvas.
 *
 * Mirrors the bar HubSpot renders above a deal-stage tab, because that is the artifact the
 * numbers get checked against: same six tiles, same "Average per deal" sub-line on the
 * amount tiles, same compacted currency. Sits above the column header rather than below the
 * rows for the same reason.
 *
 * Renders nothing at all when the canvas has no `summaries` configured, so every existing
 * canvas is unaffected until someone opts in.
 *
 * Design: apps/mail/docs/pipeline-tabs-and-canvas-aggregates.md §3.2.
 */

import type { CanvasSummaryMetric, CanvasSummarySpec } from '@/modules/canvas/types/canvas-types';
import { useCanvasAggregates } from '@/modules/crm/hooks/use-canvas-aggregates';
import { useCedarStore } from '@/modules/store';
import type { ConversationViewConfig } from '@/modules/canvas/types/canvas-types';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';

const DEFAULT_LABELS: Record<CanvasSummaryMetric['kind'], string> = {
  count: 'DEALS',
  totalDealAmount: 'TOTAL DEAL AMOUNT',
  weightedDealAmount: 'WEIGHTED DEAL AMOUNT',
  openDealAmount: 'OPEN DEAL AMOUNT',
  closedDealAmount: 'CLOSED DEAL AMOUNT',
  newDealAmount: 'NEW DEAL AMOUNT',
  averageDealAge: 'AVERAGE DEAL AGE',
  builtin: 'DEAL VALUE',
};

/** `$956K` / `$1.2M` — HubSpot's compaction, so the two can be compared at a glance. */
function formatCurrency(n: number): string {
  const abs = Math.abs(n);
  if (abs >= 1_000_000) return `$${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`;
  if (abs >= 1_000) return `$${(n / 1_000).toFixed(1).replace(/\.0$/, '')}K`;
  return `$${Math.round(n).toLocaleString()}`;
}

function formatValue(value: number | null, unit: 'currency' | 'months' | 'count'): string {
  // A dash means "we could not work this out" — never a stand-in for a real zero. The
  // service is careful to distinguish the two; don't collapse them here.
  if (value === null) return '—';
  if (unit === 'months') return `${value.toFixed(1)} months`;
  if (unit === 'count') return value.toLocaleString();
  return formatCurrency(value);
}

function labelFor(spec: CanvasSummarySpec): string {
  return spec.label ?? DEFAULT_LABELS[spec.metric.kind] ?? spec.metric.kind;
}

interface CanvasMetricsBarProps {
  canvasId: string;
  /** The pinned set the canvas is showing, if any — must match what the row list was given. */
  conversationIds?: string[];
}

export function CanvasMetricsBar({ canvasId, conversationIds }: CanvasMetricsBarProps) {
  const { tiles, total, isLoading } = useCanvasAggregates(canvasId, conversationIds);

  const collapsed = useCedarStore((state) => {
    const c = state.canvasesById[canvasId];
    return (c?.viewConfig as ConversationViewConfig | undefined)?.summariesCollapsed ?? false;
  });
  const updateCanvasViewConfig = useCedarStore((state) => state.updateCanvasViewConfig);
  const saveCanvasViewConfig = useCedarStore((state) => state.saveCanvasViewConfig);

  if (tiles.length === 0) return null;

  const toggle = () => {
    const current = (useCedarStore.getState().canvasesById[canvasId]?.viewConfig ??
      {}) as ConversationViewConfig;
    updateCanvasViewConfig(canvasId, { ...current, summariesCollapsed: !collapsed });
    void saveCanvasViewConfig(canvasId);
  };

  return (
    <div className="border-border/50 shrink-0 border-b">
      <div className="flex items-start justify-between px-4 pt-3">
        {collapsed ? (
          <span className="text-muted-foreground text-xs">
            {total.toLocaleString()} {total === 1 ? 'deal' : 'deals'}
          </span>
        ) : (
          <div className="scrollbar-none flex flex-1 gap-8 overflow-x-auto pb-3">
            {tiles.map(({ spec, result }) => (
              <div key=[redacted] className="min-w-[9rem] shrink-0">
                <div className="text-muted-foreground text-[10px] font-semibold uppercase tracking-widest">
                  {labelFor(spec)}
                </div>
                <div
                  className={cn(
                    'text-emerald-600 dark:text-emerald-400 mt-1 text-2xl font-semibold tabular-nums',
                    isLoading && 'opacity-50',
                  )}
                  // The denominator is the honest part: deal_value is sparsely populated, and
                  // an unqualified total is exactly the number a rep would call wrong.
                  title={
                    result && result.populatedCount < total
                      ? `${result.populatedCount} of ${total} deals carry a value`
                      : undefined
                  }
                >
                  {formatValue(result?.value ?? null, result?.unit ?? 'currency')}
                </div>
                {spec.showAveragePerDeal && (
                  <div className="text-muted-foreground mt-0.5 text-[11px] leading-tight">
                    Average per deal
                    <br />
                    <span className="tabular-nums">
                      {formatValue(result?.averagePerDeal ?? null, result?.unit ?? 'currency')}
                    </span>
                  </div>
                )}
              </div>
            ))}
          </div>
        )}

        <button
          onClick={toggle}
          className="text-muted-foreground hover:text-foreground ml-2 shrink-0 rounded p-1"
          aria-label={collapsed ? 'Show metrics' : 'Hide metrics'}
        >
          {collapsed ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
        </button>
      </div>
    </div>
  );
}