Sextant
← Playground

Win/Loss Reasons

free

Diverging win/loss bars by reason with the overall win rate and the objection costing the most deals.

Editable — click an element to tune it

npx shadcn add @sextant/sales-win-loss

Sales · Win/Loss Reasons

38%

87 won · 140 lost

  • Won
  • Lost

Win rate grew +59.7% QoQ to 38.3% — driven by losing on Price.

Sales ops · updated every 6 hours

Generated code

<SalesWinLoss />

Source — this is the whole widget

"use client";

import * as React from "react";
import { Bar, BarChart, ReferenceLine, XAxis, YAxis } from "recharts";

import { cn } from "@/lib/utils";
import { ChartCard } from "@/registry/default/widget-kit/chart-card";
import { sheenBar, SheenDefs } from "@/registry/default/widget-kit/bar-kit";
import {
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
  type ChartConfig,
} from "@/components/ui/chart";
import {
  ChartNarrative,
  resolveNarrative,
  type NarrativeProp,
} from "@/registry/default/chart-narrative/chart-narrative";
import type { Insight } from "@/registry/default/sextant-insights/types";
import { StudioTarget, useStudio } from "@/registry/default/widget-kit/studio";
import { SparkTile } from "@/registry/default/widget-kit/spark-tile";
import { useSizePlan } from "@/registry/default/widget-kit/use-chart-size";
import {
  FloatingPanelsProvider,
  type Drilldown,
} from "@/registry/default/chart-floating-panels/chart-floating-panels";
import { PanelDragLayer } from "@/registry/default/chart-floating-panels/panel-drag-layer";
import {
  makeBarDrilldown,
  type BarDatum,
} from "@/registry/default/chart-floating-panels/bar-detail";
import type { SizeProp } from "@/registry/default/widget-kit/chart-size";
import type { SizePlan } from "@/registry/default/widget-kit/size-plan";
import type {
  ChartClassNames,
  SeriesConfig,
} from "@/registry/default/widget-kit/contract";
import { safeData } from "@/registry/default/sextant-contracts/contracts";
import { ChartStates } from "@/registry/default/chart-states/chart-states";
import { winLossInputSchema, winLoss, type WinLossInput } from "./data";

// Re-export the data contract so consumers get the schema + types from a
// single import (no reaching into ./data).
export {
  winLossReasonSchema,
  winLossInputSchema,
  type WinLossReason,
  type WinLossInput,
} from "./data";

const COLOR_PRESETS = [
  { name: "Sage", value: "var(--chart-positive)" },
  { name: "Terracotta", value: "var(--chart-negative)" },
  { name: "Maroon (brand)", value: "var(--chart-1)" },
  { name: "Ink", value: "var(--chart-2)" },
  { name: "Honey", value: "var(--chart-warning)" },
];

const WIN_LOSS_PLAN: SizePlan = {
  lg: { shape: "full", height: "h-64", legend: "full" },
  md: { shape: "full", height: "h-56", legend: "inline" },
  sm: { shape: "compact" },
  mobile: { shape: "full", height: "h-64", legend: "inline", stack: true },
};

/**
 * Two series — deals won and deals lost. Recolor either:
 * `series={{ lost: { color: "var(--chart-warning)" } }}`.
 */
export type WinLossSeries = { won?: SeriesConfig; lost?: SeriesConfig };

type Props = {
  // ── Data ────────────────────────────────────────────────────────────────
  data?: WinLossInput;

  // ── Card chrome ───────────────────────────────────────────────────────────
  label?: string;
  attribution?: string;
  /** Shorthand for `classNames.root` — width, ring, shadow, margins, etc. */
  className?: string;
  /** Targeted class overrides per region (root/header/body/narrative/footnote). */
  classNames?: ChartClassNames;
  /** Force a size tier, or "auto" (default) to adapt to the card's own width. */
  size?: SizeProp;

  // ── Customize ───────────────────────────────────────────────────────────
  /** Recolor the won/lost bars. */
  series?: WinLossSeries;
  /** Override count formatting (axis, tooltip). Default absolute integer. */
  valueFormatter?: (n: number) => string;
  /** Replace, hide (`false`), or fully render the one-line narrative. */
  narrative?: NarrativeProp;

  // ── Floating drill-down panels ────────────────────────────────────────────
  /**
   * Enable drag-out floating panels: drag a reason's bar out of the plot to spawn
   * a draggable, resizable drill-down panel beside the chart (or click a bar /
   * focus it and press Enter). Default off, so the widget is byte-identical at
   * rest unless opted in. Auto-falls back to stacked panels below the card on
   * phones. Powered by `@sextant/chart-floating-panels`.
   */
  floatingPanels?: boolean;
  /**
   * Map a win/loss reason to its panel content. Defaults to a shared breakdown —
   * the reason's total deal volume, its share of all closed deals, its rank, and
   * a peer comparison across the other reasons.
   */
  drilldown?: Drilldown<Row>;
};

