Skip to content

Commit 2360a40

Browse files
feat: dashboard zoom, hero modals, departed-dev filtering
- Add dataZoom (slider mini-map + shift-wheel inside zoom) to all time-series category charts (line, bar, dualLine, multiLine, stackedBar, area, devVelocity). - Hero "Active Devs" tile opens a modal listing last-30-day devs ordered by PR count; "Total PRs" tile opens a modal of the 20 most recent PRs. Backed by new active_devs_list and recent_prs_list in _hero_stats. - Engineering Team roster: drop members whose end is more than 60 days ago, sort active first then departed by most-recent end date. - Per-team Developer Velocity multi-line: filter departed devs (>60d since end) via developer-tenure.yaml, sort series by name, and select only the first developer by default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c2e7d9c commit 2360a40

2 files changed

Lines changed: 193 additions & 10 deletions

File tree

reports/chart_data.py

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,17 @@
66
import numpy as np
77
import pandas as pd
88

9-
from cli.team_config import get_weekly_headcounts, load_team_mapping
9+
from cli.team_config import get_weekly_headcounts, load_developer_tenure, load_team_mapping
10+
11+
12+
def _departed_developers(cutoff_days: int = 60) -> "set[str]":
13+
"""Usernames whose tenure ended more than `cutoff_days` ago."""
14+
tenure = load_developer_tenure()
15+
cutoff = (pd.Timestamp.now().normalize() - pd.Timedelta(days=cutoff_days)).date()
16+
return {
17+
name for name, info in tenure.items()
18+
if info.get("end") and info["end"] < cutoff
19+
}
1020

1121

1222
def _ensure_date(df: pd.DataFrame) -> pd.DataFrame:
@@ -515,8 +525,13 @@ def _extract_team(df: pd.DataFrame) -> List[Dict[str, Any]]:
515525
dev_week_cx = dev_week_cx.reindex(all_week_dates, fill_value=0)
516526
dev_week_cnt = dev_week_cnt.reindex(all_week_dates, fill_value=0)
517527
week_labels_30 = [w.strftime("%Y-%m-%d") for w in all_week_dates]
528+
departed = _departed_developers()
529+
devs_sorted = sorted(
530+
(d for d in dev_week_cx.columns if d not in departed),
531+
key=lambda x: str(x).lower(),
532+
)
518533
series_30 = []
519-
for dev in dev_week_cx.columns:
534+
for dev in devs_sorted:
520535
cx_vals = [round(float(v), 2) for v in dev_week_cx[dev].tolist()]
521536
if dev in dev_week_cnt.columns:
522537
cnt_vals = [int(v) for v in dev_week_cnt[dev].tolist()]
@@ -919,6 +934,48 @@ def _extract_hero_stats(df: pd.DataFrame) -> Dict[str, Any]:
919934
dev_col = "developer" if "developer" in df.columns else "author"
920935
active_devs = last_30d[dev_col].nunique() if not last_30d.empty else 0
921936

