@@ -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 · 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 · 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 => ({{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }})[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