Skip to content

Commit 8e6e280

Browse files
feat: clickable Velocity Per Capita chart with PR drilldown modal
Adds a debug-friendly modal to the org-wide Velocity Per Capita chart so clicking a weekly point lists every PR contributing that week — with complexity, developer, team, repo and source — sorted by complexity. Wires _build_velocity_prs into chart_data and an openVelocityPrModal in the JS template, paralleling the existing cycle-time drilldown. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c8db344 commit 8e6e280

2 files changed

Lines changed: 125 additions & 1 deletion

File tree

reports/chart_data.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,55 @@ def _build_cycle_time_prs(df: pd.DataFrame) -> Dict[str, Any]:
202202
return result
203203

204204

205+
def _build_velocity_prs(df: pd.DataFrame) -> Dict[str, Any]:
206+
"""Lookup: scope -> week_start_str -> list[pr_dict] for velocity drilldown.
207+
208+
Scope is "_all" for the org-wide chart (excludes Bots/Unknown).
209+
"""
210+
if df.empty or "date" not in df.columns:
211+
return {}
212+
213+
vdf = df.copy()
214+
vdf["_dt"] = pd.to_datetime(vdf["date"], format="mixed", utc=False, errors="coerce")
215+
vdf = vdf.dropna(subset=["_dt"])
216+
if vdf.empty:
217+
return {}
218+
219+
vdf["_week"] = vdf["_dt"].dt.to_period("W").dt.start_time
220+
vdf["_team"] = vdf.get("team", pd.Series([""] * len(vdf))).fillna("").replace("", "Unknown")
221+
vdf = vdf[~vdf["_team"].isin(["Bots", "Unknown"])]
222+
if vdf.empty:
223+
return {}
224+
225+
dev_col = "developer" if "developer" in vdf.columns else "author"
226+
vdf["_dev"] = vdf.get(dev_col, pd.Series([""] * len(vdf))).fillna("").astype(str)
227+
228+
result: Dict[str, Any] = {}
229+
for _, row in vdf.iterrows():
230+
week = row["_week"]
231+
if pd.isna(week):
232+
continue
233+
week_key = week.strftime("%Y-%m-%d")
234+
235+
explanation = row.get("explanation", "")
236+
explanation = "" if pd.isna(explanation) else str(explanation).strip()
237+
pr_title = row.get("pr_title", "")
238+
pr_title = "" if pd.isna(pr_title) else str(pr_title).strip()
239+
pr_url = str(row.get("pr_url", "") or "")
240+
title = explanation or pr_title or _pr_title_from_url(pr_url)
241+
242+
pr_dict = {
243+
"title": title,
244+
"url": pr_url,
245+
"complexity": float(row.get("complexity", 0) or 0),
246+
"developer": row["_dev"],
247+
"team": row["_team"],
248+
}
249+
result.setdefault("_all", {}).setdefault(week_key, []).append(pr_dict)
250+
251+
return result
252+
253+
205254
def _extract_basic(df: pd.DataFrame) -> List[Dict[str, Any]]:
206255
charts = []
207256
df = _ensure_date(df)
@@ -236,12 +285,13 @@ def _extract_basic(df: pd.DataFrame) -> List[Dict[str, Any]]:
236285
"id": "22",
237286
"type": "line",
238287
"title": "Velocity Per Capita (by Week)",
239-
"subtitle": "Org-wide: total complexity / active developers",
288+
"subtitle": "Org-wide: total complexity / active developers · click a dot to see PRs",
240289
"overall_avg": avg_pc,
241290
"overall_avg_unit": "avg / week",
242291
"x": week_labels,
243292
"y": per_capita,
244293
"_section": "Velocity Metrics",
294+
"_velocity_scope": "_all",
245295
})
246296

247297
# 01: Complexity volume over time (bar)
@@ -1011,5 +1061,6 @@ def build_all_chart_data(df: pd.DataFrame) -> Dict[str, Any]:
10111061
"leaderboard": _extract_leaderboard(df),
10121062
"_team_dev_prs": _build_team_dev_prs(df),
10131063
"_cycle_time_prs": _build_cycle_time_prs(df),
1064+
"_velocity_prs": _build_velocity_prs(df),
10141065
"_hero_stats": _extract_hero_stats(df),
10151066
}

