1import { useState } from "react";23/** Grouped/stacked daily bars: ok vs bad. Thin marks, rounded ends, 2px gaps, hover tooltip. */4export function DailyBars({ data, width = 640, height = 170 }: { data: { day: string; ok: number; bad: number; n: number }[]; width?: number; height?: number }) {5 const [tip, setTip] = useState<{ x: number; y: number; d: (typeof data)[number] } | null>(null);6 if (!data.length) return <div className="empty">No executions in the last 14 days.</div>;7 const m = { l: 34, r: 8, t: 10, b: 24 };8 const w = width - m.l - m.r, h = height - m.t - m.b;9 const max = Math.max(1, ...data.map((d) => d.ok + d.bad));10 const bw = w / data.length;11 const ticks = [0, 0.5, 1].map((f) => Math.round(max * f));12 return (13 <div>14 <div className="legend"><span><i style={{ background: "var(--s1)" }} />succeeded</span><span><i style={{ background: "var(--s8)" }} />failed / blocked</span></div>15 <svg className="chart" viewBox={`0 0 ${width} ${height}`} role="img" aria-label="Executions per day, succeeded versus failed">16 <g className="grid">{ticks.map((t) => <line key={t} x1={m.l} x2={width - m.r} y1={m.t + h - (t / max) * h} y2={m.t + h - (t / max) * h} />)}</g>17 {ticks.map((t) => <text key={t} x={m.l - 4} y={m.t + h - (t / max) * h + 4} textAnchor="end">{t}</text>)}18 {data.map((d, i) => {19 const x = m.l + i * bw + bw * 0.2, bwi = Math.max(3, bw * 0.6);20 const hOk = (d.ok / max) * h, hBad = (d.bad / max) * h;21 const yBad = m.t + h - hBad, yOk = yBad - hOk - (hOk && hBad ? 2 : 0);22 return (23 <g key={d.day} onMouseEnter={(e) => setTip({ x: e.clientX, y: e.clientY, d })} onMouseMove={(e) => setTip({ x: e.clientX, y: e.clientY, d })} onMouseLeave={() => setTip(null)}>24 <rect x={m.l + i * bw} y={m.t} width={bw} height={h} fill="transparent" />25 {hBad > 0 && <rect x={x} y={yBad} width={bwi} height={hBad} rx={3} fill="var(--s8)" />}26 {hOk > 0 && <rect x={x} y={yOk} width={bwi} height={hOk} rx={3} fill="var(--s1)" />}27 {(i % Math.ceil(data.length / 7) === 0 || data.length <= 7) && <text x={x + bwi / 2} y={height - 6} textAnchor="middle">{d.day.slice(5)}</text>}28 </g>29 );30 })}31 <line className="axis" x1={m.l} x2={width - m.r} y1={m.t + h} y2={m.t + h} />32 </svg>33 {tip && <div className="tooltip" style={{ left: tip.x + 12, top: tip.y + 12 }}><b>{tip.d.day}</b><br />succeeded {tip.d.ok} · failed/blocked {tip.d.bad} · total {tip.d.n}</div>}34 </div>35 );36}3738/** Horizontal breakdown bars for categorical counts (one hue, labels in text tokens). */39export function Breakdown({ items, color = "var(--s1)" }: { items: { label: string; n: number; tone?: string }[]; color?: string }) {40 if (!items.length) return <div className="empty">Nothing yet.</div>;41 const max = Math.max(1, ...items.map((i) => i.n));42 return (43 <div className="stack">44 {items.map((it) => (45 <div key={it.label} className="row" style={{ gap: 8, flexWrap: "nowrap" }}>46 <span style={{ width: 150, fontSize: 13, color: "var(--text-2)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{it.label}</span>47 <svg width="100%" height="12" style={{ flex: 1 }} role="img" aria-label={`${it.label}: ${it.n}`}><rect x={0} y={1} width={`${(it.n / max) * 100}%`} height={10} rx={3} fill={it.tone ? `var(--${it.tone})` : color} /></svg>48 <span style={{ width: 44, textAlign: "right", fontVariantNumeric: "tabular-nums", fontSize: 13 }}>{it.n}</span>49 </div>50 ))}51 </div>52 );53}5455export function Sparkline({ points, width = 320, height = 48, label }: { points: { x: string; y: number }[]; width?: number; height?: number; label: string }) {56 const [tip, setTip] = useState<{ x: number; y: number; p: (typeof points)[number] } | null>(null);57 if (points.length < 2) return <div className="hint">not enough data for {label}</div>;58 const max = Math.max(1, ...points.map((p) => p.y));59 const step = width / (points.length - 1);60 const path = points.map((p, i) => `${i === 0 ? "M" : "L"}${i * step},${height - 4 - (p.y / max) * (height - 8)}`).join(" ");61 return (62 <div>63 <svg className="chart" viewBox={`0 0 ${width} ${height}`} role="img" aria-label={label} onMouseLeave={() => setTip(null)}64 onMouseMove={(e) => { const r = (e.currentTarget as SVGSVGElement).getBoundingClientRect(); const i = Math.round(((e.clientX - r.left) / r.width) * (points.length - 1)); const p = points[Math.max(0, Math.min(points.length - 1, i))]; if (p) setTip({ x: e.clientX, y: e.clientY, p }); }}>65 <path d={path} stroke="var(--s1)" strokeWidth={2} fill="none" />66 </svg>67 {tip && <div className="tooltip" style={{ left: tip.x + 12, top: tip.y + 12 }}>{tip.p.x}: {tip.p.y}</div>}68 </div>69 );70}71