From 00dbd2ae809e652bd22b25d56563c320a7f43855 Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 9 Jul 2026 15:16:53 -0500 Subject: [PATCH 1/3] feat(ui): cycle-time percentiles card (p50/p90/p99) Add cycleTime to the stats feed and operator dashboard, plus an analytics CycleTimeCard with percentile tiles and a distribution sparkbar. Closes #2194 Co-authored-by: Cursor --- .../site/app-panels/cycle-time-card-model.ts | 21 ++++ .../site/app-panels/cycle-time-card.test.tsx | 79 +++++++++++++ .../site/app-panels/cycle-time-card.tsx | 63 +++++++++++ .../src/routes/app.analytics.tsx | 5 + src/review/stats.ts | 104 +++++++++++++++++- src/services/operator-dashboard.ts | 7 ++ test/unit/operator-dashboard.test.ts | 7 ++ test/unit/stats.test.ts | 97 ++++++++++++++-- 8 files changed, 374 insertions(+), 9 deletions(-) create mode 100644 apps/gittensory-ui/src/components/site/app-panels/cycle-time-card-model.ts create mode 100644 apps/gittensory-ui/src/components/site/app-panels/cycle-time-card.test.tsx create mode 100644 apps/gittensory-ui/src/components/site/app-panels/cycle-time-card.tsx diff --git a/apps/gittensory-ui/src/components/site/app-panels/cycle-time-card-model.ts b/apps/gittensory-ui/src/components/site/app-panels/cycle-time-card-model.ts new file mode 100644 index 0000000000..ceb9fa7322 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/cycle-time-card-model.ts @@ -0,0 +1,21 @@ +// Cycle-time analytics card model (#2194). UI-side mirror of the CycleTimeAggregate shape produced by +// computeCycleTimeAggregate (src/review/stats.ts) and surfaced on the operator-dashboard payload. Types + +// pure formatters live here (not in the .tsx) so the component file exports only components +// (react-refresh/only-export-components). + +/** PR review cycle-time percentiles (mirror of src/review/stats.ts CycleTimeAggregate). */ +export interface CycleTimeAggregate { + p50Ms: number | null; + p90Ms: number | null; + p99Ms: number | null; + distribution: number[]; + sampleSize: number; +} + +/** Human-readable duration for percentile tiles; null/undefined → em dash. */ +export function formatCycleTimeMs(v: number | null | undefined): string { + if (v == null) return "—"; + if (v < 60_000) return `${Math.round(v / 1000)}s`; + if (v < 3_600_000) return `${Math.round(v / 60_000)}m`; + return `${(v / 3_600_000).toFixed(1)}h`; +} diff --git a/apps/gittensory-ui/src/components/site/app-panels/cycle-time-card.test.tsx b/apps/gittensory-ui/src/components/site/app-panels/cycle-time-card.test.tsx new file mode 100644 index 0000000000..550847c8f5 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/cycle-time-card.test.tsx @@ -0,0 +1,79 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { CycleTimeCard } from "@/components/site/app-panels/cycle-time-card"; +import { + formatCycleTimeMs, + type CycleTimeAggregate, +} from "@/components/site/app-panels/cycle-time-card-model"; + +describe("formatCycleTimeMs", () => { + it("formats seconds, minutes, and hours for present values", () => { + expect(formatCycleTimeMs(45_000)).toBe("45s"); + expect(formatCycleTimeMs(120_000)).toBe("2m"); + expect(formatCycleTimeMs(7_200_000)).toBe("2.0h"); + }); + + it("renders em dash for nullish percentiles (nullish arm)", () => { + expect(formatCycleTimeMs(null)).toBe("—"); + expect(formatCycleTimeMs(undefined)).toBe("—"); + }); +}); + +describe("CycleTimeCard", () => { + it("renders p50/p90/p99 tiles and the distribution sparkbar for populated percentiles", () => { + const cycleTime: CycleTimeAggregate = { + p50Ms: 60_000, + p90Ms: 120_000, + p99Ms: 300_000, + distribution: [1, 3, 5, 2], + sampleSize: 11, + }; + render(); + expect(screen.getByText("Review cycle time")).toBeTruthy(); + expect(screen.getByText("1m")).toBeTruthy(); + expect(screen.getByText("2m")).toBeTruthy(); + expect(screen.getByText("5m")).toBeTruthy(); + expect(screen.getByText("11 paired PR(s)")).toBeTruthy(); + expect(screen.getByText("Cycle-time distribution")).toBeTruthy(); + }); + + it("shows em dashes when percentiles are null (nullish arm)", () => { + const cycleTime: CycleTimeAggregate = { + p50Ms: null, + p90Ms: null, + p99Ms: null, + distribution: [2], + sampleSize: 2, + }; + render(); + expect(screen.getAllByText("—")).toHaveLength(3); + expect(screen.getByText("2 paired PR(s)")).toBeTruthy(); + }); + + it("shows inline empty copy when there are no samples", () => { + const cycleTime: CycleTimeAggregate = { + p50Ms: null, + p90Ms: null, + p99Ms: null, + distribution: [], + sampleSize: 0, + }; + render(); + expect(screen.getByText("no samples yet")).toBeTruthy(); + expect(screen.queryByText("Cycle-time distribution")).toBeNull(); + }); + + it("omits the sparkbar when the distribution is empty", () => { + const cycleTime: CycleTimeAggregate = { + p50Ms: 60_000, + p90Ms: 90_000, + p99Ms: 120_000, + distribution: [], + sampleSize: 3, + }; + render(); + expect(screen.getByText("3 paired PR(s)")).toBeTruthy(); + expect(screen.queryByText("Cycle-time distribution")).toBeNull(); + }); +}); diff --git a/apps/gittensory-ui/src/components/site/app-panels/cycle-time-card.tsx b/apps/gittensory-ui/src/components/site/app-panels/cycle-time-card.tsx new file mode 100644 index 0000000000..31068c70b9 --- /dev/null +++ b/apps/gittensory-ui/src/components/site/app-panels/cycle-time-card.tsx @@ -0,0 +1,63 @@ +import { MiniSparkbar, Stat, StatusPill } from "@/components/site/control-primitives"; +import { + formatCycleTimeMs, + type CycleTimeAggregate, +} from "@/components/site/app-panels/cycle-time-card-model"; + +/** Self-host maintainer analytics card (#2194): PR review cycle-time percentiles (p50/p90/p99) from the stats + * feed, read-only over the operator-dashboard payload. Shows an inline empty state when there are no paired + * gate_decision → pr_outcome samples in the window. */ +export function CycleTimeCard({ cycleTime }: { cycleTime: CycleTimeAggregate }) { + const hasSamples = cycleTime.sampleSize > 0; + const hasDistribution = cycleTime.distribution.length > 0; + + return ( +
+
+
+

