diff --git a/iris/metrics/aggregator.py b/iris/metrics/aggregator.py
index 7a7658c..7d0569a 100644
--- a/iris/metrics/aggregator.py
+++ b/iris/metrics/aggregator.py
@@ -311,6 +311,9 @@ def aggregate(
flow_efficiency_kwargs["time_in_phase_median_hours"] = (
flow_efficiency_result.time_in_phase_median_hours
)
+ flow_efficiency_kwargs["flow_pr_count"] = (
+ flow_efficiency_result.pr_count
+ )
if flow_efficiency_result.median_time_to_first_review_hours is not None:
flow_efficiency_kwargs["median_time_to_first_review_hours"] = (
flow_efficiency_result.median_time_to_first_review_hours
diff --git a/iris/models/metrics.py b/iris/models/metrics.py
index bbf743e..95aa490 100644
--- a/iris/models/metrics.py
+++ b/iris/models/metrics.py
@@ -132,6 +132,10 @@ class ReportMetrics:
flow_efficiency_by_origin: dict[str, float] | None = None
time_in_phase_median_hours: dict[str, float] | None = None
median_time_to_first_review_hours: float | None = None
+ # Count of merged PRs that survived Flow Efficiency's filters and carry a
+ # phase decomposition. The honest denominator for flow coverage — NOT
+ # pr_merged_count (which also counts PRs with no phase data).
+ flow_pr_count: int | None = None
# Human Review Coverage — fraction of merged PRs with genuine human review
# (optional — None when no merged PR exists in the window). Disambiguates
diff --git a/platform/lib/queries/cycle-time-flow.ts b/platform/lib/queries/cycle-time-flow.ts
new file mode 100644
index 0000000..41ccfb4
--- /dev/null
+++ b/platform/lib/queries/cycle-time-flow.ts
@@ -0,0 +1,208 @@
+/**
+ * Cycle Time — flow decomposition + honest verdict selection.
+ *
+ * Pure functions behind the dashboard's Cycle Time card. They turn the
+ * per-repo phase medians the engine already emits (`time_in_phase_median_hours`)
+ * into an org-level decomposition and a *computed* verdict — replacing the old
+ * hardcoded editorial sentence about where bottlenecks "live".
+ *
+ * The verdict only ever describes the code window it actually measures
+ * (PR open -> merge); it never claims anything about the "before" (demand)
+ * or "after" (deploy) windows, which the engine does not measure here.
+ *
+ * Because of that, the dominant phase and its share are computed over
+ * WINDOW_PHASES only — `coding` (first commit -> PR open) is authoring time
+ * that happens BEFORE the window, so it never wins the verdict. And the share
+ * denominator is the SUM of per-phase medians: an approximation of where time
+ * concentrates, NOT the cycle-time median (which comes from a separate
+ * open->merge aggregation and is shown alongside).
+ *
+ * No I/O, no per-PR data: fully unit-testable.
+ */
+import type {
+ DominantPhase,
+ FlowDecomposition,
+ FlowPhaseKey,
+} from "@/types/org-summary";
+
+/** Canonical phase order (earliest lifecycle stage first). */
+export const FLOW_PHASE_ORDER: readonly FlowPhaseKey[] = [
+ "coding",
+ "awaiting_first_review",
+ "in_review_active",
+ "in_review_wait",
+ "awaiting_merge",
+];
+
+/**
+ * Phases inside the measured window (PR open -> merge). Excludes `coding`,
+ * which is pre-open authoring time. The verdict's dominant phase and its
+ * share are computed over these only, so the card never blames a stage that
+ * happens before the PR exists.
+ */
+export const WINDOW_PHASES: readonly FlowPhaseKey[] = [
+ "awaiting_first_review",
+ "in_review_active",
+ "in_review_wait",
+ "awaiting_merge",
+];
+
+/** Phases that are *waiting* (queue) rather than *active* (work being done). */
+export const WAIT_PHASES: ReadonlySet = new Set([
+ "awaiting_first_review",
+ "in_review_wait",
+ "awaiting_merge",
+]);
+
+/** One repo's contribution to the org-level flow aggregation. */
+export interface FlowRow {
+ /**
+ * Merged PRs that actually carry a phase decomposition (engine
+ * `flow_pr_count`) — both the aggregation weight and the coverage
+ * numerator. Using the real decomposed count (not total merged) is what
+ * keeps `flowCoveragePct` honest instead of pinned at ~100%.
+ */
+ weight: number;
+ /** Per-phase median hours for this repo (engine output). */
+ phases: Partial>;
+ ttfrHours?: number | null;
+ flowEfficiency?: number | null;
+}
+
+function round(value: number, places: number): number {
+ const factor = 10 ** places;
+ return Math.round(value * factor) / factor;
+}
+
+/**
+ * Aggregate per-repo phase medians into an org-level decomposition, weighted
+ * by each repo's decomposed-PR count (`FlowRow.weight`). This is a weighted
+ * average of per-repo medians — an approximation (per-PR timings are never
+ * persisted, by design), so callers MUST gate display on `flowCoveragePct`.
+ * The dominant phase + share are computed over WINDOW_PHASES (coding is
+ * pre-open authoring time and never wins). Returns null when no row carried
+ * decomposed phase data.
+ */
+export function summarizeFlow(
+ rows: FlowRow[],
+ totalMerged: number,
+): FlowDecomposition | null {
+ const weighted: Record = {
+ coding: 0,
+ awaiting_first_review: 0,
+ in_review_active: 0,
+ in_review_wait: 0,
+ awaiting_merge: 0,
+ };
+ let weight = 0;
+ let ttfrWeighted = 0;
+ let ttfrWeight = 0;
+ let effWeighted = 0;
+ let effWeight = 0;
+
+ for (const row of rows) {
+ if (!row.phases || row.weight <= 0) continue;
+ weight += row.weight;
+ for (const key of FLOW_PHASE_ORDER) {
+ weighted[key] += (row.phases[key] ?? 0) * row.weight;
+ }
+ if (row.ttfrHours != null) {
+ ttfrWeighted += row.ttfrHours * row.weight;
+ ttfrWeight += row.weight;
+ }
+ if (row.flowEfficiency != null) {
+ effWeighted += row.flowEfficiency * row.weight;
+ effWeight += row.weight;
+ }
+ }
+
+ if (weight <= 0) return null;
+
+ const phaseMedianHours = {} as Record;
+ for (const key of FLOW_PHASE_ORDER) {
+ phaseMedianHours[key] = round(weighted[key] / weight, 1);
+ }
+
+ // Dominant phase + share are computed over the measured window only
+ // (WINDOW_PHASES excludes `coding`, the pre-PR-open authoring time).
+ // windowHours is the SUM of those phase medians — an approximation of where
+ // time concentrates, NOT the cycle-time median.
+ const windowHours = WINDOW_PHASES.reduce(
+ (sum, key) => sum + phaseMedianHours[key],
+ 0,
+ );
+ let dominantPhase: DominantPhase | null = null;
+ if (windowHours > 0) {
+ // Ties resolve to the earlier (lower-index) window phase — deterministic.
+ const key = WINDOW_PHASES.reduce((best, candidate) =>
+ phaseMedianHours[candidate] > phaseMedianHours[best] ? candidate : best,
+ );
+ dominantPhase = {
+ key,
+ hours: phaseMedianHours[key],
+ sharePct: round((phaseMedianHours[key] / windowHours) * 100, 1),
+ };
+ }
+
+ return {
+ phaseMedianHours,
+ medianTimeToFirstReviewHours:
+ ttfrWeight > 0 ? round(ttfrWeighted / ttfrWeight, 1) : null,
+ flowEfficiencyMedian:
+ effWeight > 0 ? round(effWeighted / effWeight, 3) : null,
+ dominantPhase,
+ prsWithFlow: weight,
+ flowCoveragePct: totalMerged > 0 ? weight / totalMerged : null,
+ };
+}
+
+export type CycleTimeVerdictVariant =
+ | "verdict"
+ | "lowCoverage"
+ | "noFlow"
+ | "none";
+
+export interface CycleTimeVerdict {
+ variant: CycleTimeVerdictVariant;
+ dominantPhase: DominantPhase | null;
+ prsWithFlow: number | null;
+ /** 0.0–1.0 */
+ flowCoveragePct: number | null;
+}
+
+/**
+ * Decide which honest verdict the card shows.
+ *
+ * - `none` — not enough merged PRs (or no within-24h figure): show nothing.
+ * - `noFlow` — dense enough, but no phase decomposition: state the KPIs and
+ * explicitly make NO claim about where the bottleneck is.
+ * - `lowCoverage` — decomposition exists but covers too little: show as a sample.
+ * - `verdict` — full computed read of the dominant code-window phase.
+ */
+export function selectCycleTimeVerdict(
+ data: {
+ totalPRsMerged: number;
+ pctMergedWithin24h: number | null;
+ flow: FlowDecomposition | null;
+ },
+ opts: { minMerged: number; coverageFloor: number },
+): CycleTimeVerdict {
+ const base = {
+ dominantPhase: data.flow?.dominantPhase ?? null,
+ prsWithFlow: data.flow?.prsWithFlow ?? null,
+ flowCoveragePct: data.flow?.flowCoveragePct ?? null,
+ };
+ if (
+ data.totalPRsMerged < opts.minMerged ||
+ data.pctMergedWithin24h === null
+ ) {
+ return { variant: "none", ...base };
+ }
+ if (!data.flow || !data.flow.dominantPhase) {
+ return { variant: "noFlow", ...base };
+ }
+ if ((data.flow.flowCoveragePct ?? 0) < opts.coverageFloor) {
+ return { variant: "lowCoverage", ...base };
+ }
+ return { variant: "verdict", ...base };
+}
diff --git a/platform/lib/queries/org-summary.ts b/platform/lib/queries/org-summary.ts
index e065156..96cd8ae 100644
--- a/platform/lib/queries/org-summary.ts
+++ b/platform/lib/queries/org-summary.ts
@@ -5,6 +5,7 @@
import type { SupabaseClient } from "@supabase/supabase-js";
+import { summarizeFlow, type FlowRow } from "@/lib/queries/cycle-time-flow";
import { DEFAULT_WINDOW_DAYS } from "@/lib/queries/temporal";
import type { ReportMetrics } from "@/types/metrics";
import type {
@@ -630,6 +631,9 @@ export function computeCycleTime(
type Row = NonNullable[number];
const rows: Row[] = [];
+ // Per-repo phase decomposition the engine already emits — aggregated below
+ // into the org-level "where does PR time go" read (see summarizeFlow).
+ const flowRows: FlowRow[] = [];
let totalMerged = 0;
let totalWithin24h = 0;
@@ -666,6 +670,18 @@ export function computeCycleTime(
totalMerged += merged;
totalWithin24h += buckets.same_day;
+ // Only contribute to the flow decomposition when the engine reported the
+ // real count of decomposed PRs (flow_pr_count). Older payloads without it
+ // are skipped so coverage stays honest instead of pinning at ~100%.
+ if (p.time_in_phase_median_hours && (p.flow_pr_count ?? 0) > 0) {
+ flowRows.push({
+ weight: p.flow_pr_count as number,
+ phases: p.time_in_phase_median_hours,
+ ttfrHours: p.median_time_to_first_review_hours ?? null,
+ flowEfficiency: p.flow_efficiency_median ?? null,
+ });
+ }
+
if (p.pr_median_time_to_merge_hours !== undefined) {
medianWeightedSum += p.pr_median_time_to_merge_hours * merged;
weightTotal += merged;
@@ -695,6 +711,7 @@ export function computeCycleTime(
medianHours: weightTotal > 0 ? medianWeightedSum / weightTotal : null,
meanHours: weightTotal > 0 ? meanWeightedSum / weightTotal : null,
p90Hours: maxP90,
+ flow: summarizeFlow(flowRows, totalMerged),
perRepo: rows,
};
}
diff --git a/platform/lib/translations.ts b/platform/lib/translations.ts
index c272ff6..12edd62 100644
--- a/platform/lib/translations.ts
+++ b/platform/lib/translations.ts
@@ -378,8 +378,23 @@ export const translations = {
title: "Cycle Time",
subtitle:
"How long engineering takes to deliver, from PR open to merge.",
- insight:
- "Engineering ships fast. {pct} of PRs are merged within a day. Median cycle time is {median}. Lead-time bottlenecks live before (demand/product) and after (infra, environments, deploy) — not in code execution.",
+ verdict:
+ "Within the code window (PR open → merge), the stage that consumes the most time is {phase}: {phaseHours} median, {sharePct} of measured phase time. Median cycle time: {median} · {pct} of PRs within 1 day. Based on {n} PRs with phase decomposition ({coverage} coverage).",
+ verdictLowCoverage:
+ "Partial sample: only {coverage} of merged PRs carry a phase decomposition — a hint, not a verdict. Where data exists, the longest stage is {phase} ({phaseHours}).",
+ verdictNoFlow:
+ "Median cycle time {median}; {pct} of PRs within 1 day. Phase decomposition isn't available for this view yet — without it, the dashboard makes no claim about where the bottleneck is.",
+ flowBarTitle: "Where PR time goes",
+ flowBarSubtitle:
+ "Code-window time by phase (median, weighted by decomposed PRs).",
+ waitNote: "Hatched = queue (wait) time.",
+ phaseLabels: {
+ coding: "Coding",
+ awaiting_first_review: "Awaiting first review",
+ in_review_active: "In review · active",
+ in_review_wait: "In review · wait",
+ awaiting_merge: "Awaiting merge",
+ },
kpi: {
pctWithin24h: "PRs merged within 1 day",
median: "Median cycle time",
@@ -1757,8 +1772,23 @@ export const translations = {
title: "Cycle Time",
subtitle:
"Quanto tempo a engenharia leva para entregar, do PR aberto ao merge.",
- insight:
- "A engenharia entrega rápido. {pct} dos PRs são mesclados em até 1 dia. A mediana do cycle time é {median}. Os gargalos de lead time estão antes (definição de demanda/produto) e depois (infra, ambientes, deploy) — não na execução do código.",
+ verdict:
+ "Dentro da janela de código (PR aberto → merge), a etapa que mais consome tempo é {phase}: mediana {phaseHours}, {sharePct} do tempo das fases medidas. Mediana do cycle time: {median} · {pct} dos PRs em ≤1 dia. Base: {n} PRs com decomposição de fase ({coverage} de cobertura).",
+ verdictLowCoverage:
+ "Amostra parcial: só {coverage} dos PRs mesclados têm decomposição de fase — indício, não veredito. Onde há dado, a etapa mais longa é {phase} ({phaseHours}).",
+ verdictNoFlow:
+ "Mediana do cycle time {median}; {pct} dos PRs em ≤1 dia. A decomposição por fase ainda não está disponível para este recorte — sem ela, o dashboard não afirma onde está o gargalo.",
+ flowBarTitle: "Onde o tempo do PR é gasto",
+ flowBarSubtitle:
+ "Tempo da janela de código por fase (mediana, ponderada por PRs decompostos).",
+ waitNote: "Hachurado = tempo em fila (espera).",
+ phaseLabels: {
+ coding: "Escrita de código",
+ awaiting_first_review: "Espera pelo 1º review",
+ in_review_active: "Em review · ativo",
+ in_review_wait: "Em review · espera",
+ awaiting_merge: "Espera pelo merge",
+ },
kpi: {
pctWithin24h: "PRs merged em até 1 dia",
median: "Mediana do cycle time",
diff --git a/platform/src/app/[tenant]/dashboard/sections/CycleTime.tsx b/platform/src/app/[tenant]/dashboard/sections/CycleTime.tsx
index a651c95..c0e8605 100644
--- a/platform/src/app/[tenant]/dashboard/sections/CycleTime.tsx
+++ b/platform/src/app/[tenant]/dashboard/sections/CycleTime.tsx
@@ -12,6 +12,7 @@ import {
Cell,
} from "recharts";
+import { FlowPhaseBar } from "@/components/charts/FlowPhaseBar";
import { MetricCard } from "@/components/charts/MetricCard";
import {
Card,
@@ -21,13 +22,22 @@ import {
CardTitle,
} from "@/components/ui/card";
import { useTranslation } from "@/hooks/useTranslation";
-import type { CycleTimeData } from "@/types/org-summary";
+import {
+ selectCycleTimeVerdict,
+ type CycleTimeVerdict,
+} from "@/lib/queries/cycle-time-flow";
+import { cn } from "@/lib/utils";
+import type { CycleTimeData, FlowPhaseKey } from "@/types/org-summary";
-// Insight banner is only shown once cycle-time data is dense enough
+// The verdict banner is only shown once cycle-time data is dense enough
// to make a confident statement. Below this many merged PRs we still
// render the section but hide the headline.
const INSIGHT_MIN_MERGED = 50;
+// Below this coverage the phase decomposition is shown as a partial sample,
+// never as a verdict. See selectCycleTimeVerdict.
+const COVERAGE_FLOOR = 0.6;
+
// 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.
@@ -74,9 +84,15 @@ interface CycleTimeProps {
export function CycleTime({ data }: CycleTimeProps) {
const { t } = useTranslation();
- const showInsight =
- data.totalPRsMerged >= INSIGHT_MIN_MERGED &&
- data.pctMergedWithin24h !== null;
+ const verdict = selectCycleTimeVerdict(
+ {
+ totalPRsMerged: data.totalPRsMerged,
+ pctMergedWithin24h: data.pctMergedWithin24h,
+ flow: data.flow,
+ },
+ { minMerged: INSIGHT_MIN_MERGED, coverageFloor: COVERAGE_FLOOR },
+ );
+ const verdictText = buildVerdictText(verdict, data, t);
return (
@@ -89,16 +105,44 @@ export function CycleTime({ data }: CycleTimeProps) {
- {showInsight && (
-
+ {verdictText && (
+
-
-
- {t("dashboard.cycleTime.insight", {
- pct: formatPct(data.pctMergedWithin24h),
- median: formatHoursAsDays(data.medianHours),
- })}
-
+
+ {verdictText.text}
+
+
+ )}
+
+ {verdict.variant === "verdict" && data.flow && verdict.dominantPhase && (
+
+
+ {t("dashboard.cycleTime.flowBarTitle")}
+
+ {t("dashboard.cycleTime.flowBarSubtitle")}
+
+
+
+
)}
@@ -339,3 +383,77 @@ function formatHoursAsDays(hours: number | null): string {
: rounded.toFixed(1).replace(".", ",");
return `${label} d`;
}
+
+type Translate = (
+ path: string,
+ params?: Record,
+) => string;
+
+function phaseLabels(t: Translate): Record {
+ return {
+ coding: t("dashboard.cycleTime.phaseLabels.coding"),
+ awaiting_first_review: t(
+ "dashboard.cycleTime.phaseLabels.awaiting_first_review",
+ ),
+ in_review_active: t("dashboard.cycleTime.phaseLabels.in_review_active"),
+ in_review_wait: t("dashboard.cycleTime.phaseLabels.in_review_wait"),
+ awaiting_merge: t("dashboard.cycleTime.phaseLabels.awaiting_merge"),
+ };
+}
+
+type VerdictText = { text: string; tone: "signal" | "muted" };
+
+/**
+ * Turn a computed verdict into the banner copy. Only ever describes the code
+ * window it measured — never claims anything about "before" or "after".
+ */
+function buildVerdictText(
+ verdict: CycleTimeVerdict,
+ data: CycleTimeData,
+ t: Translate,
+): VerdictText | null {
+ if (verdict.variant === "none") return null;
+
+ const median = formatHoursAsDays(data.medianHours);
+ const pct = formatPct(data.pctMergedWithin24h);
+
+ if (verdict.variant === "noFlow") {
+ return {
+ tone: "muted",
+ text: t("dashboard.cycleTime.verdictNoFlow", { median, pct }),
+ };
+ }
+
+ const dp = verdict.dominantPhase;
+ if (!dp) return null;
+ const phase = phaseLabels(t)[dp.key];
+ const phaseHours = formatHoursAsDays(dp.hours);
+ const coverage =
+ verdict.flowCoveragePct !== null
+ ? `${Math.round(verdict.flowCoveragePct * 100)}%`
+ : "—";
+
+ if (verdict.variant === "lowCoverage") {
+ return {
+ tone: "muted",
+ text: t("dashboard.cycleTime.verdictLowCoverage", {
+ coverage,
+ phase,
+ phaseHours,
+ }),
+ };
+ }
+
+ return {
+ tone: "signal",
+ text: t("dashboard.cycleTime.verdict", {
+ phase,
+ phaseHours,
+ sharePct: `${dp.sharePct.toFixed(0)}%`,
+ median,
+ pct,
+ n: verdict.prsWithFlow ?? 0,
+ coverage,
+ }),
+ };
+}
diff --git a/platform/src/components/charts/FlowPhaseBar.tsx b/platform/src/components/charts/FlowPhaseBar.tsx
new file mode 100644
index 0000000..36d5bd1
--- /dev/null
+++ b/platform/src/components/charts/FlowPhaseBar.tsx
@@ -0,0 +1,115 @@
+import { WAIT_PHASES, WINDOW_PHASES } from "@/lib/queries/cycle-time-flow";
+import { cn } from "@/lib/utils";
+import type { FlowPhaseKey } from "@/types/org-summary";
+
+/**
+ * Tailwind colors per lifecycle phase. Mirrors the per-repo Flow Efficiency
+ * card so the org-level bar reads consistently across the product.
+ */
+export const FLOW_PHASE_COLORS: Record = {
+ coding: "bg-signal-green",
+ awaiting_first_review: "bg-signal-yellow",
+ in_review_active: "bg-signal-purple",
+ in_review_wait: "bg-signal-red",
+ awaiting_merge: "bg-signal-gray",
+};
+
+interface FlowPhaseBarProps {
+ /** Median hours per phase (org-aggregated). */
+ phaseHours: Partial>;
+ /** Localized label per phase. */
+ labels: Record;
+ /** Format a phase duration for display (e.g. "14 h"). */
+ formatHours: (hours: number) => string;
+ /** Phase to highlight as the dominant (widest) one, if any. */
+ dominantKey?: FlowPhaseKey | null;
+ /** Localized footnote explaining the wait-phase hatch (optional). */
+ waitNote?: string;
+}
+
+/**
+ * Horizontal stacked bar + legend showing how the measured code window
+ * (PR open -> merge) splits across its phases. Excludes `coding` (pre-open
+ * authoring time), matching the verdict's window. Presentational only.
+ */
+export function FlowPhaseBar({
+ phaseHours,
+ labels,
+ formatHours,
+ dominantKey,
+ waitNote,
+}: FlowPhaseBarProps) {
+ const total = WINDOW_PHASES.reduce(
+ (sum, key) => sum + (phaseHours[key] ?? 0),
+ 0,
+ );
+ if (total <= 0) return null;
+
+ return (
+
+
+ {WINDOW_PHASES.map((key) => {
+ const hours = phaseHours[key] ?? 0;
+ if (hours === 0) return null;
+ const pct = (hours / total) * 100;
+ return (
+
+ );
+ })}
+
+
+ {WINDOW_PHASES.map((key) => {
+ const hours = phaseHours[key] ?? 0;
+ if (hours === 0) return null;
+ return (
+
+
+
+
+ {labels[key]}
+
+
+
+ {formatHours(hours)}
+
+
+ );
+ })}
+
+ {waitNote && (
+
+ {waitNote}
+
+ )}
+
+ );
+}
diff --git a/platform/src/types/metrics.ts b/platform/src/types/metrics.ts
index 2c9ce81..b09a030 100644
--- a/platform/src/types/metrics.ts
+++ b/platform/src/types/metrics.ts
@@ -261,6 +261,9 @@ export interface ReportMetrics {
awaiting_merge?: number;
};
median_time_to_first_review_hours?: number;
+ // Merged PRs that actually carry a phase decomposition — the honest
+ // denominator for flow coverage (not pr_merged_count).
+ flow_pr_count?: number;
// Human Review Coverage — fraction of merged PRs with genuine human review.
// Disambiguates pr_single_pass_rate: "merged in one pass" vs "no human looked".
diff --git a/platform/src/types/org-summary.ts b/platform/src/types/org-summary.ts
index 962f604..4951de0 100644
--- a/platform/src/types/org-summary.ts
+++ b/platform/src/types/org-summary.ts
@@ -93,6 +93,44 @@ export interface PRHealthData {
reposWithData: number;
}
+/** The five lifecycle phases of a merged PR, in canonical order. */
+export type FlowPhaseKey =
+ | "coding"
+ | "awaiting_first_review"
+ | "in_review_active"
+ | "in_review_wait"
+ | "awaiting_merge";
+
+/** The phase that consumes the most time within the measured window. */
+export interface DominantPhase {
+ key: FlowPhaseKey;
+ /** Org-weighted median hours spent in this phase. */
+ hours: number;
+ /**
+ * This phase's share of the summed window-phase medians (an approximation
+ * of where time concentrates — NOT a share of the cycle-time median), 0–100.
+ * Computed over the post-open window only (excludes `coding`).
+ */
+ sharePct: number;
+}
+
+/**
+ * Org-level decomposition of the code window (PR open → merge) into phases.
+ * Weighted by each repo's decomposed-PR count — an approximation (per-PR
+ * timings are never persisted, by design), so display is gated on
+ * `flowCoveragePct`. `dominantPhase` is chosen over the post-open window only.
+ */
+export interface FlowDecomposition {
+ phaseMedianHours: Record;
+ medianTimeToFirstReviewHours: number | null;
+ flowEfficiencyMedian: number | null;
+ dominantPhase: DominantPhase | null;
+ /** Merged PRs that carried a phase decomposition (the aggregation weight). */
+ prsWithFlow: number;
+ /** prsWithFlow ÷ total merged PRs, 0.0–1.0. */
+ flowCoveragePct: number | null;
+}
+
/**
* Cycle Time view — how long the org takes to go from PR open to merge.
*
@@ -113,6 +151,12 @@ export interface CycleTimeData {
meanHours: number | null;
/** Hours. Worst-case P90 across repos (max of repo P90s). */
p90Hours: number | null;
+ /**
+ * Code-window phase decomposition + dominant phase. Null when no repo in
+ * the window carried phase data (older payloads). This is what replaces the
+ * old hardcoded "bottleneck" sentence with a computed read of the tenant.
+ */
+ flow: FlowDecomposition | null;
/** Per-repo rows, sorted from fastest to slowest. */
perRepo: Array<{
name: string;
diff --git a/platform/tests/cycle-time-flow.test.ts b/platform/tests/cycle-time-flow.test.ts
new file mode 100644
index 0000000..a372ff5
--- /dev/null
+++ b/platform/tests/cycle-time-flow.test.ts
@@ -0,0 +1,362 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ FLOW_PHASE_ORDER,
+ WAIT_PHASES,
+ WINDOW_PHASES,
+ selectCycleTimeVerdict,
+ summarizeFlow,
+ type FlowRow,
+} from "@/lib/queries/cycle-time-flow";
+import { computeCycleTime } from "@/lib/queries/org-summary";
+import type { ReportMetrics } from "@/types/metrics";
+import type { FlowDecomposition } from "@/types/org-summary";
+import type { RepoSummary } from "@/types/temporal";
+
+// ---------------------------------------------------------------------------
+// summarizeFlow
+// ---------------------------------------------------------------------------
+
+describe("summarizeFlow", () => {
+ it("weights per-repo phase medians by the decomposed-PR count and picks the window dominant", () => {
+ const rows: FlowRow[] = [
+ { weight: 10, phases: { awaiting_first_review: 8, in_review_active: 2 } },
+ { weight: 30, phases: { awaiting_first_review: 2, in_review_active: 6 } },
+ ];
+ const out = summarizeFlow(rows, 40)!;
+
+ // awaiting = (8*10 + 2*30)/40 = 3.5 ; active = (2*10 + 6*30)/40 = 5
+ expect(out.phaseMedianHours.awaiting_first_review).toBe(3.5);
+ expect(out.phaseMedianHours.in_review_active).toBe(5);
+ expect(out.dominantPhase).toEqual({
+ key: "in_review_active",
+ hours: 5,
+ sharePct: 58.8, // round(5 / 8.5 * 100, 1)
+ });
+ expect(WAIT_PHASES.has(out.dominantPhase!.key)).toBe(false);
+ expect(out.prsWithFlow).toBe(40);
+ expect(out.flowCoveragePct).toBe(1);
+ });
+
+ it("never lets 'coding' (pre-PR-open authoring) win the verdict — M2", () => {
+ // coding is the largest phase, but it is OUTSIDE the measured window.
+ const rows: FlowRow[] = [
+ {
+ weight: 10,
+ phases: { coding: 20, awaiting_first_review: 8, in_review_wait: 2 },
+ },
+ ];
+ const out = summarizeFlow(rows, 10)!;
+ // coding is still computed in the data...
+ expect(out.phaseMedianHours.coding).toBe(20);
+ // ...but the dominant is the largest WINDOW phase, and the share denominator
+ // is the window total (8 + 2 = 10), not the all-phase total.
+ expect(out.dominantPhase?.key).toBe("awaiting_first_review");
+ expect(out.dominantPhase?.hours).toBe(8);
+ expect(out.dominantPhase?.sharePct).toBe(80); // 8 / 10 window total
+ expect(WAIT_PHASES.has(out.dominantPhase!.key)).toBe(true);
+ });
+
+ it("reports partial coverage from the decomposed count, not total merged — M3", () => {
+ const rows: FlowRow[] = [{ weight: 20, phases: { in_review_wait: 4 } }];
+ const out = summarizeFlow(rows, 50)!; // 50 merged, only 20 decomposed
+ expect(out.prsWithFlow).toBe(20);
+ expect(out.flowCoveragePct).toBe(0.4);
+ });
+
+ it("weights TTFR and flow efficiency only over rows that carry them", () => {
+ const rows: FlowRow[] = [
+ {
+ weight: 10,
+ phases: { awaiting_first_review: 1 },
+ ttfrHours: 12,
+ flowEfficiency: 0.5,
+ },
+ { weight: 30, phases: { awaiting_first_review: 1 }, ttfrHours: null },
+ ];
+ const out = summarizeFlow(rows, 40)!;
+ expect(out.medianTimeToFirstReviewHours).toBe(12);
+ expect(out.flowEfficiencyMedian).toBe(0.5);
+ });
+
+ it("returns null when no row carries decomposed phase data", () => {
+ expect(summarizeFlow([], 10)).toBeNull();
+ expect(
+ summarizeFlow([{ weight: 0, phases: { awaiting_first_review: 5 } }], 10),
+ ).toBeNull();
+ });
+
+ it("has no dominant phase when every window phase is zero (even if coding > 0)", () => {
+ const out = summarizeFlow([{ weight: 5, phases: { coding: 9 } }], 5)!;
+ expect(out.dominantPhase).toBeNull();
+ expect(out.phaseMedianHours.coding).toBe(9);
+ expect(out.prsWithFlow).toBe(5);
+ expect(out.flowCoveragePct).toBe(1);
+ });
+
+ it("returns null coverage when the total merged count is unknown", () => {
+ const out = summarizeFlow(
+ [{ weight: 5, phases: { awaiting_first_review: 2 } }],
+ 0,
+ )!;
+ expect(out.flowCoveragePct).toBeNull();
+ });
+
+ it("breaks ties toward the earlier window phase deterministically", () => {
+ const out = summarizeFlow(
+ [{ weight: 1, phases: { awaiting_first_review: 4, awaiting_merge: 4 } }],
+ 1,
+ )!;
+ expect(out.dominantPhase?.key).toBe("awaiting_first_review");
+ });
+
+ it("exposes the canonical order, the window phases, and the wait-phase set", () => {
+ expect(FLOW_PHASE_ORDER).toEqual([
+ "coding",
+ "awaiting_first_review",
+ "in_review_active",
+ "in_review_wait",
+ "awaiting_merge",
+ ]);
+ expect(WINDOW_PHASES).toEqual([
+ "awaiting_first_review",
+ "in_review_active",
+ "in_review_wait",
+ "awaiting_merge",
+ ]);
+ expect(WINDOW_PHASES).not.toContain("coding");
+ expect(WAIT_PHASES.has("in_review_wait")).toBe(true);
+ expect(WAIT_PHASES.has("in_review_active")).toBe(false);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// selectCycleTimeVerdict
+// ---------------------------------------------------------------------------
+
+const OPTS = { minMerged: 50, coverageFloor: 0.6 };
+
+function flow(over: Partial): FlowDecomposition {
+ return {
+ phaseMedianHours: {
+ coding: 1,
+ awaiting_first_review: 4,
+ in_review_active: 1,
+ in_review_wait: 1,
+ awaiting_merge: 1,
+ },
+ medianTimeToFirstReviewHours: 4,
+ flowEfficiencyMedian: 0.3,
+ dominantPhase: {
+ key: "awaiting_first_review",
+ hours: 4,
+ sharePct: 57.1,
+ },
+ prsWithFlow: 80,
+ flowCoveragePct: 0.9,
+ ...over,
+ };
+}
+
+describe("selectCycleTimeVerdict", () => {
+ it("returns none when there are too few merged PRs", () => {
+ const v = selectCycleTimeVerdict(
+ { totalPRsMerged: 10, pctMergedWithin24h: 0.7, flow: flow({}) },
+ OPTS,
+ );
+ expect(v.variant).toBe("none");
+ });
+
+ it("returns none when the within-24h figure is missing", () => {
+ const v = selectCycleTimeVerdict(
+ { totalPRsMerged: 100, pctMergedWithin24h: null, flow: flow({}) },
+ OPTS,
+ );
+ expect(v.variant).toBe("none");
+ });
+
+ it("returns noFlow when there is no decomposition", () => {
+ const v = selectCycleTimeVerdict(
+ { totalPRsMerged: 100, pctMergedWithin24h: 0.7, flow: null },
+ OPTS,
+ );
+ expect(v.variant).toBe("noFlow");
+ expect(v.dominantPhase).toBeNull();
+ });
+
+ it("returns noFlow when the decomposition has no dominant phase", () => {
+ const v = selectCycleTimeVerdict(
+ {
+ totalPRsMerged: 100,
+ pctMergedWithin24h: 0.7,
+ flow: flow({ dominantPhase: null }),
+ },
+ OPTS,
+ );
+ expect(v.variant).toBe("noFlow");
+ });
+
+ it("returns lowCoverage below the coverage floor", () => {
+ const v = selectCycleTimeVerdict(
+ {
+ totalPRsMerged: 100,
+ pctMergedWithin24h: 0.7,
+ flow: flow({ flowCoveragePct: 0.4 }),
+ },
+ OPTS,
+ );
+ expect(v.variant).toBe("lowCoverage");
+ expect(v.flowCoveragePct).toBe(0.4);
+ });
+
+ it("returns the full verdict at or above the coverage floor", () => {
+ const v = selectCycleTimeVerdict(
+ {
+ totalPRsMerged: 100,
+ pctMergedWithin24h: 0.7,
+ flow: flow({ flowCoveragePct: 0.6 }),
+ },
+ OPTS,
+ );
+ expect(v.variant).toBe("verdict");
+ expect(v.dominantPhase?.key).toBe("awaiting_first_review");
+ expect(v.prsWithFlow).toBe(80);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// computeCycleTime — flow wiring (integration)
+// ---------------------------------------------------------------------------
+
+const repos = [
+ { id: "r1", name: "checkout" },
+ { id: "r2", name: "orders" },
+] as unknown as RepoSummary[];
+
+function metrics(over: Partial): ReportMetrics {
+ return {
+ commits_total: 0,
+ commits_revert: 0,
+ revert_rate: 0,
+ churn_events: 0,
+ churn_lines_affected: 0,
+ files_touched: 0,
+ files_stabilized: 0,
+ stabilization_ratio: 0,
+ pr_cycle_time_buckets: {
+ same_day: 0,
+ one_day: 0,
+ two_to_three_days: 0,
+ four_to_seven_days: 0,
+ seven_plus_days: 0,
+ },
+ ...over,
+ } as ReportMetrics;
+}
+
+describe("computeCycleTime — flow decomposition", () => {
+ it("aggregates phase data weighted by flow_pr_count and excludes coding", () => {
+ const payloads = new Map();
+ payloads.set(
+ "r1",
+ metrics({
+ pr_merged_count: 20,
+ flow_pr_count: 20,
+ pr_cycle_time_buckets: {
+ same_day: 12,
+ one_day: 4,
+ two_to_three_days: 2,
+ four_to_seven_days: 1,
+ seven_plus_days: 1,
+ },
+ time_in_phase_median_hours: { coding: 30, awaiting_first_review: 10 },
+ }),
+ );
+ payloads.set(
+ "r2",
+ metrics({
+ pr_merged_count: 30,
+ flow_pr_count: 30,
+ pr_cycle_time_buckets: {
+ same_day: 20,
+ one_day: 6,
+ two_to_three_days: 2,
+ four_to_seven_days: 1,
+ seven_plus_days: 1,
+ },
+ time_in_phase_median_hours: { coding: 30, awaiting_first_review: 3 },
+ }),
+ );
+
+ const out = computeCycleTime(repos, payloads)!;
+ expect(out.flow).not.toBeNull();
+ // awaiting = (10*20 + 3*30)/50 = 5.8 ; coding present but EXCLUDED from dominant
+ expect(out.flow!.phaseMedianHours.awaiting_first_review).toBe(5.8);
+ expect(out.flow!.phaseMedianHours.coding).toBe(30);
+ expect(out.flow!.dominantPhase?.key).toBe("awaiting_first_review");
+ expect(out.flow!.flowCoveragePct).toBe(1); // 50 decomposed / 50 merged
+ });
+
+ it("yields partial coverage when flow_pr_count < merged — M3 guard-rail is live", () => {
+ const payloads = new Map();
+ payloads.set(
+ "r1",
+ metrics({
+ pr_merged_count: 100,
+ flow_pr_count: 40, // only 40 of 100 merged PRs were decomposed
+ pr_cycle_time_buckets: {
+ same_day: 60,
+ one_day: 20,
+ two_to_three_days: 10,
+ four_to_seven_days: 6,
+ seven_plus_days: 4,
+ },
+ time_in_phase_median_hours: { awaiting_first_review: 9 },
+ }),
+ );
+ const out = computeCycleTime(repos, payloads)!;
+ expect(out.flow!.flowCoveragePct).toBe(0.4);
+ expect(out.flow!.prsWithFlow).toBe(40);
+ });
+
+ it("leaves data.flow null when phase data has no flow_pr_count (old payloads) — M3", () => {
+ const payloads = new Map();
+ payloads.set(
+ "r1",
+ metrics({
+ pr_merged_count: 60,
+ // time_in_phase present but NO flow_pr_count → cannot trust coverage
+ time_in_phase_median_hours: { awaiting_first_review: 5 },
+ pr_cycle_time_buckets: {
+ same_day: 40,
+ one_day: 12,
+ two_to_three_days: 4,
+ four_to_seven_days: 2,
+ seven_plus_days: 2,
+ },
+ }),
+ );
+ const out = computeCycleTime(repos, payloads)!;
+ expect(out.flow).toBeNull();
+ });
+
+ it("leaves data.flow null when no payload carries phase data", () => {
+ const payloads = new Map();
+ payloads.set(
+ "r1",
+ metrics({
+ pr_merged_count: 10,
+ flow_pr_count: 10,
+ pr_cycle_time_buckets: {
+ same_day: 8,
+ one_day: 2,
+ two_to_three_days: 0,
+ four_to_seven_days: 0,
+ seven_plus_days: 0,
+ },
+ }),
+ );
+ const out = computeCycleTime(repos, payloads)!;
+ expect(out.flow).toBeNull();
+ });
+});