937+
# Top active devs in the last 30 days (for the Active Devs tile modal).
938+
active_devs_list: list[dict] = []
939+
if not last_30d.empty and dev_col in last_30d.columns:
940+
grouped = last_30d.groupby(dev_col).agg(
941+
prs=(dev_col, "size"),
942+
complexity=("complexity", "sum") if "complexity" in last_30d.columns else (dev_col, "size"),
943+
)
944+
if "team" in last_30d.columns:
945+
team_map = last_30d.groupby(dev_col)["team"].agg(
946+
lambda s: s.dropna().iloc[0] if not s.dropna().empty else ""
947+
)
948+
grouped["team"] = team_map
949+
grouped = grouped.sort_values("prs", ascending=False)
950+
for name, row in grouped.iterrows():
951+
active_devs_list.append({
952+
"developer": str(name),
953+
"team": str(row.get("team", "")) if "team" in grouped.columns else "",
954+
"prs": int(row["prs"]),
955+
"complexity": round(float(row["complexity"]), 1),
956+
})
957+
958+
# 20 most recent merged PRs (for the Total PRs tile modal).
959+
recent_prs_list: list[dict] = []
960+
sort_col = "merged_at" if "merged_at" in df.columns and df["merged_at"].notna().any() else "date"
961+
recent_df = df.sort_values(sort_col, ascending=False).head(20)
962+
for _, row in recent_df.iterrows():
963+
merged_at = row.get(sort_col)
964+
merged_str = ""
965+
if pd.notna(merged_at):
966+
try:
967+
merged_str = pd.to_datetime(merged_at).strftime("%Y-%m-%d")
968+
except Exception:
969+
merged_str = str(merged_at)[:10]
970+
recent_prs_list.append({
971+
"title": str(row.get("pr_title", "") or ""),
972+
"url": str(row.get("pr_url", "") or ""),
973+
"developer": str(row.get(dev_col, "") or ""),
974+
"team": str(row.get("team", "") or ""),
975+
"complexity": round(float(row.get("complexity", 0) or 0), 1),
976+
"merged_at": merged_str,
977+
})
978+
922979
# Total PRs and avg complexity
923980
total_prs = len(df)
924981
avg_cx = round(df["complexity"].mean(), 1) if "complexity" in df.columns else 0
@@ -928,6 +985,8 @@ def _extract_hero_stats(df: pd.DataFrame) -> Dict[str, Any]:
928985
"active_developers": active_devs,
929986
"total_prs": total_prs,
930987
"avg_complexity": avg_cx,
988+
"active_devs_list": active_devs_list,
989+
"recent_prs_list": recent_prs_list,
931990
}
932991

933992

reports/interactive_report.py

