Skip to content

Commit c7c2749

Browse files
ctruedenclaude
andcommitted
Add clickable popup details to team HTML report
Store actual item details (URL, title, number, repo) in RepoStats and DeveloperRow instead of just counts, with URL-keyed deduplication across components that share a GitHub repo. The team HTML now embeds this data as JSON and shows a lightbox-style modal when clicking any nonzero cell, listing the contributing PRs/issues/bugs/features/releases with links. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 76f6836 commit c7c2749

4 files changed

Lines changed: 202 additions & 30 deletions

File tree

src/pombast/team/_github.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from collections import defaultdict
6-
from dataclasses import dataclass
6+
from dataclasses import dataclass, field
77
from typing import TYPE_CHECKING
88

99
from monoqueue.github import DEFAULT_CACHE_DIR, DEFAULT_MAX_AGE, fetch_items
@@ -12,12 +12,20 @@
1212
from pathlib import Path
1313

1414

15+
@dataclass
16+
class RepoItem:
17+
url: str
18+
title: str
19+
number: int
20+
repo: str # e.g. "imagej/imagej-common"
21+
22+
1523
@dataclass
1624
class RepoStats:
17-
prs: int = 0 # open non-draft PRs (→ reviewer role)
18-
issues: int = 0 # open issues, not labeled "question" (→ support role)
19-
bugs: int = 0 # open issues labeled "bug" (→ debugger role)
20-
enhancements: int = 0 # open issues labeled "enhancement", not milestone "unscheduled" (→ developer role)
25+
prs: list[RepoItem] = field(default_factory=list)
26+
issues: list[RepoItem] = field(default_factory=list)
27+
bugs: list[RepoItem] = field(default_factory=list)
28+
enhancements: list[RepoItem] = field(default_factory=list)
2129

2230