const fmtCount = (n: number): string => `${Math.abs(Math.round(n))}`;

type Row = { reason: string; won: number; lostNeg: number; lost: number };

/**
 * Pure: diverging rows — won to the right, lost (negated) to the left — sorted
 * by total volume so the highest-traffic reasons sit on top. Exported so the
 * headless `<SalesWinLossPlot>` and your own code build the same rows.
 */
export function buildWinLossRows(reasons: WinLossInput["reasons"]): Row[] {
  return [...reasons]
    .sort((a, b) => b.won + b.lost - (a.won + a.lost))
    .map((r) => ({ reason: r.reason, won: r.won, lostNeg: -r.lost, lost: r.lost }));
}

/**
 * SSR-safe coarse-pointer detection — the same signal `ChartSizeProvider` uses to
 * flip a card into its `touch` tier. Read here (outside the size context, since
 * the floating-panels stage must wrap the card from *outside* its `overflow-hidden`
 * box) to choose stacked panels on phones and floating panels on the desktop.
 */
function useCoarsePointer(): boolean {
  const [coarse, setCoarse] = React.useState(false);
  React.useEffect(() => {
    if (typeof window === "undefined" || !window.matchMedia) return;
    const mql = window.matchMedia("(pointer: coarse), (hover: none)");
    const update = () => setCoarse(mql.matches);
    update();
    mql.addEventListener("change", update);
    return () => mql.removeEventListener("change", update);
  }, []);
  return coarse;
}

export function SalesWinLoss(props: Props) {
  // Validate consumer data at the boundary: a bad shape shows the error
  // state instead of crashing. The built-in demo data is trusted, so skip it.
  const parsed =
    props.data === undefined ? null : safeData(winLossInputSchema, props.data);
  if (parsed && !parsed.ok) {
    return (
      <ChartStates status="error" shape="bar" error={{ message: parsed.error }}>
        {null}
      </ChartStates>
    );
  }
  return <SalesWinLossImpl {...props} />;
}

