Sextant
← Playground

Response Time Distribution

free

A first-response-time histogram with p50/p90 markers and the share of tickets breaching SLA — the tail that averages hide.

Editable — click an element to tune it

npx shadcn add @sextant/support-response-distribution

First-Response Times

6h 12m

p90 first response · p50 46m

  • Tickets
  • Past SLA

Half of tickets get a first reply within 46m, but 20% breach the 3h SLA.

Support platform · updated hourly

Generated code

<SupportResponseDistribution />

Source — this is the whole widget

"use client";

import * as React from "react";
import { Bar, BarChart, Cell, 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 { 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 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 {
  responseDistSchema,
  responseDistribution,
  type ResponseDistInput,
} from "./data";

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

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

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

/**
 * Two encodings: bins within SLA and bins past it. Recolor either:
 * `series={{ breached: { color: "var(--chart-4)" } }}`.
 */
export type SupportResponseDistributionSeries = {
  within?: SeriesConfig;
  breached?: SeriesConfig;
};

/** A histogram bucket: its label, [lo, hi) bounds, and ticket count. */
export type ResponseBin = { label: string; lo: number; hi: number; count: number };

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

  // ── Card chrome ───────────────────────────────────────────────────────────
  /** Card title (also the export filename). */
  label?: string;
  /** Footnote / source line. */
  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 within-SLA / breached histogram bins. */
  series?: SupportResponseDistributionSeries;
  /** Override duration formatting (hero, tooltip). Default is "Xh Ym" / "Zm". */
  valueFormatter?: (n: number) => string;
  /** Number of histogram bins. Default 10. */
  bins?: number;
  /** Replace, hide (`false`), or fully render the one-line narrative. */
  narrative?: NarrativeProp;
};

const fmtDuration = (m: number): string => {
  if (m < 60) return `${Math.round(m)}m`;
  const h = Math.floor(m / 60);
  const min = Math.round(m % 60);
  return min === 0 ? `${h}h` : `${h}h ${min}m`;
};

/** Linear-interpolated quantile of an ascending array. */
function quantile(sorted: number[], q: number): number {
  if (sorted.length === 0) return 0;
  const pos = (sorted.length - 1) * q;
  const lo = Math.floor(pos);
  const hi = Math.ceil(pos);
  if (lo === hi) return sorted[lo];
  return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
}

/** The derived shape the histogram draws plus the stats the headline reports. */
export type ResponseStats = {
  binData: ResponseBin[];
  p50: number;
  p90: number;
  breachFrac: number;
  max: number;
};

/**
 * Pure: bucket first-response times into a histogram and compute the p50 / p90 /
 * breach-rate stats. Exported so the headless `<SupportResponseDistributionPlot>`
 * and your own code derive the same bins.
 */
export function buildResponseStats(
  data: ResponseDistInput,
  bins: number,
): ResponseStats {
  const { slaMinutes, samples } = data;
  const sorted = [...samples].sort((a, b) => a - b);
  const maxSample = sorted[sorted.length - 1] ?? 0;
  const n = sorted.length;
  const breached = sorted.filter((s) => s > slaMinutes).length;
  const width = maxSample > 0 ? maxSample / bins : 1;
  const out: ResponseBin[] = [];
  for (let i = 0; i < bins; i++) {
    const lo = i * width;
    const hi = (i + 1) * width;
    out.push({ label: `${Math.round(lo)}-${Math.round(hi)}`, lo, hi, count: 0 });
  }
  for (const s of sorted) {
    let idx = width > 0 ? Math.floor(s / width) : 0;
    if (idx >= bins) idx = bins - 1;
    if (idx < 0) idx = 0;
    out[idx].count += 1;
  }
  return {
    binData: out,
    p50: quantile(sorted, 0.5),
    p90: quantile(sorted, 0.9),
    breachFrac: n > 0 ? breached / n : 0,
    max: maxSample,
  };
}

export function SupportResponseDistribution(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(responseDistSchema, props.data);
  if (parsed && !parsed.ok) {
    return (
      <ChartStates status="error" shape="bar" error={{ message: parsed.error }}>
        {null}
      </ChartStates>
    );
  }
  return <SupportResponseDistributionImpl {...props} />;
}

function SupportResponseDistributionImpl({
  data = responseDistribution,
  label,
  attribution,
  className,
  classNames,
  size = "auto",
  series,
  valueFormatter,
  bins = 10,
  narrative,
}: Props) {
  const studio = useStudio();
  const title = studio.text("title", label ?? "First-Response Times", {
    label: "Chart title",
  });
  // `series.*.color` sets the default; Studio (when mounted) still edits on top.
  const withinColor = studio.color(
    "withinColor",
    series?.within?.color ?? "var(--chart-1)",
    { label: "Within SLA color", presets: COLOR_PRESETS },
  );
  const breachedColor = studio.color(
    "breachedColor",
    series?.breached?.color ?? "var(--chart-negative)",
    { label: "Breached color", presets: COLOR_PRESETS },
  );
  const attributionText = studio.text(
    "attribution",
    attribution ?? "Support platform · updated hourly",
    { label: "Attribution" },
  );

  const withinLabel = series?.within?.label ?? "Tickets";
  const breachedLabel = series?.breached?.label ?? "Past SLA";
  const fmt = valueFormatter ?? fmtDuration;
  const { slaMinutes } = data;

  const { binData, p50, p90, breachFrac, max } = React.useMemo(
    () => buildResponseStats(data, bins),
    [data, bins],
  );

  // Map a value to the label of the bin that contains it.
  const binLabelFor = React.useCallback(
    (x: number): string | undefined => {
      const width = max > 0 ? max / bins : 1;
      let idx = width > 0 ? Math.floor(x / width) : 0;
      if (idx >= bins) idx = bins - 1;
      if (idx < 0) idx = 0;
      return binData[idx]?.label;
    },
    [binData, max, bins],
  );

  // Custom, deterministic sentence — no Insight kind expresses "median vs tail
  // breach" cleanly, so the widget composes its own (still pure, no LLM).
  const autoNarrative = `Half of tickets get a first reply within ${fmt(p50)}, but ${(
    breachFrac * 100
  ).toFixed(0)}% breach the ${fmt(slaMinutes)} SLA.`;

  const config = {
    count: { label: withinLabel, color: withinColor },
  } satisfies ChartConfig;

  const heroValue = fmt(p90);
  const chipText = `p50 ${fmt(p50)}`;

  return (
    <ChartCard.Root
      size={size}
      className={cn(className, classNames?.root)}
      exportable
      exportTitle={title}
    >
      <ResponseInner
        binData={binData}
        config={config}
        narrative={narrative}
        autoNarrative={autoNarrative}
        title={title}
        heroValue={heroValue}
        chipText={chipText}
        p50={p50}
        slaMinutes={slaMinutes}
        p50Bin={binLabelFor(p50)}
        p90Bin={binLabelFor(p90)}
        slaBin={binLabelFor(slaMinutes)}
        withinColor={withinColor}
        breachedColor={breachedColor}
        withinLabel={withinLabel}
        breachedLabel={breachedLabel}
        valueFormatter={valueFormatter}
        fmt={fmt}
        attributionText={attributionText}
        classNames={classNames}
      />
    </ChartCard.Root>
  );
}

type InnerProps = {
  binData: ResponseBin[];
  config: ChartConfig;
  narrative: NarrativeProp | undefined;
  autoNarrative: string;
  title: string;
  heroValue: string;
  chipText: string;
  p50: number;
  slaMinutes: number;
  p50Bin: string | undefined;
  p90Bin: string | undefined;
  slaBin: string | undefined;
  withinColor: string;
  breachedColor: string;
  withinLabel: string;
  breachedLabel: string;
  valueFormatter?: (n: number) => string;
  fmt: (n: number) => string;
  attributionText: string;
  classNames?: ChartClassNames;
};

function ResponseInner({
  binData,
  config,
  narrative,
  autoNarrative,
  title,
  heroValue,
  chipText,
  p50,
  slaMinutes,
  p50Bin,
  p90Bin,
  slaBin,
  withinColor,
  breachedColor,
  withinLabel,
  breachedLabel,
  valueFormatter,
  fmt,
  attributionText,
  classNames,
}: InnerProps) {
  const spec = useSizePlan(RESPONSE_PLAN);

  if (spec.shape === "compact") {
    // A histogram doesn't sparkline meaningfully; the glance tile reports the
    // p90 hero with the median as the trend chip.
    return (
      <SparkTile
        label={title}
        value={heroValue}
        chip={<ChartNarrative text={chipText} variant="chip" />}
      />
    );
  }

  return (
    <>
      <ChartCard.Header className={classNames?.header}>
        <ChartCard.HeaderMain>
          <StudioTarget fieldKey="title" variant="inline">
            <ChartCard.Label>{title}</ChartCard.Label>
          </StudioTarget>
          <ChartCard.HeroNumber>{heroValue}</ChartCard.HeroNumber>
          <p className="tabular text-sm text-muted-foreground">
            p90 first response · p50 {fmt(p50)}
          </p>
        </ChartCard.HeaderMain>
        {(spec.legend === "full" || spec.legend === "inline") && (
          <ChartCard.Legend
            items={[
              { label: withinLabel, color: withinColor },
              { label: breachedLabel, color: breachedColor },
            ]}
          />
        )}
      </ChartCard.Header>

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

      <ChartCard.Body className={classNames?.body}>
        <SupportResponseDistributionPlot
          binData={binData}
          config={config}
          heightClass={spec.height}
          slaMinutes={slaMinutes}
          p50Bin={p50Bin}
          p90Bin={p90Bin}
          slaBin={slaBin}
          withinColor={withinColor}
          breachedColor={breachedColor}
          valueFormatter={valueFormatter}
        />
      </ChartCard.Body>

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

type PlotProps = {
  /** The pre-bucketed histogram bins (use `buildResponseStats` to derive them). */
  binData: ResponseBin[];
  /** Series color + label (a shadcn ChartConfig keyed by `count`). */
  config: ChartConfig;
  heightClass?: string;
  /** First-response SLA in minutes — bins at/after it paint as breached. */
  slaMinutes: number;
  /** Bin labels for the p50 / p90 / SLA reference lines (optional). */
  p50Bin?: string;
  p90Bin?: string;
  slaBin?: string;
  withinColor?: string;
  breachedColor?: string;
  /** Override duration formatting (tooltip range). Default is "Xh Ym" / "Zm". */
  valueFormatter?: (n: number) => string;
};

/**
 * Just the chart-drawing piece — the response-time histogram with the p50 / p90
 * / SLA reference marks, axes and tooltip but no card chrome, narrative, or
 * states. Takes the pre-bucketed bins (derive them with `buildResponseStats`) so
 * you can drop it into your own layout or compose several side by side.
 */
export function SupportResponseDistributionPlot({
  binData,
  config,
  heightClass = "h-64",
  slaMinutes,
  p50Bin,
  p90Bin,
  slaBin,
  withinColor = "var(--chart-1)",
  breachedColor = "var(--chart-negative)",
  valueFormatter,
}: PlotProps) {
  const fmt = valueFormatter ?? fmtDuration;

  return (
    <ChartContainer config={config} className={cn(heightClass, "w-full")}>
      <BarChart data={binData} margin={{ left: 0, right: 8, top: 16, bottom: 4 }}>
        <XAxis
          dataKey="label"
          tickLine={false}
          axisLine={false}
          tickMargin={8}
          fontSize={9}
          interval={0}
          className="smallcaps tabular"
        />
        <YAxis
          allowDecimals={false}
          tickLine={false}
          axisLine={false}
          width={28}
          fontSize={10}
          className="smallcaps tabular"
        />
        <ChartTooltip
          cursor={{ fill: "var(--muted)", fillOpacity: 0.4 }}
          content={
            <ChartTooltipContent
              labelFormatter={(_v, payload) => {
                const b = payload?.[0]?.payload as ResponseBin | undefined;
                if (!b) return "";
                const slaTag = b.lo >= slaMinutes ? " · breached" : " · within SLA";
                return `${fmt(b.lo)}–${fmt(b.hi)}${slaTag}`;
              }}
              formatter={(value) => (
                <span className="tabular">
                  {Number(value)}
                  <span className="text-muted-foreground"> tickets</span>
                </span>
              )}
            />
          }
        />
        {p50Bin ? (
          <ReferenceLine
            x={p50Bin}
            stroke="var(--foreground)"
            strokeOpacity={0.45}
            strokeDasharray="3 3"
            label={{
              value: "p50",
              position: "top",
              fontSize: 9,
              fill: "var(--muted-foreground)",
            }}
          />
        ) : null}
        {p90Bin ? (
          <ReferenceLine
            x={p90Bin}
            stroke="var(--foreground)"
            strokeOpacity={0.45}
            strokeDasharray="3 3"
            label={{
              value: "p90",
              position: "top",
              fontSize: 9,
              fill: "var(--muted-foreground)",
            }}
          />
        ) : null}
        {slaBin ? (
          <ReferenceLine
            x={slaBin}
            stroke="var(--chart-negative)"
            strokeDasharray="4 4"
            label={{
              value: "SLA",
              position: "insideBottomRight",
              fontSize: 9,
              fill: "var(--muted-foreground)",
            }}
          />
        ) : null}
        <SheenDefs />
        <Bar
          dataKey="count"
          radius={[2, 2, 0, 0]}
          isAnimationActive={false}
          shape={sheenBar}
        >
          {binData.map((b) => (
            <Cell
              key={b.label}
              fill={b.lo >= slaMinutes ? breachedColor : withinColor}
            />
          ))}
        </Bar>
      </BarChart>
    </ChartContainer>
  );
}