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