"use client";
import * as React from "react";
export type UptimeStatus = "up" | "degraded" | "down" | "none";
export interface UptimeDay {
/** "yyyy-mm-dd". */
date: string;
status: UptimeStatus;
/** Uptime for the day in percent (0–100). */
uptime?: number;
/** Incident or maintenance note shown in the tooltip. */
note?: string;
}
export interface UptimeBarProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
/** Days in chronological order; the most recent ones are shown. */
days: UptimeDay[];
/** Service name; labels the grid and the header. */
label?: string;
/** Maximum number of bars (days). Fewer are shown when the container is too narrow. */
maxBars?: number;
/** Narrowest a bar may get before older days are dropped, in px. */
minBarWidth?: number;
/** Bar height in px. */
barHeight?: number;
/** Gap between bars in px. */
gap?: number;
/** Bar corner radius in px. */
radius?: number;
/** Header with the current status and overall uptime. */
showSummary?: boolean;
/** Legend with a key for each status. */
showLegend?: boolean;
upColor?: string;
degradedColor?: string;
downColor?: string;
/** Locale for dates and percentages. */
locale?: string;
}
const STATUS_TEXT: Record<UptimeStatus, string> = {
up: "Operational",
degraded: "Degraded performance",
down: "Outage",
none: "No data",
};
/** Estimated uptime when a day has no explicit `uptime`. */
const ASSUMED_UPTIME: Record<Exclude<UptimeStatus, "none">, number> = { up: 100, degraded: 100, down: 0 };
/** Fill per status. Degraded days are hatched and empty days outlined, so colour is never the only cue. */
function barStyle(status: UptimeStatus): React.CSSProperties {
switch (status) {
case "up":
return { background: "var(--ub-up)" };
case "degraded":
return {
background: "repeating-linear-gradient(135deg, var(--ub-degraded) 0 3px, color-mix(in oklab, var(--ub-degraded) 55%, transparent) 3px 5px)",
};
case "down":
return { background: "var(--ub-down)" };
default:
return {};
}
}
interface Tip {
index: number;
x: number;
y: number;
arrow: number;
}
/**
* Status-page style uptime bars: one thin bar per day, a computed overall uptime, a legend and a
* tooltip on hover, tap or keyboard focus. The bars form a one-row ARIA grid with roving focus,
* and the number of bars adapts to the container width.
*/
export function UptimeBar({
days,
label = "Uptime",
maxBars = 90,
minBarWidth = 3,
barHeight = 32,
gap = 2,
radius = 1,
showSummary = true,
showLegend = true,
upColor = "#2FBF71",
degradedColor = "#F5B83D",
downColor = "#E5484D",
locale = "en-GB",
className = "",
style,
...props
}: UptimeBarProps) {
const id = React.useId();
const summaryId = `${id}-summary`;
const rootRef = React.useRef<HTMLDivElement>(null);
const barsRef = React.useRef<HTMLDivElement>(null);
const cellRefs = React.useRef<(HTMLDivElement | null)[]>([]);
// How many bars fit; unknown on the server, so all `maxBars` render until measured.
const [fit, setFit] = React.useState(Infinity);
const [active, setActive] = React.useState<number | null>(null);
const [tip, setTip] = React.useState<Tip | null>(null);
React.useEffect(() => {
const el = barsRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
setFit(Math.max(1, Math.floor((entry.contentRect.width + gap) / (Math.max(minBarWidth, 1) + gap))));
});
ro.observe(el);
return () => ro.disconnect();
}, [gap, minBarWidth]);
const formats = React.useMemo(() => {
const safe = <T,>(make: (l: string) => T, fallback: () => T) => {
try {
return make(locale);
} catch {
return fallback();
}
};
return {
date: safe(
(l) => new Intl.DateTimeFormat(l, { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" }),
() => new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", year: "numeric", timeZone: "UTC" }),
),
percent: safe(
(l) => new Intl.NumberFormat(l, { maximumFractionDigits: 2, minimumFractionDigits: 2 }),
() => new Intl.NumberFormat("en-GB", { maximumFractionDigits: 2, minimumFractionDigits: 2 }),
),
};
}, [locale]);
const count = Math.max(0, Math.min(maxBars, days.length, fit));
const visible = days.slice(days.length - count);
const formatDate = (iso: string) => {
const t = Date.parse(`${iso}T00:00:00Z`);
return Number.isNaN(t) ? iso : formats.date.format(new Date(t));
};
// Overall uptime across days with data.
const measured = visible.filter((d) => d.status !== "none");
const overall = measured.length
? measured.reduce((sum, d) => sum + (d.uptime ?? ASSUMED_UPTIME[d.status as Exclude<UptimeStatus, "none">]), 0) / measured.length
: null;
const incidents = visible.filter((d) => d.status === "down" || d.status === "degraded").length;
const latest = visible[visible.length - 1];
const overallText = overall === null ? "—" : `${formats.percent.format(overall)}%`;
const describe = (d: UptimeDay) =>
`${formatDate(d.date)}: ${STATUS_TEXT[d.status]}${d.uptime !== undefined ? `, ${formats.percent.format(d.uptime)}% uptime` : ""}${d.note ? `. ${d.note}` : ""}`;
const tabStop = active !== null && active < count ? active : count - 1;
const showTip = (index: number, target: HTMLElement) => {
const root = rootRef.current;
if (!root) return;
const r = root.getBoundingClientRect();
const b = target.getBoundingClientRect();
const centre = b.left + b.width / 2 - r.left;
const half = 110; // about half the tooltip width; keeps it inside the component
const x = r.width > half * 2 ? Math.min(Math.max(centre, half), r.width - half) : r.width / 2;
setTip({ index, x, y: b.top - r.top, arrow: centre - x });
};
const focusBar = (index: number) => {
const next = Math.max(0, Math.min(count - 1, index));
setActive(next);
cellRefs.current[next]?.focus();
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const moves: Record<string, () => void> = {
ArrowLeft: () => focusBar(tabStop - 1),
ArrowRight: () => focusBar(tabStop + 1),
PageUp: () => focusBar(tabStop - 7),
PageDown: () => focusBar(tabStop + 7),
Home: () => focusBar(0),
End: () => focusBar(count - 1),
Escape: () => setTip(null),
};
const action = moves[event.key];
if (!action) return;
event.preventDefault();
action();
};
const tipDay = tip ? visible[tip.index] : undefined;
const dot = (status: UptimeStatus, extra = "") => (
<span
aria-hidden="true"
className={`inline-block shrink-0 ${status === "none" ? "border border-dashed border-zinc-900/30 dark:border-white/30" : ""} ${extra}`}
style={{ ...barStyle(status), borderRadius: Math.min(radius, 2) }}
/>
);
return (
<div
ref={rootRef}
className={`relative w-full text-zinc-900 dark:text-zinc-100 ${className}`}
style={{ ["--ub-up" as string]: upColor, ["--ub-degraded" as string]: degradedColor, ["--ub-down" as string]: downColor, ...style }}
{...props}
>
<p id={summaryId} className="sr-only">
{`${label}: ${overallText} uptime over the last ${count} ${count === 1 ? "day" : "days"}, ${incidents} ${incidents === 1 ? "day" : "days"} with incidents. Use the arrow keys to move between days.`}
</p>
{showSummary && (
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<div className="flex min-w-0 items-center gap-2">
{latest && dot(latest.status, "size-2 rounded-full")}
<span className="truncate text-sm font-medium tracking-tight">{label}</span>
{latest && (
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-500 dark:text-zinc-400">{STATUS_TEXT[latest.status]}</span>
)}
</div>
<span className="font-mono text-xs tabular-nums text-zinc-600 dark:text-zinc-300">
<span className="text-zinc-900 dark:text-zinc-100">{overallText}</span> uptime
</span>
</div>
)}
<div
ref={barsRef}
role="grid"
aria-label={`${label}, daily status`}
aria-describedby={summaryId}
aria-readonly="true"
className="w-full"
onKeyDown={handleKeyDown}
onPointerLeave={() => setTip(null)}
>
<div role="row" className="flex w-full" style={{ gap, height: barHeight }}>
{visible.map((d, i) => (
<div
key={`${d.date}-${i}`}
ref={(el) => {
cellRefs.current[i] = el;
}}
role="gridcell"
tabIndex={i === tabStop ? 0 : -1}
aria-label={describe(d)}
onFocus={(event) => {
setActive(i);
showTip(i, event.currentTarget);
}}
onBlur={() => setTip(null)}
onPointerEnter={(event) => {
if (event.pointerType === "mouse") showTip(i, event.currentTarget);
}}
onPointerDown={(event) => showTip(i, event.currentTarget)}
className={`min-w-0 flex-1 outline-offset-2 transition-opacity duration-150 focus-visible:outline-2 focus-visible:outline-zinc-900 motion-reduce:transition-none dark:focus-visible:outline-white ${
d.status === "none" ? "border border-dashed border-zinc-900/20 bg-zinc-900/[0.03] dark:border-white/20 dark:bg-white/[0.03]" : ""
} ${tip && tip.index !== i ? "opacity-55" : "opacity-100"}`}
style={{ ...barStyle(d.status), borderRadius: radius }}
/>
))}
</div>
</div>
<div aria-hidden="true" className="mt-2 flex items-center gap-3 font-mono text-[10px] uppercase tracking-[0.1em] text-zinc-500 dark:text-zinc-400">
<span className="shrink-0">{visible[0] ? formatDate(visible[0].date) : ""}</span>
<span className="h-px flex-1 bg-zinc-900/10 dark:bg-white/10" />
<span className="shrink-0">
{count} {count === 1 ? "day" : "days"}
</span>
<span className="h-px flex-1 bg-zinc-900/10 dark:bg-white/10" />
<span className="shrink-0">{latest ? formatDate(latest.date) : ""}</span>
</div>
{showLegend && (
<ul className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-zinc-500 dark:text-zinc-400">
{(["up", "degraded", "down", "none"] as const).map((status) => (
<li key={status} className="flex items-center gap-1.5">
{dot(status, "h-3 w-1.5")}
{STATUS_TEXT[status]}
</li>
))}
</ul>
)}
{tip && tipDay && (
<div
role="presentation"
aria-hidden="true"
className="pointer-events-none absolute z-10 w-max max-w-[220px] -translate-x-1/2 -translate-y-full rounded-md border border-zinc-900/10 bg-white px-2.5 py-2 text-xs shadow-[0_8px_24px_-12px_rgba(0,0,0,0.35)] dark:border-white/10 dark:bg-zinc-900"
style={{ left: tip.x, top: tip.y - 8 }}
>
<span className="block font-mono text-[10px] uppercase tracking-[0.1em] text-zinc-500 dark:text-zinc-400">{formatDate(tipDay.date)}</span>
<span className="mt-1 flex items-center gap-1.5 font-medium">
{dot(tipDay.status, "h-3 w-1.5")}
{STATUS_TEXT[tipDay.status]}
{tipDay.uptime !== undefined && (
<span className="ml-auto pl-2 font-mono text-[11px] tabular-nums text-zinc-500 dark:text-zinc-400">{formats.percent.format(tipDay.uptime)}%</span>
)}
</span>
{tipDay.note && <span className="mt-1 block leading-snug text-zinc-600 dark:text-zinc-300">{tipDay.note}</span>}
<span
className="absolute top-full size-2 -translate-x-1/2 -translate-y-1 rotate-45 border-b border-r border-zinc-900/10 bg-white dark:border-white/10 dark:bg-zinc-900"
style={{ left: `calc(50% + ${tip.arrow}px)` }}
/>
</div>
)}
</div>
);
}