2331
def fetch_repo_stats(
@@ -42,14 +50,20 @@ def fetch_repo_stats(
4250
slug = item["repository_url"].removeprefix("https://api.github.com/repos/")
4351
labels = {lbl["name"] for lbl in item.get("labels", [])}
4452
milestone = (item.get("milestone") or {}).get("title", "").lower()
53+
ri = RepoItem(
54+
url=item.get("html_url", ""),
55+
title=item.get("title", ""),
56+
number=item.get("number", 0),
57+
repo=slug,
58+
)
4559
if "pull_request" in item:
4660
if not item.get("draft", False):
47-
stats[slug].prs += 1
61+
stats[slug].prs.append(ri)
4862
else:
4963
if "question" not in labels:
50-
stats[slug].issues += 1
64+
stats[slug].issues.append(ri)
5165
if "bug" in labels:
52-
stats[slug].bugs += 1
66+
stats[slug].bugs.append(ri)
5367
if "enhancement" in labels and milestone != "unscheduled":
54-
stats[slug].enhancements += 1
68+
stats[slug].enhancements.append(ri)
5569
return dict(stats)

src/pombast/team/_html.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import json
56
from typing import TYPE_CHECKING, Iterable
67

78
from jinja2 import Environment, PackageLoader, select_autoescape
@@ -22,6 +23,15 @@ def _dev_link(row: DeveloperRow) -> str:
2223
return f'<a href="{url}">{label}</a>'
2324

2425

26+
def _item_dict(item) -> dict:
27+
return {
28+
"url": item.url,
29+
"title": item.title,
30+
"number": item.number,
31+
"repo": item.repo,
32+
}
33+
34+
2535
def generate_team_html(
2636
rows: Iterable[DeveloperRow],
2737
*,
@@ -30,10 +40,22 @@ def generate_team_html(
3040
) -> str:
3141
"""Return a complete HTML page with the team accountability table."""
3242

43+
popup_data: dict[str, dict] = {}
44+
3345
def _row_data(r: DeveloperRow) -> dict:
46+
dev_id = r.developer.id
47+
popup_data[dev_id] = {
48+
"reviewer_prs": [_item_dict(i) for i in r.reviewer_pr_items],
49+
"support_issues": [_item_dict(i) for i in r.support_issue_items],
50+
"debugger_bugs": [_item_dict(i) for i in r.debugger_bug_items],
51+
"developer_features": [_item_dict(i) for i in r.developer_feature_items],
52+
"maintainer_releases": [
53+
{"ga": i.ga, "url": i.url} for i in r.maintainer_release_items
54+
],
55+
}
3456
return {
3557
"dev_link": _dev_link(r),
36-
"dev_id": r.developer.id,
58+
"dev_id": dev_id,
3759
"reviewer_prs": r.reviewer_prs,
3860
"support_issues": r.support_issues,
3961
"debugger_bugs": r.debugger_bugs,
@@ -44,9 +66,11 @@ def _row_data(r: DeveloperRow) -> dict:
4466
"components": sorted(r.components),
4567
}
4668

69+
row_list = [_row_data(r) for r in rows]
4770
template = _env.get_template("team.html.j2")
4871
return template.render(
4972
title=title,
5073
generated=generated,
51-
rows=[_row_data(r) for r in rows],
74+
rows=row_list,
75+
popup_data_json=json.dumps(popup_data) if popup_data else None,
5276
)

src/pombast/team/_workload.py

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
if TYPE_CHECKING:
99
from pombast.status._entry import StatusEntry
10-
from pombast.team._github import RepoStats
10+
from pombast.team._github import RepoItem, RepoStats
1111
from pombast.team._pom_devs import Developer
1212

1313
# Roles that represent ongoing maintenance responsibility.
@@ -22,18 +22,63 @@
2222
}
2323

2424

25+
@dataclass
26+
class ReleaseItem:
27+
ga: str # e.g. "net.imagej:imagej-common"
28+
url: str # project URL (GitHub repo page)
29+
30+
2531
@dataclass
2632
class DeveloperRow:
2733
developer: Developer
28-
reviewer_prs: int = 0 # open PRs to review (reviewer or lead)
29-
support_issues: int = 0 # open issues to answer (support or lead)
30-
debugger_bugs: int = 0 # open bug reports (debugger or lead)
31-
developer_features: int = 0 # open enhancement requests (developer or lead)
32-
maintainer_releases: int = (
33-
0 # components with unreleased changes (maintainer or lead)
34-
)
34+
# Dicts keyed by URL for deduplication across components sharing a repo.
35+
_prs: dict[str, RepoItem] = field(default_factory=dict)
36+
_issues: dict[str, RepoItem] = field(default_factory=dict)
37+
_bugs: dict[str, RepoItem] = field(default_factory=dict)
38+
_features: dict[str, RepoItem] = field(default_factory=dict)
39+
_releases: dict[str, ReleaseItem] = field(default_factory=dict) # ga → item
3540
components: list[str] = field(default_factory=list)
3641

42+
@property
43+
def reviewer_prs(self) -> int:
44+
return len(self._prs)
45+
46+
@property
47+
def reviewer_pr_items(self) -> list[RepoItem]:
48+
return list(self._prs.values())
49+
50+
@property
51+
def support_issues(self) -> int:
52+
return len(self._issues)
53+
54+
@property
55+
def support_issue_items(self) -> list[RepoItem]:
56+
return list(self._issues.values())
57+
58+
@property
59+
def debugger_bugs(self) -> int:
60+
return len(self._bugs)
61+
62+
@property
63+
def debugger_bug_items(self) -> list[RepoItem]:
64+
return list(self._bugs.values())
65+
66+
@property
67+
def developer_features(self) -> int:
68+
return len(self._features)
69+
70+
@property
71+
def developer_feature_items(self) -> list[RepoItem]:
72+
return list(self._features.values())
73+
74+
@property
75+
def maintainer_releases(self) -> int:
76+
return len(self._releases)
77+
78+
@property
79+
def maintainer_release_items(self) -> list[ReleaseItem]:
80+
return list(self._releases.values())
81+
3782
@property
3883
def total(self) -> int:
3984
return (
@@ -85,15 +130,19 @@ def build_workloads(
85130

86131
if stats:
87132
if is_lead or "reviewer" in roles:
88-
row.reviewer_prs += stats.prs
133+
for item in stats.prs:
134+
row._prs.setdefault(item.url, item)
89135
if is_lead or "support" in roles:
90-
row.support_issues += stats.issues
136+
for item in stats.issues:
137+
row._issues.setdefault(item.url, item)
91138
if is_lead or "debugger" in roles:
92-
row.debugger_bugs += stats.bugs
139+
for item in stats.bugs:
140+
row._bugs.setdefault(item.url, item)
93141
if is_lead or "developer" in roles:
94-
row.developer_features += stats.enhancements
142+
for item in stats.enhancements:
143+
row._features.setdefault(item.url, item)
95144

96145
if needs_release and (is_lead or "maintainer" in roles):
97-
row.maintainer_releases += 1
146+
row._releases.setdefault(ga, ReleaseItem(ga=ga, url=url))
98147

99148
return sorted(rows.values(), key=lambda r: r.total, reverse=True)

src/pombast/team/templates/team.html.j2

Lines changed: 91 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,24 @@ td.nonzero { color: firebrick; font-weight: bold; }
1111
td.zero { color: #999; }
1212
td.total-cell { font-weight: bold; border-left: 1px solid gray; }
1313
th.role-header { text-align: center; }
14+
{%- if popup_data_json %}
15+
td.has-popup { cursor: pointer; text-decoration: underline dotted; text-underline-offset: 2px; }
16+
td.has-popup:hover { filter: brightness(1.3); }
17+
#team-modal { display: none; position: fixed; inset: 0; z-index: 9999; align-items: center; justify-content: center; }
18+
#team-modal.open { display: flex; }
19+
#team-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.5); }
20+
#team-box { position: relative; z-index: 1; background: #fff; border-radius: 6px; max-width: 700px; width: 90vw; max-height: 80vh; display: flex; flex-direction: column; box-shadow: 0 8px 32px rgba(0,0,0,.3); overflow: hidden; }
21+
#team-header { display: flex; align-items: center; gap: .5em; padding: .6em 1em; background: #f5f5f5; border-bottom: 1px solid #ddd; }
22+
#team-title { flex: 1; font-weight: bold; font-size: .95em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
23+
#team-close { background: none; border: none; color: #888; font-size: 1.4em; line-height: 1; cursor: pointer; padding: 0 .15em; flex-shrink: 0; }
24+
#team-close:hover { color: #333; }
25+
#team-list { margin: 0; padding: .75em 1em; overflow-y: auto; flex: 1; list-style: none; }
26+
#team-list li { padding: .3em 0; border-bottom: 1px solid #f0f0f0; font-size: .9em; }
27+
#team-list li:last-child { border-bottom: none; }
28+
#team-list li a { color: #0366d6; text-decoration: none; }
29+
#team-list li a:hover { text-decoration: underline; }
30+
.popup-repo { color: #888; font-size: .85em; margin-left: .4em; }
31+
{%- endif %}
1432
</style>
1533
</head>
1634
<body>
@@ -32,13 +50,13 @@ th.role-header { text-align: center; }
3250
<th class="role-header" title="Number of BOM components this developer is involved with (hover for list)">Components</th>
3351
</tr>
3452
{%- for row in rows %}
35-
<tr>
53+
<tr data-dev="{{ row.dev_id }}">
3654
<td>{{ row.dev_link | safe }}</td>
37-
<td class="{{ 'nonzero' if row.reviewer_prs > 0 else 'zero' }}">{{ row.reviewer_prs }}</td>
38-
<td class="{{ 'nonzero' if row.support_issues > 0 else 'zero' }}">{{ row.support_issues }}</td>
39-
<td class="{{ 'nonzero' if row.debugger_bugs > 0 else 'zero' }}">{{ row.debugger_bugs }}</td>
40-
<td class="{{ 'nonzero' if row.developer_features > 0 else 'zero' }}">{{ row.developer_features }}</td>
41-
<td class="{{ 'nonzero' if row.maintainer_releases > 0 else 'zero' }}">{{ row.maintainer_releases }}</td>
55+
<td class="{{ 'nonzero has-popup' if row.reviewer_prs > 0 else 'zero' }}" data-col="reviewer_prs">{{ row.reviewer_prs }}</td>
56+
<td class="{{ 'nonzero has-popup' if row.support_issues > 0 else 'zero' }}" data-col="support_issues">{{ row.support_issues }}</td>
57+
<td class="{{ 'nonzero has-popup' if row.debugger_bugs > 0 else 'zero' }}" data-col="debugger_bugs">{{ row.debugger_bugs }}</td>
58+
<td class="{{ 'nonzero has-popup' if row.developer_features > 0 else 'zero' }}" data-col="developer_features">{{ row.developer_features }}</td>
59+
<td class="{{ 'nonzero has-popup' if row.maintainer_releases > 0 else 'zero' }}" data-col="maintainer_releases">{{ row.maintainer_releases }}</td>
4260
<td class="total-cell">{{ row.total }}</td>
4361
<td title="{{ row.components | join(', ') }}">{{ row.component_count }}</td>
4462
</tr>
@@ -48,6 +66,73 @@ th.role-header { text-align: center; }
4866
<b>Note:</b> Lead developers are counted in all role columns for each of their components.
4967
GitHub data excludes draft PRs and issues labeled &ldquo;question&rdquo;.
5068
Enhancement counts exclude issues with milestone &ldquo;unscheduled&rdquo;.
69+
Click any nonzero cell to see the contributing items.
5170
</p>
71+
{%- if popup_data_json %}
72+
<div id="team-modal">
73+
<div id="team-backdrop"></div>
74+
<div id="team-box">
75+
<div id="team-header">
76+
<span id="team-title"></span>
77+
<button id="team-close">&times;</button>
78+
</div>
79+
<ul id="team-list"></ul>
80+
</div>
81+
</div>
82+
<script>
83+
(function(){
84+
var modal = document.getElementById('team-modal');
85+
var titleEl = document.getElementById('team-title');
86+
var list = document.getElementById('team-list');
87+
var DATA = {{ popup_data_json | safe }};
88+
89+
var COL_LABELS = {
90+
reviewer_prs: 'PRs to review',
91+
support_issues: 'Issues to answer',
92+
debugger_bugs: 'Bugs to fix',
93+
developer_features: 'Features requested',
94+
maintainer_releases: 'Releases needed'
95+
};
96+
97+
function show(devId, col, devName) {
98+
var items = DATA[devId] && DATA[devId][col];
99+
if (!items || !items.length) return;
100+
titleEl.textContent = devName + ' — ' + COL_LABELS[col] + ' (' + items.length + ')';
101+
list.innerHTML = '';
102+
items.forEach(function(item) {
103+
var li = document.createElement('li');
104+
if (col === 'maintainer_releases') {
105+
var repoLabel = item.url ? item.url.replace('https://github.com/', '') : item.ga;
106+
li.innerHTML = item.url
107+
? '<a href="' + item.url + '/commits" target="_blank">' + item.ga + '</a>'
108+
: item.ga;
109+
} else {
110+
li.innerHTML = '<a href="' + item.url + '" target="_blank">#' + item.number + '' + escHtml(item.title) + '</a>'
111+
+ '<span class="popup-repo">' + escHtml(item.repo) + '</span>';
112+
}
113+
list.appendChild(li);
114+
});
115+
modal.classList.add('open');
116+
}
117+
function hide() { modal.classList.remove('open'); }
118+
function escHtml(s) {
119+
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
120+
}
121+
122+
document.querySelectorAll('td.has-popup').forEach(function(td) {
123+
td.addEventListener('click', function() {
124+
var tr = td.closest('tr');
125+
var devId = tr.dataset.dev;
126+
var col = td.dataset.col;
127+
var devName = tr.querySelector('td:first-child').textContent.trim();
128+
show(devId, col, devName);
129+
});
130+
});
131+
document.getElementById('team-close').addEventListener('click', hide);
132+
document.getElementById('team-backdrop').addEventListener('click', hide);
133+
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') hide(); });
134+
})();
135+
</script>
136+
{%- endif %}
52137
</body>
53138
</html>

0 commit comments

Comments
 (0)