function SalesWinLossImpl({
  data = winLoss,
  label,
  attribution,
  className,
  classNames,
  size = "auto",
  series,
  valueFormatter,
  narrative,
  floatingPanels = false,
  drilldown,
}: Props) {
  const studio = useStudio();
  const title = studio.text("title", label ?? "Sales · Win/Loss Reasons", {
    label: "Chart title",
  });
  // `series.*.color` sets the default; Studio (when mounted) still edits on top.
  const wonColor = studio.color(
    "wonColor",
    series?.won?.color ?? "var(--chart-positive)",
    { label: "Won color", presets: COLOR_PRESETS },
  );
  const lostColor = studio.color(
    "lostColor",
    series?.lost?.color ?? "var(--chart-negative)",
    { label: "Lost color", presets: COLOR_PRESETS },
  );
  const attributionText = studio.text(
    "attribution",
    attribution ?? "Sales ops · updated every 6 hours",
    { label: "Attribution" },
  );

  const wonLabel = series?.won?.label ?? "Won";
  const lostLabel = series?.lost?.label ?? "Lost";
  const fmt = valueFormatter ?? fmtCount;

  const { reasons, priorWinRate } = data;

  const totalWon = reasons.reduce((s, r) => s + r.won, 0);
  const totalLost = reasons.reduce((s, r) => s + r.lost, 0);
  const winRate = totalWon + totalLost > 0 ? totalWon / (totalWon + totalLost) : 0;
  const topLoss = reasons.reduce(
    (b, r) => (r.lost > b.lost ? r : b),
    reasons[0] ?? { reason: "—", won: 0, lost: 0 },
  );

  // Win rate vs last quarter, attributing the move to the top loss bucket.
  const insight: Insight = {
    kind: "delta",
    metric: "Win rate",
    value: winRate,
    delta: priorWinRate > 0 ? (winRate - priorWinRate) / priorWinRate : 0,
    period: "QoQ",
    driver: `losing on ${topLoss?.reason ?? "—"}`,
    format: "percent",
  };

  const config = {
    won: { label: wonLabel, color: wonColor },
    lost: { label: lostLabel, color: lostColor },
  } satisfies ChartConfig;

  const winRateLabel = `${(winRate * 100).toFixed(0)}%`;

  // ── Floating drill-down panels ────────────────────────────────────────────
  // The prop is the hard gate; within it, a Studio toggle governs (no-op in
  // production) — the same "prop gates, toggle governs" discipline as ChartCard's
  // Motion knob. The stage must wrap the card from *outside* its `overflow-hidden`
  // box, so the gate + mode are resolved here (not in the inner size context).
  const panelsOn =
    floatingPanels &&
    studio.toggle("floatingPanels", true, {
      label: "Floating panels",
      onLabel: "On",
      offLabel: "Off",
    });
  const coarse = useCoarsePointer();
  // No horizontal room on a phone → panels stack below the card instead of
  // floating around it.
  const panelMode = coarse ? "stacked" : "float";
  // One drillable bar per reason; magnitude = total deal volume so the shared
  // detail's share / rank / peers reflect each reason's overall traffic — the
  // same ordering the diverging bars are sorted by.
  const panelBars: BarDatum[] = reasons.map((r) => ({
    key: r.reason,
    label: r.reason,
    value: r.won + r.lost,
  }));
  const resolvedDrilldown =
    drilldown ??
    makeBarDrilldown<Row>({
      bars: panelBars,
      format: (n) => fmt(n),
      accent: wonColor,
      unit: "deals",
    });

  const card = (
    <ChartCard.Root
      size={size}
      className={cn(className, classNames?.root)}
      exportable
      exportTitle={title}
    >
      <WinLossInner
        data={data}
        config={config}
        insight={insight}
        narrative={narrative}
        title={title}
        winRateLabel={winRateLabel}
        totalWon={totalWon}
        totalLost={totalLost}
        wonColor={wonColor}
        lostColor={lostColor}
        wonLabel={wonLabel}
        lostLabel={lostLabel}
        valueFormatter={valueFormatter}
        fmt={fmt}
        attributionText={attributionText}
        classNames={classNames}
        panelsOn={panelsOn}
        drilldown={resolvedDrilldown}
      />
    </ChartCard.Root>
  );

  // Wrap the card in the floating-panels stage only when enabled — at rest the
  // widget is byte-identical (the provider adds one relative wrapper and nothing
  // else).
  if (!panelsOn) return card;
  return <FloatingPanelsProvider mode={panelMode}>{card}</FloatingPanelsProvider>;
}

type InnerProps = {
  data: WinLossInput;
  config: ChartConfig;
  insight: Insight;
  narrative: NarrativeProp | undefined;
  title: string;
  winRateLabel: string;
  totalWon: number;
  totalLost: number;
  wonColor: string;
  lostColor: string;
  wonLabel: string;
  lostLabel: string;
  valueFormatter?: (n: number) => string;
  fmt: (n: number) => string;
  attributionText: string;
  classNames?: ChartClassNames;
  /** Resolved drag-out gate (prop AND Studio toggle). */
  panelsOn: boolean;
  /** Resolved per-reason drill-down content for the panels. */
  drilldown: Drilldown<Row>;
};

function WinLossInner({
  data,
  config,
  insight,
  narrative,
  title,
  winRateLabel,
  totalWon,
  totalLost,
  wonColor,
  lostColor,
  wonLabel,
  lostLabel,
  valueFormatter,
  attributionText,
  classNames,
  panelsOn,
  drilldown,
}: InnerProps) {
  const spec = useSizePlan(WIN_LOSS_PLAN);

  if (spec.shape === "compact") {
    return (
      <SparkTile
        label={title}
        value={winRateLabel}
        chip={
          <ChartNarrative text={`${totalWon} won · ${totalLost} lost`} variant="chip" />
        }
      />
    );
  }

  // Diverging rows — rebuilt here (cheap, pure) so the drag-out overlay shares
  // the plot's exact bar geometry; the standalone plot builds its own internally.
  const rows = buildWinLossRows(data.reasons);

  return (
    <>
      <ChartCard.Header className={classNames?.header}>
        <ChartCard.HeaderMain>
          <StudioTarget fieldKey="title" variant="inline">
            <ChartCard.Label>{title}</ChartCard.Label>
          </StudioTarget>
          <ChartCard.HeroNumber>{winRateLabel}</ChartCard.HeroNumber>
          <p className="tabular text-sm text-muted-foreground">
            {totalWon} won · {totalLost} lost
          </p>
        </ChartCard.HeaderMain>
        {(spec.legend === "full" || spec.legend === "inline") && (
          <ChartCard.Legend
            items={[
              { label: wonLabel, color: wonColor },
              { label: lostLabel, color: lostColor },
            ]}
          />
        )}
      </ChartCard.Header>

      {resolveNarrative(narrative, insight, { className: classNames?.narrative })}

      <ChartCard.Body className={classNames?.body}>
        <SalesWinLossPlot
          data={data}
          config={config}
          heightClass={spec.height}
          wonColor={wonColor}
          lostColor={lostColor}
          valueFormatter={valueFormatter}
          overlay={
            panelsOn ? (
              <PanelDragLayer<Row>
                bars={rows}
                getKey={(r) => r.reason}
                getSpan={(r) => [-r.lost, r.won]}
                orientation="horizontal"
                drilldown={drilldown}
                accent={wonColor}
                ghostValue={(r) => `${r.won} won · ${r.lost} lost`}
              />
            ) : undefined
          }
        />
      </ChartCard.Body>

      <StudioTarget fieldKey="attribution" variant="inline">
        <ChartCard.Footnote className={classNames?.footnote}>
          {attributionText}
        </ChartCard.Footnote>
      </StudioTarget>
    </>
  );
}