reports/interactive_report.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1655,6 +1655,7 @@ def build_interactive_report(
16551655
const featuresRows = chartData['_features_rows'] || [];
16561656
const teamDevPrs = chartData['_team_dev_prs'] || {{}};
16571657
const cycleTimePrs = chartData['_cycle_time_prs'] || {{}};
1658+
const velocityPrs = chartData['_velocity_prs'] || {{}};
16581659
16591660
tabOrder.forEach((key, i) => {{
16601661
const panel = document.createElement('div');
@@ -2120,6 +2121,14 @@ def build_interactive_report(
21202121
if (prs.length > 0) openCycleTimePrModal(scope, week, prs);
21212122
}});
21222123
}}
2124+
if (c._velocity_scope) {{
2125+
ch.on('click', function(params) {{
2126+
const week = params.name;
2127+
const scope = c._velocity_scope;
2128+
const prs = (velocityPrs[scope] || {{}})[week] || [];
2129+
if (prs.length > 0) openVelocityPrModal(scope, week, prs);
2130+
}});
2131+
}}
21232132
if (c.type === 'scatter' && c._pr_examples) {{
21242133
ch.on('click', function(params) {{
21252134
const linesChanged = params.data[0];
@@ -2434,6 +2443,70 @@ def build_interactive_report(
24342443
ddOverlay.classList.add('open');
24352444
}}
24362445
2446+
function openVelocityPrModal(scope, week, prs) {{
2447+
const ddOverlay = document.getElementById('drilldown-overlay');
2448+
const ddTitle = document.getElementById('dd-title');
2449+
const ddBody = document.getElementById('dd-body');
2450+
const weekDate = new Date(week + 'T00:00:00');
2451+
const weekFmt = weekDate.toLocaleDateString('en-US', {{month: 'short', day: 'numeric', year: 'numeric'}});
2452+
const totalCx = prs.reduce((s, p) => s + (p.complexity || 0), 0);
2453+
const devSet = new Set(prs.map(p => p.developer).filter(Boolean));
2454+
const perCapita = devSet.size > 0 ? (totalCx / devSet.size).toFixed(2) : '0';
2455+
const scopeLabel = scope === '_all' ? 'All Teams' : scope;
2456+
ddTitle.innerHTML = `Velocity Per Capita — ${{scopeLabel}} — week of ${{weekFmt}}<span class="dd-count">${{prs.length}} PR${{prs.length !== 1 ? 's' : ''}} · ${{devSet.size}} dev${{devSet.size !== 1 ? 's' : ''}} · total cx ${{totalCx}} · per-capita ${{perCapita}}</span>`;
2457+
2458+
const cxColor = (v) => v >= 8 ? '#991b1b' : v >= 5 ? '#92400e' : '#065f46';
2459+
const cxBg = (v) => v >= 8 ? '#fee2e2' : v >= 5 ? '#fef3c7' : '#d1fae5';
2460+
2461+
const sorted = prs.slice().sort((a, b) => (b.complexity || 0) - (a.complexity || 0));
2462+
2463+
let tableHtml = `<table class="dd-table">
2464+
<thead><tr>
2465+
<th>PR</th><th>Complexity</th><th>Developer</th><th>Team</th><th>Repo</th><th>Source</th><th>Link</th>
2466+
</tr></thead><tbody>`;
2467+
2468+
sorted.forEach(pr => {{
2469+
const title = pr.title || pr.url || '—';
2470+
const displayTitle = title.length > 60 ? title.slice(0, 60) + '…' : title;
2471+
const cx = pr.complexity || 0;
2472+
const cxBadge = `<span style="display:inline-block;font-size:0.7rem;font-family:'Syne',sans-serif;font-weight:600;padding:0.12rem 0.45rem;border-radius:4px;background:${{cxBg(cx)}};color:${{cxColor(cx)}}">${{cx}}</span>`;
2473+
const link = pr.url ? `<a href="${{pr.url}}" target="_blank" rel="noopener" style="color:var(--accent);font-size:0.85rem">→ Open PR</a>` : '—';
2474+
const dev = pr.developer || '—';
2475+
const team = pr.team || '—';
2476+
2477+
let repoName = '—';
2478+
let source = '—';
2479+
if (pr.url) {{
2480+
try {{
2481+
const url = new URL(pr.url);
2482+
if (url.hostname.includes('github.com')) {{
2483+
source = '<span style="display:inline-block;font-size:0.7rem;font-family:\\'Syne\\',sans-serif;font-weight:600;padding:0.12rem 0.45rem;border-radius:4px;background:#dbeafe;color:#1e40af">GitHub</span>';
2484+
const pathParts = url.pathname.split('/').filter(p => p);
2485+
if (pathParts.length >= 2) repoName = pathParts[1];
2486+
}} else if (url.hostname.includes('bitbucket.org')) {{
2487+
source = '<span style="display:inline-block;font-size:0.7rem;font-family:\\'Syne\\',sans-serif;font-weight:600;padding:0.12rem 0.45rem;border-radius:4px;background:#e0e7ff;color:#4338ca">Bitbucket</span>';
2488+
const pathParts = url.pathname.split('/').filter(p => p);
2489+
if (pathParts.length >= 2) repoName = pathParts[1];
2490+
}}
2491+
}} catch (e) {{}}
2492+
}}
2493+
2494+
tableHtml += `<tr>
2495+
<td class="cell-name" title="${{title}}"><span class="name-text">${{displayTitle}}</span></td>
2496+
<td>${{cxBadge}}</td>
2497+
<td style="font-size:0.85rem">${{dev}}</td>
2498+
<td style="font-size:0.85rem">${{team}}</td>
2499+
<td style="font-size:0.85rem">${{repoName}}</td>
2500+
<td>${{source}}</td>
2501+
<td>${{link}}</td>
2502+
</tr>`;
2503+
}});
2504+
2505+
tableHtml += '</tbody></table>';
2506+
ddBody.innerHTML = tableHtml;
2507+
ddOverlay.classList.add('open');
2508+
}}
2509+
24372510
function openScatterPrModal(linesChanged, complexity, prs) {{
24382511
const ddTitle = document.getElementById('dev-drill-title');
24392512
const ddBody = document.getElementById('dev-drill-body');

0 commit comments

Comments
 (0)