- {FLOW_PHASE_ORDER.map((key) => {
+ {WINDOW_PHASES.map((key) => {
const hours = phaseHours[key] ?? 0;
if (hours === 0) return null;
const pct = (hours / total) * 100;
@@ -63,7 +64,7 @@ export function FlowPhaseBar({
})}
- {FLOW_PHASE_ORDER.map((key) => {
+ {WINDOW_PHASES.map((key) => {
const hours = phaseHours[key] ?? 0;
if (hours === 0) return null;
return (
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 f3660df..375d535 100644
--- a/platform/src/types/org-summary.ts
+++ b/platform/src/types/org-summary.ts
@@ -101,12 +101,16 @@ export type FlowPhaseKey =
| "in_review_wait"
| "awaiting_merge";
-/** The phase that consumes the most time within the code window. */
+/** 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 total code-window time, 0–100. */
+ /**
+ * 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;
/** True when the phase is a wait (queue) phase — the actionable kind. */
isWait: boolean;
@@ -114,15 +118,16 @@ export interface DominantPhase {
/**
* Org-level decomposition of the code window (PR open → merge) into phases.
- * Weighted by per-repo merged count — an approximation (per-PR timings are
- * never persisted, by design), so display is gated on `flowCoveragePct`.
+ * 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 phase data (the aggregation weight). */
+ /** Merged PRs that carried a phase decomposition (the aggregation weight). */
prsWithFlow: number;
/** prsWithFlow ÷ total merged PRs, 0.0–1.0. */
flowCoveragePct: number | null;
diff --git a/platform/tests/cycle-time-flow.test.ts b/platform/tests/cycle-time-flow.test.ts
index e2d93c1..395e515 100644
--- a/platform/tests/cycle-time-flow.test.ts
+++ b/platform/tests/cycle-time-flow.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
FLOW_PHASE_ORDER,
WAIT_PHASES,
+ WINDOW_PHASES,
selectCycleTimeVerdict,
summarizeFlow,
type FlowRow,
@@ -17,18 +18,18 @@ import type { RepoSummary } from "@/types/temporal";
// ---------------------------------------------------------------------------
describe("summarizeFlow", () => {
- it("weights per-repo phase medians by merged count and picks the dominant", () => {
+ it("weights per-repo phase medians by the decomposed-PR count and picks the window dominant", () => {
const rows: FlowRow[] = [
- { merged: 10, phases: { coding: 2, awaiting_first_review: 8 } },
- { merged: 30, phases: { coding: 6, awaiting_first_review: 2 } },
+ { 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)!;
- // coding = (2*10 + 6*30)/40 = 5 ; awaiting = (8*10 + 2*30)/40 = 3.5
- expect(out.phaseMedianHours.coding).toBe(5);
+ // 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: "coding",
+ key: "in_review_active",
hours: 5,
sharePct: 58.8, // round(5 / 8.5 * 100, 1)
isWait: false,
@@ -37,60 +38,79 @@ describe("summarizeFlow", () => {
expect(out.flowCoveragePct).toBe(1);
});
- it("flags a wait phase as the actionable dominant kind", () => {
+ 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[] = [
- { merged: 5, phases: { coding: 1, awaiting_first_review: 9 } },
+ {
+ weight: 10,
+ phases: { coding: 20, awaiting_first_review: 8, in_review_wait: 2 },
+ },
];
- const out = summarizeFlow(rows, 5)!;
+ 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(out.dominantPhase?.isWait).toBe(true);
});
- it("reports partial coverage when some merged PRs lack phase data", () => {
- const rows: FlowRow[] = [{ merged: 20, phases: { coding: 4 } }];
- const out = summarizeFlow(rows, 50)!; // 30 merged PRs carried no phase data
+ 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[] = [
- { merged: 10, phases: { coding: 1 }, ttfrHours: 12, flowEfficiency: 0.5 },
- { merged: 30, phases: { coding: 1 }, ttfrHours: null }, // skipped for ttfr/eff
+ {
+ 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); // only the 10-weight row
+ expect(out.medianTimeToFirstReviewHours).toBe(12);
expect(out.flowEfficiencyMedian).toBe(0.5);
});
- it("returns null when no row carries phase data", () => {
+ it("returns null when no row carries decomposed phase data", () => {
expect(summarizeFlow([], 10)).toBeNull();
expect(
- summarizeFlow([{ merged: 0, phases: { coding: 5 } }], 10),
+ summarizeFlow([{ weight: 0, phases: { awaiting_first_review: 5 } }], 10),
).toBeNull();
});
- it("returns a decomposition with no dominant phase when all phases are zero", () => {
- const out = summarizeFlow([{ merged: 5, phases: {} }], 5)!;
+ 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([{ merged: 5, phases: { coding: 2 } }], 0)!;
+ const out = summarizeFlow(
+ [{ weight: 5, phases: { awaiting_first_review: 2 } }],
+ 0,
+ )!;
expect(out.flowCoveragePct).toBeNull();
});
- it("breaks ties toward the earlier phase deterministically", () => {
+ it("breaks ties toward the earlier window phase deterministically", () => {
const out = summarizeFlow(
- [{ merged: 1, phases: { coding: 4, awaiting_merge: 4 } }],
+ [{ weight: 1, phases: { awaiting_first_review: 4, awaiting_merge: 4 } }],
1,
)!;
- expect(out.dominantPhase?.key).toBe("coding");
+ expect(out.dominantPhase?.key).toBe("awaiting_first_review");
});
- it("exposes the canonical order and the wait-phase set", () => {
+ it("exposes the canonical order, the window phases, and the wait-phase set", () => {
expect(FLOW_PHASE_ORDER).toEqual([
"coding",
"awaiting_first_review",
@@ -98,8 +118,15 @@ describe("summarizeFlow", () => {
"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("coding")).toBe(false);
+ expect(WAIT_PHASES.has("in_review_active")).toBe(false);
});
});
@@ -123,7 +150,7 @@ function flow(over: Partial): FlowDecomposition {
dominantPhase: {
key: "awaiting_first_review",
hours: 4,
- sharePct: 50,
+ sharePct: 57.1,
isWait: true,
},
prsWithFlow: 80,
@@ -229,12 +256,13 @@ function metrics(over: Partial): ReportMetrics {
}
describe("computeCycleTime — flow decomposition", () => {
- it("aggregates phase data the engine emits into data.flow", () => {
+ 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,
@@ -242,14 +270,14 @@ describe("computeCycleTime — flow decomposition", () => {
four_to_seven_days: 1,
seven_plus_days: 1,
},
- time_in_phase_median_hours: { coding: 2, awaiting_first_review: 10 },
- median_time_to_first_review_hours: 10,
+ 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,
@@ -257,16 +285,60 @@ describe("computeCycleTime — flow decomposition", () => {
four_to_seven_days: 1,
seven_plus_days: 1,
},
- time_in_phase_median_hours: { coding: 3, awaiting_first_review: 3 },
+ 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 = (2*20 + 3*30)/50 = 2.6
+ // 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);
+ 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", () => {
@@ -275,6 +347,7 @@ describe("computeCycleTime — flow decomposition", () => {
"r1",
metrics({
pr_merged_count: 10,
+ flow_pr_count: 10,
pr_cycle_time_buckets: {
same_day: 8,
one_day: 2,
From 1fc83777b21027d2ff8f8daea31df746ebf49f23 Mon Sep 17 00:00:00 2001
From: Marcos Sabatino
Date: Sat, 20 Jun 2026 03:20:32 -0300
Subject: [PATCH 3/3] fix(dashboard): gate phase bar to the verdict variant;
show wait vs active
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Follow-up from the post-fix re-review, which found one new major (a UI
consistency regression that the M3 coverage fix made routine) plus the
orphaned isWait field.
- FlowPhaseBar now renders only when verdict.variant === "verdict".
Before, it showed in lowCoverage and none too (dominantPhase is set in
every variant), so an affirmative phase bar appeared next to a "partial
sample, not a verdict" banner — and below the density floor with no
banner at all. Honest coverage (M3) made lowCoverage common, so this
was about to ship as a systematic contradiction.
- Re-expose the wait/active signal lost when M4 dropped the waitTag: the
phase bar now hatches wait-phase segments (via WAIT_PHASES) with a short
legend note, instead of a redundant text tag. The orphaned
DominantPhase.isWait field (its only reader was the removed waitTag) is
deleted; tests assert wait-ness via WAIT_PHASES directly.
TS 231 pass, tsc clean, cycle-time-flow.ts 100% lines / 97.5% branch.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
platform/lib/queries/cycle-time-flow.ts | 1 -
platform/lib/translations.ts | 2 ++
.../[tenant]/dashboard/sections/CycleTime.tsx | 3 ++-
.../src/components/charts/FlowPhaseBar.tsx | 20 +++++++++++++++++--
platform/src/types/org-summary.ts | 2 --
platform/tests/cycle-time-flow.test.ts | 5 ++---
6 files changed, 24 insertions(+), 9 deletions(-)
diff --git a/platform/lib/queries/cycle-time-flow.ts b/platform/lib/queries/cycle-time-flow.ts
index cbee13f..41ccfb4 100644
--- a/platform/lib/queries/cycle-time-flow.ts
+++ b/platform/lib/queries/cycle-time-flow.ts
@@ -141,7 +141,6 @@ export function summarizeFlow(
key,
hours: phaseMedianHours[key],
sharePct: round((phaseMedianHours[key] / windowHours) * 100, 1),
- isWait: WAIT_PHASES.has(key),
};
}
diff --git a/platform/lib/translations.ts b/platform/lib/translations.ts
index 6055316..12edd62 100644
--- a/platform/lib/translations.ts
+++ b/platform/lib/translations.ts
@@ -387,6 +387,7 @@ export const translations = {
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",
@@ -1780,6 +1781,7 @@ export const translations = {
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",
diff --git a/platform/src/app/[tenant]/dashboard/sections/CycleTime.tsx b/platform/src/app/[tenant]/dashboard/sections/CycleTime.tsx
index 482a43a..c0e8605 100644
--- a/platform/src/app/[tenant]/dashboard/sections/CycleTime.tsx
+++ b/platform/src/app/[tenant]/dashboard/sections/CycleTime.tsx
@@ -127,7 +127,7 @@ export function CycleTime({ data }: CycleTimeProps) {
)}
- {data.flow && verdict.dominantPhase && (
+ {verdict.variant === "verdict" && data.flow && verdict.dominantPhase && (
{t("dashboard.cycleTime.flowBarTitle")}
@@ -141,6 +141,7 @@ export function CycleTime({ data }: CycleTimeProps) {
labels={phaseLabels(t)}
formatHours={formatHoursAsDays}
dominantKey={verdict.dominantPhase.key}
+ waitNote={t("dashboard.cycleTime.waitNote")}
/>
diff --git a/platform/src/components/charts/FlowPhaseBar.tsx b/platform/src/components/charts/FlowPhaseBar.tsx
index ff18092..36d5bd1 100644
--- a/platform/src/components/charts/FlowPhaseBar.tsx
+++ b/platform/src/components/charts/FlowPhaseBar.tsx
@@ -1,4 +1,4 @@
-import { WINDOW_PHASES } from "@/lib/queries/cycle-time-flow";
+import { WAIT_PHASES, WINDOW_PHASES } from "@/lib/queries/cycle-time-flow";
import { cn } from "@/lib/utils";
import type { FlowPhaseKey } from "@/types/org-summary";
@@ -23,6 +23,8 @@ interface FlowPhaseBarProps {
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;
}
/**
@@ -35,6 +37,7 @@ export function FlowPhaseBar({
labels,
formatHours,
dominantKey,
+ waitNote,
}: FlowPhaseBarProps) {
const total = WINDOW_PHASES.reduce(
(sum, key) => sum + (phaseHours[key] ?? 0),
@@ -57,7 +60,15 @@ export function FlowPhaseBar({
FLOW_PHASE_COLORS[key],
key === dominantKey && "ring-2 ring-inset ring-foreground/40",
)}
- style={{ width: `${pct}%` }}
+ style={{
+ width: `${pct}%`,
+ // Wait phases (queue time) get a diagonal hatch so active vs
+ // wait reads at a glance — the flow-efficiency signal.
+ ...(WAIT_PHASES.has(key) && {
+ backgroundImage:
+ "repeating-linear-gradient(45deg, rgba(255,255,255,0.35) 0, rgba(255,255,255,0.35) 1.5px, transparent 1.5px, transparent 4px)",
+ }),
+ }}
title={`${labels[key]}: ${formatHours(hours)}`}
/>
);
@@ -94,6 +105,11 @@ export function FlowPhaseBar({
);
})}
+ {waitNote && (
+
+ {waitNote}
+
+ )}
);
}
diff --git a/platform/src/types/org-summary.ts b/platform/src/types/org-summary.ts
index 375d535..4951de0 100644
--- a/platform/src/types/org-summary.ts
+++ b/platform/src/types/org-summary.ts
@@ -112,8 +112,6 @@ export interface DominantPhase {
* Computed over the post-open window only (excludes `coding`).
*/
sharePct: number;
- /** True when the phase is a wait (queue) phase — the actionable kind. */
- isWait: boolean;
}
/**
diff --git a/platform/tests/cycle-time-flow.test.ts b/platform/tests/cycle-time-flow.test.ts
index 395e515..a372ff5 100644
--- a/platform/tests/cycle-time-flow.test.ts
+++ b/platform/tests/cycle-time-flow.test.ts
@@ -32,8 +32,8 @@ describe("summarizeFlow", () => {
key: "in_review_active",
hours: 5,
sharePct: 58.8, // round(5 / 8.5 * 100, 1)
- isWait: false,
});
+ expect(WAIT_PHASES.has(out.dominantPhase!.key)).toBe(false);
expect(out.prsWithFlow).toBe(40);
expect(out.flowCoveragePct).toBe(1);
});
@@ -54,7 +54,7 @@ describe("summarizeFlow", () => {
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(out.dominantPhase?.isWait).toBe(true);
+ expect(WAIT_PHASES.has(out.dominantPhase!.key)).toBe(true);
});
it("reports partial coverage from the decomposed count, not total merged — M3", () => {
@@ -151,7 +151,6 @@ function flow(over: Partial