Review cycle time

+

+ Gate decision → PR outcome duration percentiles from review_audit. Public-safe aggregates + only. +

+
+ + {hasSamples ? `${cycleTime.sampleSize} paired PR(s)` : "no samples yet"} + +
+ + {hasSamples ? ( + <> +
+ median cycle time} + /> + 90th percentile} + /> + 99th percentile} + /> +
+ {hasDistribution ? ( +
+
Cycle-time distribution
+ +
+ ) : null} + + ) : ( +

+ Paired gate decisions and PR outcomes will appear here once the gate has resolved pull requests + in the analytics window. +

+ )} +
+ ); +} diff --git a/apps/gittensory-ui/src/routes/app.analytics.tsx b/apps/gittensory-ui/src/routes/app.analytics.tsx index d8d49371c3..ec1d4b16b9 100644 --- a/apps/gittensory-ui/src/routes/app.analytics.tsx +++ b/apps/gittensory-ui/src/routes/app.analytics.tsx @@ -11,6 +11,8 @@ import { } from "@/components/site/usage-analytics-panels"; import { GatePrecisionCard } from "@/components/site/app-panels/gate-precision-card"; import type { GateEvalReport } from "@/components/site/app-panels/gate-precision-card-model"; +import { CycleTimeCard } from "@/components/site/app-panels/cycle-time-card"; +import type { CycleTimeAggregate } from "@/components/site/app-panels/cycle-time-card-model"; import { useApiResource } from "@/lib/api/use-api-resource"; export const Route = createFileRoute("/app/analytics")({ @@ -101,6 +103,7 @@ type OperatorDashboard = { }; upstreamDrift?: { status?: string; openReportCount?: number } | null; gateEval?: GateEvalReport; + cycleTime?: CycleTimeAggregate; }; function ProductAnalytics() { @@ -183,6 +186,8 @@ function ProductAnalytics() { {data.gateEval ? : null} + {data.cycleTime ? : null} + {data.usageSummary ? ( }; + /** PR review cycle-time percentiles (gate decision → outcome) from review_audit (#2194). */ + cycleTime: CycleTimeAggregate; +} + +/** ms between the gate decision and the resolution; null if implausible (NaN or negative). */ +export function cycleTimeMs(decidedAt: string, outcomeAt: string): number | null { + const ms = new Date(outcomeAt).getTime() - new Date(decidedAt).getTime(); + return Number.isFinite(ms) && ms >= 0 ? ms : null; +} + +/** Nearest-rank percentile on a pre-sorted sample; null when empty (mirrors orb/analytics.ts). */ +export function percentileNearestRank(sorted: number[], p: number): number | null { + if (sorted.length === 0) return null; + const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)); + return sorted[idx]!; +} + +/** Fold cycle-time samples into histogram buckets for the sparkbar; empty input → []. */ +export function buildCycleTimeDistribution(samplesMs: number[], bucketCount = 12): number[] { + if (samplesMs.length === 0) return []; + const max = Math.max(...samplesMs); + const min = Math.min(...samplesMs); + if (max === min) return [samplesMs.length]; + const buckets = Array.from({ length: bucketCount }, () => 0); + const span = max - min || 1; + for (const ms of samplesMs) { + const idx = Math.min(bucketCount - 1, Math.floor(((ms - min) / span) * bucketCount)); + buckets[idx]! += 1; + } + return buckets; +} + +/** Pure fold: cycle-time samples → p50/p90/p99 + distribution (#2194). */ +export function aggregateCycleTimePercentiles(samplesMs: number[]): CycleTimeAggregate { + const sorted = samplesMs.filter((ms) => Number.isFinite(ms) && ms >= 0).sort((a, b) => a - b); + if (sorted.length === 0) return EMPTY_CYCLE_TIME; + return { + p50Ms: percentileNearestRank(sorted, 50), + p90Ms: percentileNearestRank(sorted, 90), + p99Ms: percentileNearestRank(sorted, 99), + distribution: buildCycleTimeDistribution(sorted), + sampleSize: sorted.length, + }; +} + +const CYCLE_TIME_SQL = `WITH gd AS ( + SELECT target_id, created_at AS decided_at, + ROW_NUMBER() OVER (PARTITION BY target_id ORDER BY created_at DESC) AS rn + FROM review_audit + WHERE event_type = 'gate_decision' AND decision IS NOT NULL AND created_at >= ? +), +po AS ( + SELECT target_id, created_at AS outcome_at, ROW_NUMBER() OVER (PARTITION BY target_id ORDER BY created_at DESC) AS rn + FROM review_audit + WHERE event_type = 'pr_outcome' AND decision IS NOT NULL AND created_at >= ? +) +SELECT gd.decided_at AS decided_at, po.outcome_at AS outcome_at +FROM gd +JOIN po ON gd.target_id = po.target_id +WHERE gd.rn = 1 AND po.rn = 1`; + +/** Load paired gate_decision → pr_outcome cycle times for the stats window. Fail-safe → empty aggregate. */ +export async function computeCycleTimeAggregate( + env: Env, + opts: { days: number; nowMs: number }, +): Promise { + const days = Number.isFinite(opts.days) && opts.days > 0 ? Math.min(opts.days, 730) : 90; + const fromIso = new Date(opts.nowMs - days * 86_400_000).toISOString().slice(0, 10); + try { + const rows = await storage(env) + .prepare(CYCLE_TIME_SQL) + .bind(fromIso, fromIso) + .all<{ decided_at: string; outcome_at: string }>(); + const samples = (rows.results ?? []) + .map((row) => cycleTimeMs(row.decided_at, row.outcome_at)) + .filter((ms): ms is number => ms !== null); + return aggregateCycleTimePercentiles(samples); + } catch { + return EMPTY_CYCLE_TIME; + } } /** Fold per-PR persisted minutes into the maintainer aggregate (avg band + total minutes). */ @@ -224,7 +324,7 @@ export async function computeStats( const bucketExpr = BUCKET_SQL[bucket] ?? BUCKET_SQL.day; const fromIso = new Date(opts.nowMs - days * 86_400_000).toISOString().slice(0, 10); // YYYY-MM-DD - const [decisionRows, reversalRows, effortRows] = await Promise.all([ + const [decisionRows, reversalRows, effortRows, cycleTime] = await Promise.all([ storage(env).prepare( `SELECT ${bucketExpr} AS bucket, project, COALESCE(verdict, status) AS verdict, COUNT(*) AS n FROM review_targets @@ -258,6 +358,7 @@ export async function computeStats( )`, ).bind(fromIso).all<{ minutes: number }>() .catch(() => ({ results: [] as Array<{ minutes: number }> })), + computeCycleTimeAggregate(env, { days, nowMs: opts.nowMs }), ]); // Non-content gate decisions (incl. SHADOW would-actions) — recorded as `gate_decision` audit rows with @@ -294,6 +395,7 @@ export async function computeStats( gateEval, recommendations, gateParity: { ...parity, cutoverReady: parity.rows.map((r) => ({ project: r.project, ready: isParityCutoverReady(r) })) }, + cycleTime, }; } diff --git a/src/services/operator-dashboard.ts b/src/services/operator-dashboard.ts index 01ae58fa0c..dcaa0a2630 100644 --- a/src/services/operator-dashboard.ts +++ b/src/services/operator-dashboard.ts @@ -27,6 +27,7 @@ import type { } from "../types"; import { computeFleetAnalytics, type FleetAnalytics } from "../orb/analytics"; import { computeGateEval, type GateEvalReport } from "../review/parity"; +import { computeCycleTimeAggregate, type CycleTimeAggregate } from "../review/stats"; import { loadUpstreamStatus, type UpstreamStatus } from "../upstream/ruleset"; import { nowIso } from "../utils/json"; import { buildRecommendationQualityReport, type RecommendationQualityReport } from "./recommendation-quality-report"; @@ -63,6 +64,8 @@ export type OperatorDashboardPayload = { // Gate-precision eval (#2191): the per-project confusion matrix + precisions from computeGateEval, surfaced // read-only for the maintainer analytics card. Fail-safe empty report when there is no review_audit signal. gateEval: GateEvalReport; + // PR review cycle-time percentiles (#2194): gate decision → outcome from review_audit; fail-safe empty aggregate. + cycleTime: CycleTimeAggregate; }; const USAGE_WINDOW_DAYS = 7; @@ -87,6 +90,7 @@ export async function buildOperatorDashboardPayload(env: Env): Promise { // #2191: gate-eval report is surfaced read-only; with no review_audit signal it fails safe to an empty // report (no rows, no signal) rather than being absent. expect(payload.gateEval).toEqual({ rows: [], hasSignal: false }); + expect(payload.cycleTime).toEqual({ + p50Ms: null, + p90Ms: null, + p99Ms: null, + distribution: [], + sampleSize: 0, + }); // Empty fleet → instanceCount 0, null precision card ("—"), no-outlier delta. expect(payload.fleetMetrics.instanceCount).toBe(0); expect(payload.metrics).toEqual( diff --git a/test/unit/stats.test.ts b/test/unit/stats.test.ts index 20460576ee..45c31d919a 100644 --- a/test/unit/stats.test.ts +++ b/test/unit/stats.test.ts @@ -1,13 +1,18 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createTestEnv } from "../helpers/d1"; import { + aggregateCycleTimePercentiles, aggregateReviewEffort, + buildCycleTimeDistribution, computeStats, + cycleTimeMs, + EMPTY_CYCLE_TIME, handleParity, handleStats, isParityCutoverReady, MIN_PARITY_SAMPLE, PARITY_AGREEMENT_FLOOR, + percentileNearestRank, type GateParityRow, type StatsEvalDeps, } from "../../src/review/stats"; @@ -28,6 +33,10 @@ function stubEnv(extra: Record = {}): Env { { minutes: 4 }, { minutes: 96 }, ]; + const cyclePairs = [ + { decided_at: "2026-06-01T10:00:00Z", outcome_at: "2026-06-01T10:05:00Z" }, + { decided_at: "2026-06-01T11:00:00Z", outcome_at: "2026-06-01T11:30:00Z" }, + ]; let lastSql = ""; return { ...extra, @@ -37,15 +46,17 @@ function stubEnv(extra: Record = {}): Env { return { bind: () => ({ all: async () => ({ - // review-effort read → effortMinutes; gate_decision → gateActions; other review_audit → reversals; - // everything else → decision rows. (The eval/parity engine is the default no-op deps.) + // review-effort read → effortMinutes; cycle-time pairs → cyclePairs; gate action counts → gateActions; + // other review_audit → reversals; everything else → decision rows. results: lastSql.includes("reviewEffortMinutes") ? effortMinutes - : lastSql.includes("gate_decision") - ? gateActions - : lastSql.includes("review_audit") - ? reversals - : decisions, + : lastSql.includes("decided_at") && lastSql.includes("outcome_at") + ? cyclePairs + : lastSql.includes("decision AS action") + ? gateActions + : lastSql.includes("review_audit") + ? reversals + : decisions, }), }), }; @@ -72,6 +83,9 @@ describe("computeStats — D1 aggregate for the dashboard", () => { expect(out.recommendations).toEqual([]); expect(out.gateParity.cutoverReady).toEqual([]); expect(out.reviewEffort).toEqual({ avgBand: 3, totalEstimatedMinutes: 100 }); + expect(out.cycleTime.sampleSize).toBe(2); + expect(out.cycleTime.p50Ms).toBe(300_000); + expect(out.cycleTime.distribution.length).toBeGreaterThan(0); }); it("clamps an absurd window and falls back to a safe bucket", async () => { @@ -249,7 +263,7 @@ describe("computeStats — gate-decision read is fail-safe", () => { return { bind: () => ({ all: async () => { - if (lastSql.includes("gate_decision")) throw new Error("gate read down"); + if (lastSql.includes("decision AS action")) throw new Error("gate read down"); return { results: lastSql.includes("review_audit") ? [] : decisions }; }, }), @@ -408,6 +422,73 @@ describe("computeStats — NaN window + null D1 results (the ?? [] fallbacks)", expect(out.projects).toEqual([]); expect(out.verdicts).toEqual([]); expect(out.reviewEffort).toEqual({ avgBand: null, totalEstimatedMinutes: 0 }); + expect(out.cycleTime).toEqual(EMPTY_CYCLE_TIME); + }); +}); + +describe("cycle-time aggregation (#2194)", () => { + it("cycleTimeMs rejects negative and non-finite deltas", () => { + expect(cycleTimeMs("2026-06-01T10:00:00Z", "2026-06-01T09:00:00Z")).toBeNull(); + expect(cycleTimeMs("bad", "2026-06-01T09:00:00Z")).toBeNull(); + expect(cycleTimeMs("2026-06-01T10:00:00Z", "2026-06-01T10:05:00Z")).toBe(300_000); + }); + + it("percentileNearestRank uses nearest-rank on sorted samples", () => { + const sorted = [100, 200, 300, 400]; + expect(percentileNearestRank(sorted, 50)).toBe(200); + expect(percentileNearestRank(sorted, 90)).toBe(400); + expect(percentileNearestRank([], 50)).toBeNull(); + }); + + it("buildCycleTimeDistribution returns [] for empty input and a single bucket when all samples match", () => { + expect(buildCycleTimeDistribution([])).toEqual([]); + expect(buildCycleTimeDistribution([5, 5, 5])).toEqual([3]); + }); + + it("aggregateCycleTimePercentiles folds samples into p50/p90/p99 + distribution", () => { + const agg = aggregateCycleTimePercentiles([60_000, 120_000, 180_000, 240_000, 300_000]); + expect(agg.sampleSize).toBe(5); + expect(agg.p50Ms).toBe(180_000); + expect(agg.p90Ms).toBe(300_000); + expect(agg.p99Ms).toBe(300_000); + expect(agg.distribution.length).toBeGreaterThan(0); + }); + + it("aggregateCycleTimePercentiles returns EMPTY_CYCLE_TIME for no valid samples", () => { + expect(aggregateCycleTimePercentiles([])).toEqual(EMPTY_CYCLE_TIME); + expect(aggregateCycleTimePercentiles([-1, Number.NaN])).toEqual(EMPTY_CYCLE_TIME); + }); + + it("computeCycleTimeAggregate reads paired review_audit rows from D1", async () => { + const env = createTestEnv(); + await env.DB.prepare( + `INSERT INTO review_audit (id, project, target_id, event_type, decision, source, created_at) VALUES + ('gd1', 'owner/repo', 'owner/repo#1', 'gate_decision', 'merge', 'test', '2026-06-10T10:00:00Z'), + ('po1', 'owner/repo', 'owner/repo#1', 'pr_outcome', 'merged', 'test', '2026-06-10T10:10:00Z'), + ('gd2', 'owner/repo', 'owner/repo#2', 'gate_decision', 'close', 'test', '2026-06-11T10:00:00Z'), + ('po2', 'owner/repo', 'owner/repo#2', 'pr_outcome', 'closed', 'test', '2026-06-11T10:30:00Z')`, + ).run(); + const { computeCycleTimeAggregate } = await import("../../src/review/stats"); + const agg = await computeCycleTimeAggregate(env, { days: 90, nowMs: NOW }); + expect(agg.sampleSize).toBe(2); + expect(agg.p50Ms).toBe(600_000); + expect(agg.distribution.length).toBeGreaterThan(0); + }); + + it("computeCycleTimeAggregate fails safe to EMPTY_CYCLE_TIME when the query rejects", async () => { + const env = { + DB: { + prepare: () => ({ + bind: () => ({ + all: async () => { + throw new Error("d1 down"); + }, + }), + }), + }, + } as unknown as Env; + const { computeCycleTimeAggregate } = await import("../../src/review/stats"); + expect(await computeCycleTimeAggregate(env, { days: 30, nowMs: NOW })).toEqual(EMPTY_CYCLE_TIME); }); }); From 45d6296aeef700daf657414f99506d8f914a08ba Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 9 Jul 2026 15:31:51 -0500 Subject: [PATCH 2/3] test(stats): cover cycle-time aggregate branch paths Exercise computeCycleTimeAggregate day clamping/defaulting, missing D1 results, and null cycle deltas so codecov patch meets the 99% floor. Co-authored-by: Cursor --- src/review/stats.ts | 2 +- test/unit/stats.test.ts | 69 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/review/stats.ts b/src/review/stats.ts index 63427ba01e..f10955aa1d 100644 --- a/src/review/stats.ts +++ b/src/review/stats.ts @@ -238,7 +238,7 @@ export function buildCycleTimeDistribution(samplesMs: number[], bucketCount = 12 const min = Math.min(...samplesMs); if (max === min) return [samplesMs.length]; const buckets = Array.from({ length: bucketCount }, () => 0); - const span = max - min || 1; + const span = max - min; for (const ms of samplesMs) { const idx = Math.min(bucketCount - 1, Math.floor(((ms - min) / span) * bucketCount)); buckets[idx]! += 1; diff --git a/test/unit/stats.test.ts b/test/unit/stats.test.ts index 45c31d919a..5007f507ce 100644 --- a/test/unit/stats.test.ts +++ b/test/unit/stats.test.ts @@ -445,6 +445,10 @@ describe("cycle-time aggregation (#2194)", () => { expect(buildCycleTimeDistribution([5, 5, 5])).toEqual([3]); }); + it("buildCycleTimeDistribution places the max sample in the last bucket (boundary clamp)", () => { + expect(buildCycleTimeDistribution([0, 100], 2)).toEqual([1, 1]); + }); + it("aggregateCycleTimePercentiles folds samples into p50/p90/p99 + distribution", () => { const agg = aggregateCycleTimePercentiles([60_000, 120_000, 180_000, 240_000, 300_000]); expect(agg.sampleSize).toBe(5); @@ -490,6 +494,71 @@ describe("cycle-time aggregation (#2194)", () => { const { computeCycleTimeAggregate } = await import("../../src/review/stats"); expect(await computeCycleTimeAggregate(env, { days: 30, nowMs: NOW })).toEqual(EMPTY_CYCLE_TIME); }); + + it("computeCycleTimeAggregate defaults non-finite/non-positive days to 90", async () => { + let boundFrom: string | undefined; + const env = { + DB: { + prepare: () => ({ + bind: (fromIso: string) => { + boundFrom = fromIso; + return { all: async () => ({ results: [] as Array<{ decided_at: string; outcome_at: string }> }) }; + }, + }), + }, + } as unknown as Env; + const { computeCycleTimeAggregate } = await import("../../src/review/stats"); + await computeCycleTimeAggregate(env, { days: Number.NaN, nowMs: NOW }); + expect(boundFrom).toBe(new Date(NOW - 90 * 86_400_000).toISOString().slice(0, 10)); + }); + + it("computeCycleTimeAggregate clamps days to 730", async () => { + let boundFrom: string | undefined; + const env = { + DB: { + prepare: () => ({ + bind: (fromIso: string) => { + boundFrom = fromIso; + return { all: async () => ({ results: [] as Array<{ decided_at: string; outcome_at: string }> }) }; + }, + }), + }, + } as unknown as Env; + const { computeCycleTimeAggregate } = await import("../../src/review/stats"); + await computeCycleTimeAggregate(env, { days: 99_999, nowMs: NOW }); + expect(boundFrom).toBe(new Date(NOW - 730 * 86_400_000).toISOString().slice(0, 10)); + }); + + it("computeCycleTimeAggregate tolerates missing D1 results and skips null cycle deltas", async () => { + const env = { + DB: { + prepare: () => ({ + bind: () => ({ + all: async () => ({ + results: undefined, + }), + }), + }), + }, + } as unknown as Env; + const envWithBadRows = { + DB: { + prepare: () => ({ + bind: () => ({ + all: async () => ({ + results: [ + { decided_at: "2026-06-01T10:00:00Z", outcome_at: "2026-06-01T09:00:00Z" }, + { decided_at: "bad", outcome_at: "2026-06-01T09:00:00Z" }, + ], + }), + }), + }), + }, + } as unknown as Env; + const { computeCycleTimeAggregate } = await import("../../src/review/stats"); + expect(await computeCycleTimeAggregate(env, { days: 30, nowMs: NOW })).toEqual(EMPTY_CYCLE_TIME); + expect(await computeCycleTimeAggregate(envWithBadRows, { days: 30, nowMs: NOW })).toEqual(EMPTY_CYCLE_TIME); + }); }); describe("isParityCutoverReady — every gate condition", () => { From 0867714bae8634440f5c6c4fdc12201267d5a6ca Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 9 Jul 2026 15:35:09 -0500 Subject: [PATCH 3/3] style(ui): fix cycle-time-card prettier line breaks Co-authored-by: Cursor --- .../src/components/site/app-panels/cycle-time-card.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/gittensory-ui/src/components/site/app-panels/cycle-time-card.tsx b/apps/gittensory-ui/src/components/site/app-panels/cycle-time-card.tsx index 31068c70b9..08a7bc2443 100644 --- a/apps/gittensory-ui/src/components/site/app-panels/cycle-time-card.tsx +++ b/apps/gittensory-ui/src/components/site/app-panels/cycle-time-card.tsx @@ -17,8 +17,8 @@ export function CycleTimeCard({ cycleTime }: { cycleTime: CycleTimeAggregate })

Review cycle time

- Gate decision → PR outcome duration percentiles from review_audit. Public-safe aggregates - only. + Gate decision → PR outcome duration percentiles from review_audit. Public-safe + aggregates only.

@@ -54,8 +54,8 @@ export function CycleTimeCard({ cycleTime }: { cycleTime: CycleTimeAggregate }) ) : (

- Paired gate decisions and PR outcomes will appear here once the gate has resolved pull requests - in the analytics window. + Paired gate decisions and PR outcomes will appear here once the gate has resolved pull + requests in the analytics window.

)}