Response Time Distribution
freeA 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-distributionFirst-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>
);
}