type PlotProps = {
  /** Closed-deal reasons — sorted into diverging rows internally. */
  data: WinLossInput;
  /** Series colors + labels (a shadcn ChartConfig keyed by `won` / `lost`). */
  config: ChartConfig;
  heightClass?: string;
  wonColor?: string;
  lostColor?: string;
  /** Override axis + tooltip number formatting. */
  valueFormatter?: (n: number) => string;
  /**
   * Extra chart children rendered on top of the bars (a Recharts subtree), so a
   * caller can layer interactive overlays that read the live axis scales — e.g.
   * the `PanelDragLayer` from chart-floating-panels. Rendered last so its hit
   * targets sit above the bars. Inert/undefined for the standalone plot.
   */
  overlay?: React.ReactNode;
};

/**
 * Just the chart-drawing piece — the diverging won/lost bars by reason (won
 * right, lost left) with a symmetric domain, axes, and tooltip, but no card
 * chrome, narrative, or states. Takes the same input the widget does and sorts
 * it internally, so you can drop it into your own layout or compose several.
 */
export function SalesWinLossPlot({
  data,
  config,
  heightClass = "h-64",
  wonColor = "var(--chart-positive)",
  lostColor = "var(--chart-negative)",
  valueFormatter,
  overlay,
}: PlotProps) {
  const rows = React.useMemo(() => buildWinLossRows(data.reasons), [data.reasons]);
  const fmt = valueFormatter ?? fmtCount;
  // Symmetric domain so the zero line sits dead center.
  const bound = Math.max(1, ...rows.map((r) => Math.max(r.won, r.lost)));
  const yWidth = Math.min(130, Math.max(72, ...rows.map((r) => r.reason.length * 7)));

  return (
    <ChartContainer config={config} className={cn(heightClass, "w-full")}>
      <BarChart
        data={rows}
        layout="vertical"
        margin={{ left: 0, right: 16, top: 4, bottom: 4 }}
      >
        <XAxis
          type="number"
          domain={[-bound, bound]}
          tickFormatter={(v: number) => fmt(v)}
          tickLine={false}
          axisLine={false}
          fontSize={10}
          className="smallcaps tabular"
        />
        <YAxis
          type="category"
          dataKey="reason"
          tickLine={false}
          axisLine={false}
          width={yWidth}
          fontSize={11}
          className="smallcaps"
        />
        <ChartTooltip
          cursor={{ fill: "var(--muted)", fillOpacity: 0.4 }}
          content={
            <ChartTooltipContent
              formatter={(value, name) => (
                <span className="tabular">
                  <span className="text-muted-foreground">{name}</span>{" "}
                  {fmt(Number(value))}
                </span>
              )}
            />
          }
        />
        <ReferenceLine x={0} stroke="var(--foreground)" strokeOpacity={0.5} />
        <SheenDefs />
        <Bar
          dataKey="won"
          name="Won"
          fill={wonColor}
          radius={[0, 3, 3, 0]}
          isAnimationActive={false}
          shape={sheenBar}
        />
        <Bar
          dataKey="lostNeg"
          name="Lost"
          fill={lostColor}
          radius={[3, 0, 0, 3]}
          isAnimationActive={false}
          shape={sheenBar}
        />
        {/* Caller overlay (e.g. the floating-panels drag layer) — last child so
            its hit targets sit above the bars and it reads the live axis scales,
            hence the static (non-animated) bars above. No-op without a
            <FloatingPanelsProvider> ancestor. */}
        {overlay}
      </BarChart>
    </ChartContainer>
  );
}