Lines changed: 132 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,8 @@ def build_interactive_report(
394394
.hero-card:hover::before {{
395395
opacity: 1;
396396
}}
397+
.hero-card.hero-clickable {{ cursor: pointer; }}
398+
.hero-card.hero-clickable:hover {{ box-shadow: 0 16px 40px rgba(0,0,0,0.12); }}
397399
.hero-value {{
398400
font-family: 'Syne', sans-serif;
399401
font-size: 3rem;
@@ -1142,6 +1144,26 @@ def build_interactive_report(
11421144
11431145
const COLORS = ['#b45309', '#0d9488', '#7c3aed', '#2563eb', '#ea580c', '#16a34a', '#dc2626', '#6b7280'];
11441146
1147+
function applyTimeZoom(opt, options) {{
1148+
// Adds wheel/pinch zoom + a slider mini-map to time-series category charts.
1149+
// topSlider=true places the slider above the grid (use when legend is at bottom).
1150+
options = options || {{}};
1151+
const topSlider = !!options.topSlider;
1152+
const inside = {{ type: 'inside', xAxisIndex: 0, throttle: 30, zoomOnMouseWheel: 'shift', moveOnMouseWheel: false, moveOnMouseMove: false }};
1153+
const grid = opt.grid || {{}};
1154+
let slider;
1155+
if (topSlider) {{
1156+
slider = {{ type: 'slider', xAxisIndex: 0, top: 6, height: 14, brushSelect: false, showDetail: false }};
1157+
opt.grid = {{ ...grid, top: Math.max(grid.top || 40, 56) }};
1158+
}} else {{
1159+
const curBottom = grid.bottom != null ? grid.bottom : 60;
1160+
slider = {{ type: 'slider', xAxisIndex: 0, bottom: 8, height: 14, brushSelect: false, showDetail: false }};
1161+
opt.grid = {{ ...grid, bottom: curBottom + 28 }};
1162+
}}
1163+
opt.dataZoom = [inside, slider];
1164+
return opt;
1165+
}}
1166+
11451167
function renderBar(container, c) {{
11461168
const opt = {{
11471169
...CHART_THEME,
@@ -1151,6 +1173,7 @@ def build_interactive_report(
11511173
yAxis: {{ type: 'value', minInterval: 0 }},
11521174
series: [{{ type: 'bar', data: c.y, barWidth: '50%', barMinWidth: 20, barMaxWidth: 100, itemStyle: {{ color: COLORS[0] }} }}],
11531175
}};
1176+
applyTimeZoom(opt);
11541177
const chart = echarts.init(container);
11551178
chart.setOption(opt);
11561179
window.addEventListener('resize', () => chart.resize());
@@ -1176,6 +1199,7 @@ def build_interactive_report(
11761199
yAxis: {{ type: 'value', minInterval: 0 }},
11771200
series: [seriesItem],
11781201
}};
1202+
applyTimeZoom(opt);
11791203
const chart = echarts.init(container);
11801204
chart.setOption(opt);
11811205
window.addEventListener('resize', () => chart.resize());
@@ -1198,6 +1222,7 @@ def build_interactive_report(
11981222
{{ type: 'line', name: c.y2Name, data: c.y2, smooth: true, yAxisIndex: 1, itemStyle: {{ color: COLORS[1] }} }},
11991223
],
12001224
}};
1225+
applyTimeZoom(opt);
12011226
const chart = echarts.init(container);
12021227
chart.setOption(opt);
12031228
window.addEventListener('resize', () => chart.resize());
@@ -1244,6 +1269,7 @@ def build_interactive_report(
12441269
yAxis: {{ type: 'value', minInterval: 0 }},
12451270
series,
12461271
}};
1272+
applyTimeZoom(opt, {{ topSlider: true }});
12471273
const chart = echarts.init(container);
12481274
chart.setOption(opt);
12491275
window.addEventListener('resize', () => chart.resize());
@@ -1407,6 +1433,7 @@ def build_interactive_report(
14071433
yAxis: {{ type: 'value', minInterval: 0 }},
14081434
series,
14091435
}};
1436+
applyTimeZoom(opt, {{ topSlider: true }});
14101437
const chart = echarts.init(container);
14111438
chart.setOption(opt);
14121439
window.addEventListener('resize', () => chart.resize());
@@ -1472,6 +1499,7 @@ def build_interactive_report(
14721499
yAxis: {{ type: 'value', minInterval: 0 }},
14731500
series: [{{ type: 'line', data: c.y, areaStyle: {{}}, smooth: true, itemStyle: {{ color: COLORS[0] }} }}],
14741501
}};
1502+
applyTimeZoom(opt);
14751503
const chart = echarts.init(container);
14761504
chart.setOption(opt);
14771505
window.addEventListener('resize', () => chart.resize());
@@ -1492,14 +1520,21 @@ def build_interactive_report(
14921520
smooth: false,
14931521
connectNulls: false,
14941522
}}));
1523+
const defaultSelected = {{}};
1524+
series.forEach((s, i) => {{ defaultSelected[s.name] = i === 0; }});
14951525
chart.setOption({{
14961526
...CHART_THEME,
14971527
legend: {{
14981528
type: 'scroll',
14991529
bottom: 0,
15001530
textStyle: {{fontSize: 11}},
1531+
selected: defaultSelected,
15011532
}},
1502-
grid: {{top: 28, right: 16, bottom: 60, left: 48, containLabel: false}},
1533+
dataZoom: [
1534+
{{ type: 'inside', xAxisIndex: 0, throttle: 30, zoomOnMouseWheel: 'shift', moveOnMouseWheel: false, moveOnMouseMove: false }},
1535+
{{ type: 'slider', xAxisIndex: 0, top: 6, height: 14, brushSelect: false, showDetail: false }},
1536+
],
1537+
grid: {{top: 56, right: 16, bottom: 60, left: 48, containLabel: false}},
15031538
xAxis: {{
15041539
type: 'category',
15051540
data: weeks,
@@ -1711,12 +1746,31 @@ def build_interactive_report(
17111746
}}
17121747
17131748
if (key === 'engineers') {{
1714-
const allMembers = engineersData.flatMap(t => t.members);
1749+
// Drop members who left more than 2 months ago from the current-state roster.
1750+
// Historical charts/leaderboards keep their PRs — this only filters the People view.
1751+
const cutoffMs = Date.now() - 60 * 24 * 60 * 60 * 1000;
1752+
const teamsView = engineersData
1753+
.map(t => {{
1754+
const members = (t.members || [])
1755+
.filter(m => !m.end || new Date(m.end + 'T00:00:00').getTime() >= cutoffMs)
1756+
.slice()
1757+
.sort((a, b) => {{
1758+
const aEnded = a.end != null;
1759+
const bEnded = b.end != null;
1760+
if (aEnded !== bEnded) return aEnded ? 1 : -1; // active first
1761+
if (aEnded && bEnded) return b.end.localeCompare(a.end); // most recent end first
1762+
return (a.username || '').localeCompare(b.username || '');
1763+
}});
1764+
return {{ ...t, members }};
1765+
}})
1766+
.filter(t => t.members.length > 0);
1767+
1768+
const allMembers = teamsView.flatMap(t => t.members);
17151769
const totalActive = allMembers.filter(m => m.active === true).length;
17161770
const totalEnded = allMembers.filter(m => m.end != null).length;
17171771
const totalOnLeave = allMembers.filter(m => m.leaves && m.leaves.length > 0).length;
17181772
const totalNoData = allMembers.filter(m => m.start == null).length;
1719-
const totalTeams = engineersData.length;
1773+
const totalTeams = teamsView.length;
17201774
17211775
const earliest = allMembers.filter(m => m.start).map(m => m.start).sort()[0] || '2024-01-01';
17221776
const today = new Date().toISOString().slice(0, 10);
@@ -1754,7 +1808,7 @@ def build_interactive_report(
17541808
}}
17551809
17561810
let teamCards = '';
1757-
engineersData.forEach((t, ti) => {{
1811+
teamsView.forEach((t, ti) => {{
17581812
const active = t.members.filter(m => m.active === true).length;
17591813
const rows = t.members.map(m => `
17601814
<tr>
@@ -1889,15 +1943,15 @@ def build_interactive_report(
18891943
<div class="hero-label">Velocity/Dev</div>
18901944
<div class="hero-sublabel">Complexity per capita per week</div>
18911945
</div>
1892-
<div class="hero-card">
1946+
<div class="hero-card hero-clickable" data-hero="active_devs">
18931947
<div class="hero-value">${{heroStats.active_developers || 0}}</div>
18941948
<div class="hero-label">Active Devs</div>
1895-
<div class="hero-sublabel">Last 30 days</div>
1949+
<div class="hero-sublabel">Last 30 days &middot; click to view</div>
18961950
</div>
1897-
<div class="hero-card">
1951+
<div class="hero-card hero-clickable" data-hero="total_prs">
18981952
<div class="hero-value">${{heroStats.total_prs || 0}}</div>
18991953
<div class="hero-label">Total PRs</div>
1900-
<div class="hero-sublabel">All time</div>
1954+
<div class="hero-sublabel">All time &middot; click to view recent</div>
19011955
</div>
19021956
<div class="hero-card">
19031957
<div class="hero-value">${{heroStats.avg_complexity || 0}}</div>
@@ -2015,6 +2069,10 @@ def build_interactive_report(
20152069
panel.innerHTML = html;
20162070
panelsEl.appendChild(panel);
20172071
2072+
panel.querySelectorAll('.hero-clickable').forEach(card => {{
2073+
card.addEventListener('click', () => openHeroModal(card.dataset.hero));
2074+
}});
2075+
20182076
if (hasSubtabs) {{
20192077
panel.querySelectorAll('.subtab').forEach(btn => {{
20202078
btn.onclick = () => {{
@@ -2172,6 +2230,72 @@ def build_interactive_report(
21722230
const ddBody = document.getElementById('dd-body');
21732231
const ddClose = document.getElementById('dd-close');
21742232
2233+
function escapeHtml(s) {{
2234+
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }})[c]);
2235+
}}
2236+
2237+
function openHeroModal(kind) {{
2238+
const ddOverlay = document.getElementById('drilldown-overlay');
2239+
const ddTitle = document.getElementById('dd-title');
2240+
const ddBody = document.getElementById('dd-body');
2241+
2242+
if (kind === 'active_devs') {{
2243+
const list = (heroStats.active_devs_list || []);
2244+
ddTitle.innerHTML = `Active Developers — last 30 days<span class="dd-count">${{list.length}} dev${{list.length !== 1 ? 's' : ''}}</span>`;
2245+
if (!list.length) {{
2246+
ddBody.innerHTML = '<p style="padding:1rem;color:var(--text-muted)">No PR activity in the last 30 days.</p>';
2247+
}} else {{
2248+
let html = '<table class="dd-table"><thead><tr><th>#</th><th>Developer</th><th>Team</th><th>PRs</th><th>Complexity</th></tr></thead><tbody>';
2249+
list.forEach((d, i) => {{
2250+
html += `<tr>
2251+
<td style="font-family:'IBM Plex Mono',monospace;color:var(--text-muted)">${{i + 1}}</td>
2252+
<td class="cell-name"><span class="name-text">${{escapeHtml(d.developer)}}</span></td>
2253+
<td style="font-size:0.85rem">${{escapeHtml(d.team || '—')}}</td>
2254+
<td><b>${{d.prs}}</b></td>
2255+
<td>${{d.complexity}}</td>
2256+
</tr>`;
2257+
}});
2258+
html += '</tbody></table>';
2259+
ddBody.innerHTML = html;
2260+
}}
2261+
ddOverlay.classList.add('open');
2262+
return;
2263+
}}
2264+
2265+
if (kind === 'total_prs') {{
2266+
const list = (heroStats.recent_prs_list || []);
2267+
ddTitle.innerHTML = `Recent PRs<span class="dd-count">${{list.length}} most recent</span>`;
2268+
if (!list.length) {{
2269+
ddBody.innerHTML = '<p style="padding:1rem;color:var(--text-muted)">No PRs available.</p>';
2270+
}} else {{
2271+
const cxColor = (v) => v >= 8 ? '#991b1b' : v >= 5 ? '#92400e' : '#065f46';
2272+
const cxBg = (v) => v >= 8 ? '#fee2e2' : v >= 5 ? '#fef3c7' : '#d1fae5';
2273+
let html = '<table class="dd-table"><thead><tr><th>PR</th><th>Developer</th><th>Team</th><th>Complexity</th><th>Merged</th><th>Link</th></tr></thead><tbody>';
2274+
list.forEach(pr => {{
2275+
const title = pr.title || pr.url || '—';
2276+
const display = title.length > 70 ? title.slice(0, 70) + '…' : title;
2277+
const cx = pr.complexity || 0;
2278+
const badge = `<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>`;
2279+
const link = pr.url
2280+
? `<a href="${{escapeHtml(pr.url)}}" target="_blank" rel="noopener" style="color:var(--accent);font-size:0.85rem">→ Open</a>`
2281+
: '—';
2282+
html += `<tr>
2283+
<td class="cell-name" title="${{escapeHtml(title)}}"><span class="name-text">${{escapeHtml(display)}}</span></td>
2284+
<td style="font-size:0.85rem">${{escapeHtml(pr.developer || '—')}}</td>
2285+
<td style="font-size:0.85rem">${{escapeHtml(pr.team || '—')}}</td>
2286+
<td>${{badge}}</td>
2287+
<td style="font-family:'IBM Plex Mono',monospace;font-size:0.78rem">${{escapeHtml(pr.merged_at || '—')}}</td>
2288+
<td>${{link}}</td>
2289+
</tr>`;
2290+
}});
2291+
html += '</tbody></table>';
2292+
ddBody.innerHTML = html;
2293+
}}
2294+
ddOverlay.classList.add('open');
2295+
return;
2296+
}}
2297+
}}
2298+
21752299
function openDevPrModal(developer, week, prs) {{
21762300
const ddOverlay = document.getElementById('drilldown-overlay');
21772301
const ddTitle = document.getElementById('dd-title');

0 commit comments

Comments
 (0)