From 3e8e3a78eb351f6efe53e80c0ee884f9186787f0 Mon Sep 17 00:00:00 2001 From: Fabio Wakim Trentini Date: Thu, 11 Jun 2026 16:31:10 -0300 Subject: [PATCH] style(dashboard): rebuild Cycle Time charts in Recharts to match Stabilization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two per-repo Cycle Time charts ("% PRs Merged within 1 Day" and "Cycle Time Distribution") were hand-rolled CSS bars; the prior tweak only thickened them, which wasn't the ask. Rebuild both with the same Recharts horizontal-bar pattern as the Stabilization Distribution chart: category Y-axis of repo names, % X-axis with dashed gridlines, fixed 200px container, and the existing color ramp / bucket palette via CSS vars. - Ranking: single bar per repo (% merged within 1 day), color-ramped. - Distribution: stacked bar per repo, buckets normalized to 100%, with the legend kept below and a per-bucket tooltip. Both sorted fastest→slowest so the two charts align row-for-row. No data or types changed — same CycleTimeData.perRepo input. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../[tenant]/dashboard/sections/CycleTime.tsx | 305 +++++++++++------- 1 file changed, 192 insertions(+), 113 deletions(-) diff --git a/platform/src/app/[tenant]/dashboard/sections/CycleTime.tsx b/platform/src/app/[tenant]/dashboard/sections/CycleTime.tsx index f58596d..a651c95 100644 --- a/platform/src/app/[tenant]/dashboard/sections/CycleTime.tsx +++ b/platform/src/app/[tenant]/dashboard/sections/CycleTime.tsx @@ -1,6 +1,16 @@ "use client"; import { Zap } from "lucide-react"; +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + CartesianGrid, + Cell, +} from "recharts"; import { MetricCard } from "@/components/charts/MetricCard"; import { @@ -11,7 +21,6 @@ import { CardTitle, } from "@/components/ui/card"; import { useTranslation } from "@/hooks/useTranslation"; -import { cn } from "@/lib/utils"; import type { CycleTimeData } from "@/types/org-summary"; // Insight banner is only shown once cycle-time data is dense enough @@ -19,13 +28,46 @@ import type { CycleTimeData } from "@/types/org-summary"; // render the section but hide the headline. const INSIGHT_MIN_MERGED = 50; -// Cutoffs for the "% merged within 24h" horizontal bar color ramp. -// Tuned so a repo that ships in a day most of the time reads green, -// "mixed" reads yellow, and slow repos read orange/red. +// Cutoffs for the "% merged within 24h" bar color ramp. Tuned so a repo that +// ships in a day most of the time reads green, "mixed" reads yellow, and slow +// repos read orange. const FAST_GREEN_PCT = 0.8; const MID_YELLOW_PCT = 0.65; const SLOW_ORANGE_PCT = 0.5; +// Match the Stabilization Distribution chart for a consistent dashboard look. +const CHART_HEIGHT = 200; +const Y_AXIS_WIDTH = 170; +const AXIS_TICK = { fontSize: 10, fill: "var(--color-muted-foreground)" }; + +// Cycle-time buckets, in fastest→slowest order, shared by the stacked bars, +// the tooltip, and the legend. +const BUCKETS = [ + { + key: "same_day", + labelKey: "sameDay", + color: "var(--color-bucket-same-day)", + }, + { key: "one_day", labelKey: "oneDay", color: "var(--color-bucket-one-day)" }, + { + key: "two_to_three_days", + labelKey: "twoThree", + color: "var(--color-bucket-two-three)", + }, + { + key: "four_to_seven_days", + labelKey: "fourSeven", + color: "var(--color-bucket-four-seven)", + }, + { + key: "seven_plus_days", + labelKey: "sevenPlus", + color: "var(--color-bucket-seven-plus)", + }, +] as const; + +type RepoRow = CycleTimeData["perRepo"][number]; + interface CycleTimeProps { data: CycleTimeData; } @@ -114,136 +156,173 @@ export function CycleTime({ data }: CycleTimeProps) { ); } -interface Row { - name: string; - merged: number; - pctWithin24h: number; - buckets: CycleTimeData["perRepo"][number]["buckets"]; +function truncateRepo(value: string): string { + return value.length > 26 ? `…${value.slice(-25)}` : value; +} + +const tickPct = (v: number) => `${(v * 100).toFixed(0)}%`; + +function rampColor(pct: number): string { + if (pct >= FAST_GREEN_PCT) return "var(--color-signal-green)"; + if (pct >= MID_YELLOW_PCT) return "var(--color-bucket-one-day)"; + if (pct >= SLOW_ORANGE_PCT) return "var(--color-signal-yellow)"; + return "var(--color-bucket-four-seven)"; } -function RankingChart({ rows }: { rows: Row[] }) { +/** Single bar per repo — share of PRs merged within a day. Mirrors the + * Stabilization Distribution chart (horizontal Recharts bars + % axis). */ +function RankingChart({ rows }: { rows: RepoRow[] }) { const { t } = useTranslation(); + const data = [...rows].sort((a, b) => b.pctWithin24h - a.pctWithin24h); + return ( -
- {rows.map((row) => ( -
- - {row.name} - -
-
-
- - {formatPct(row.pctWithin24h)} - -
- ))} -
+ + + + + + { + const p = payload?.[0]?.payload as RepoRow | undefined; + if (!p) return null; + return ( +
+

{p.name}

+

+ {formatPct(p.pctWithin24h)} ·{" "} + {t("dashboard.cycleTime.tooltips.ranking", { + merged: p.merged, + })} +

+
+ ); + }} + /> + + {data.map((row) => ( + + ))} + +
+
); } -function DistributionChart({ rows }: { rows: Row[] }) { +/** Stacked bar per repo — cycle-time bucket mix normalized to 100%. Same + * Recharts horizontal layout as the ranking chart. */ +function DistributionChart({ rows }: { rows: RepoRow[] }) { + const { t } = useTranslation(); + const data = [...rows] + .sort((a, b) => b.pctWithin24h - a.pctWithin24h) + .map((row) => { + const total = BUCKETS.reduce((sum, b) => sum + row.buckets[b.key], 0); + const out: Record = { name: row.name }; + for (const b of BUCKETS) { + out[b.key] = total > 0 ? row.buckets[b.key] / total : 0; + } + return out; + }) + .filter((d) => BUCKETS.some((b) => (d[b.key] as number) > 0)); + return ( -
- {rows.map((row) => { - const total = - row.buckets.same_day + - row.buckets.one_day + - row.buckets.two_to_three_days + - row.buckets.four_to_seven_days + - row.buckets.seven_plus_days; - if (total === 0) return null; - const pct = (n: number) => (n / total) * 100; - return ( -
- - {row.name} - -
- {row.buckets.same_day > 0 && ( - - )} - {row.buckets.one_day > 0 && ( - - )} - {row.buckets.two_to_three_days > 0 && ( - - )} - {row.buckets.four_to_seven_days > 0 && ( - - )} - {row.buckets.seven_plus_days > 0 && ( - - )} -
-
- ); - })} -
+ + + + + + { + const p = payload?.[0]?.payload as + | Record + | undefined; + if (!p) return null; + return ( +
+

{p.name as string}

+ {BUCKETS.map((b) => { + const frac = (p[b.key] as number) ?? 0; + if (frac <= 0) return null; + return ( +

+ + {t(`dashboard.cycleTime.buckets.${b.labelKey}`)}:{" "} + {(frac * 100).toFixed(0)}% +

+ ); + })} +
+ ); + }} + /> + {BUCKETS.map((b) => ( + + ))} +
+
); } function DistributionLegend() { const { t } = useTranslation(); - const items = [ - { key: "sameDay", className: "bg-bucket-same-day" }, - { key: "oneDay", className: "bg-bucket-one-day" }, - { key: "twoThree", className: "bg-bucket-two-three" }, - { key: "fourSeven", className: "bg-bucket-four-seven" }, - { key: "sevenPlus", className: "bg-bucket-seven-plus" }, - ] as const; return (
- {items.map((i) => ( - - - {t(`dashboard.cycleTime.buckets.${i.key}`)} + {BUCKETS.map((b) => ( + + + {t(`dashboard.cycleTime.buckets.${b.labelKey}`)} ))}
); } -function rampClass(pct: number): string { - if (pct >= FAST_GREEN_PCT) return "bg-signal-green"; - if (pct >= MID_YELLOW_PCT) return "bg-bucket-one-day"; // lime/green - if (pct >= SLOW_ORANGE_PCT) return "bg-signal-yellow"; - return "bg-bucket-four-seven"; // orange — slow repo -} - function formatPct(value: number | null): string { if (value === null || value === undefined) return "—"; return `${(value * 100).toFixed(1).replace(".", ",")}%`;