You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@community.apache.org by rb...@apache.org on 2026/09/25 20:27:33 UTC
(comdev-metrics) 01/01: Add per-month participant counts to mailing list metrics
This is an automated email from the ASF dual-hosted git repository.
rbowen pushed a commit to branch rbowen-participants
in repository https://gitbox.apache.org/repos/asf/comdev-metrics.git
commit 31cd13407066ec6801906cca7a6f246e77681e2f
Author: Rich Bowen <rb...@rcbowen.com>
AuthorDate: Fri Sep 25 16:26:53 2026 -0400
Add per-month participant counts to mailing list metrics
Show distinct participants alongside message volume in the Monthly
Messages view: a participants column in the list table and a
participant line overlaid on the chart.
- Parse authors from stats.json emails[], normalizing "(via GitHub)"
relays to the underlying username
- Hybrid counting: client-side from emails[] for low-volume months,
per-month numparts API calls for high-volume months where emails[]
is capped
- Fetch one month wider than the reporting window so the chart's
leftmost month has participant data
- Fix chart tooltip z-index so it renders above all bars
- Add per-project timing and API-call instrumentation to the CLI
---
site/project.html | 69 +++++++-
src/asfmetrics/cli.py | 22 ++-
src/asfmetrics/collectors/cache.py | 25 +++
src/asfmetrics/collectors/mailing_lists.py | 255 ++++++++++++++++++++++++++++-
4 files changed, 359 insertions(+), 12 deletions(-)
diff --git a/site/project.html b/site/project.html
index bef1d1a..c1ac953 100644
--- a/site/project.html
+++ b/site/project.html
@@ -62,8 +62,9 @@
.chart-bar {
flex: 1; background: #d45500; border-radius: 3px 3px 0 0;
min-height: 2px; position: relative;
+ z-index: 1;
}
- .chart-bar:hover { background: #ff6600; }
+ .chart-bar:hover { background: #ff6600; z-index: 10; }
.chart-bar .tooltip {
display: none; position: absolute; bottom: 100%; left: 50%;
transform: translateX(-50%); background: #333; color: white;
@@ -115,7 +116,7 @@
<div class="section">
<h2>Mailing List Activity (12 Months)</h2>
<table id="lists-table">
- <thead><tr><th>List</th><th>Messages</th><th>Monthly Trend</th></tr></thead>
+ <thead><tr><th>List</th><th>Messages</th><th title="Distinct senders over the last 12 months (GitHub relays keyed by username; see docs)">Participants</th><th>Monthly Trend</th></tr></thead>
<tbody></tbody>
</table>
</div>
@@ -237,6 +238,7 @@
row.innerHTML = `
<td>${list.list_name}@${ml.domain} <a href="https://lists.apache.org/list.html?${list.list_name}@${ml.domain}" target="_blank" title="View archive on lists.apache.org" style="margin-left:0.4rem">✉️</a></td>
<td>${quarterTotal}</td>
+ <td>${list.participants || 0}</td>
<td><div class="sparkline">${sparkHtml}</div></td>
`;
row.style.cursor = 'pointer';
@@ -255,7 +257,7 @@
document.getElementById('stat-messages').textContent = '—';
document.getElementById('stat-lists').textContent = '0';
document.querySelector('#lists-table tbody').innerHTML =
- '<tr><td colspan="3" style="color:#999">No mailing list data collected yet. Run: uv run asfmetrics --project ' + id + '</td></tr>';
+ '<tr><td colspan="4" style="color:#999">No mailing list data collected yet. Run: uv run asfmetrics --project ' + id + '</td></tr>';
}
// --- Committee / roster ---
@@ -448,6 +450,7 @@
`${list.list_name}@${domain} — Monthly Messages`;
const months = list.active_months || {};
+ const partsByMonth = list.participants_by_month || {};
// Generate ALL months in the 12-month window (fill gaps with 0)
const allMonths = [];
@@ -460,6 +463,11 @@
cursor.setMonth(cursor.getMonth() + 1);
}
+ // Parallel participant series (distinct senders per month)
+ const partsSeries = allMonths.map(([m]) => partsByMonth[m] || 0);
+ const maxParts = Math.max(...partsSeries, 1);
+ const hasParts = partsSeries.some(v => v > 0);
+
const maxCount = Math.max(...allMonths.map(([_,c]) => c), 1);
const chart = document.getElementById('chart');
const labels = document.getElementById('chart-labels');
@@ -467,14 +475,17 @@
labels.innerHTML = '';
const existingTrend = document.getElementById('chart-wrapper').querySelector('.trend-overlay');
if (existingTrend) existingTrend.remove();
+ const existingParts = document.getElementById('chart-wrapper').querySelector('.participants-overlay');
+ if (existingParts) existingParts.remove();
- allMonths.forEach(([month, count]) => {
+ allMonths.forEach(([month, count], idx) => {
const h = count > 0 ? Math.max(2, (count / maxCount) * 180) : 0;
const bar = document.createElement('div');
bar.className = 'chart-bar';
bar.style.height = `${h}px`;
if (count === 0) { bar.style.minHeight = '0'; bar.style.background = '#eee'; }
- bar.innerHTML = `<div class="tooltip">${month}: ${count}</div>`;
+ const p = partsSeries[idx];
+ bar.innerHTML = `<div class="tooltip">${month}: ${count} msgs · ${p} participant${p === 1 ? '' : 's'}</div>`;
chart.appendChild(bar);
const lbl = document.createElement('span');
@@ -513,13 +524,59 @@
const y2 = yForVal(slope * (n - 1) + intercept);
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
- svg.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;';
+ svg.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:2;';
svg.setAttribute('class', 'trend-overlay');
svg.setAttribute('viewBox', `0 0 ${n} 200`);
svg.setAttribute('preserveAspectRatio', 'none');
svg.innerHTML = `<line class="trend-line" x1="0.5" y1="${y1}" x2="${n-0.5}" y2="${y2}" vector-effect="non-scaling-stroke"/>`;
document.getElementById('chart-wrapper').appendChild(svg);
}
+
+ // Overlay participant count as a line + dots (distinct senders/month),
+ // scaled to its own axis so it reads regardless of message volume.
+ if (hasParts && allMonths.length >= 1) {
+ const n = allMonths.length;
+ // Map participant value to pixel Y (same 4..184 band as bars/trend).
+ const yForParts = (v) => 184 - (v / maxParts) * 180;
+ const stepX = n > 1 ? (n - 1) : 1;
+ const pts = partsSeries.map((v, i) => {
+ const x = n > 1 ? (i / stepX) * (n - 1) + 0.5 : n / 2;
+ return `${x.toFixed(3)},${yForParts(v).toFixed(2)}`;
+ });
+ const dots = partsSeries.map((v, i) => {
+ const x = n > 1 ? (i / stepX) * (n - 1) + 0.5 : n / 2;
+ return `<circle cx="${x.toFixed(3)}" cy="${yForParts(v).toFixed(2)}" r="0.12" fill="#1a73e8" vector-effect="non-scaling-stroke"/>`;
+ }).join('');
+ const svgP = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ svgP.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:2;';
+ svgP.setAttribute('class', 'participants-overlay');
+ svgP.setAttribute('viewBox', `0 0 ${n} 200`);
+ svgP.setAttribute('preserveAspectRatio', 'none');
+ svgP.innerHTML =
+ `<polyline points="${pts.join(' ')}" fill="none" stroke="#1a73e8" stroke-width="2" vector-effect="non-scaling-stroke" opacity="0.85"/>` + dots;
+ document.getElementById('chart-wrapper').appendChild(svgP);
+ }
+
+ // Legend + peak-participant summary under the title.
+ const titleEl = document.getElementById('chart-title');
+ let legend = document.getElementById('parts-legend');
+ if (!legend) {
+ legend = document.createElement('div');
+ legend.id = 'parts-legend';
+ legend.style.cssText = 'font-size:0.8rem;color:#666;margin:-0.5rem 0 0.5rem 0';
+ titleEl.insertAdjacentElement('afterend', legend);
+ }
+ if (hasParts) {
+ const peak = Math.max(...partsSeries);
+ const total12 = list.participants || 0;
+ legend.style.display = 'block';
+ legend.innerHTML =
+ `<span style="display:inline-block;width:20px;height:0;border-top:2px solid #1a73e8;vertical-align:middle"></span> ` +
+ `Distinct participants/month (peak ${peak}` +
+ (total12 ? ` · ${total12} distinct over 12 mo` : '') + `)`;
+ } else {
+ legend.style.display = 'none';
+ }
}
function showGitChart(repo, org, vcs, cutoffStr) {
diff --git a/src/asfmetrics/cli.py b/src/asfmetrics/cli.py
index f87a999..40c5302 100644
--- a/src/asfmetrics/cli.py
+++ b/src/asfmetrics/cli.py
@@ -3,6 +3,7 @@
import json
import argparse
import sys
+import time
from pathlib import Path
from asfmetrics.config import (
@@ -11,6 +12,8 @@ from asfmetrics.config import (
from asfmetrics.collectors.mailing_lists import (
collect_mailing_list_stats,
invalidate_cache,
+ reset_call_counter,
+ get_call_counter,
)
from asfmetrics.collectors.git_activity import (
collect_git_activity,
@@ -118,19 +121,34 @@ def main():
total = len(projects)
collected = 0
skipped = 0
+ ml_t0 = time.time()
for i, project in enumerate(projects, 1):
status(f"[{i}/{total}] {project}: fetching mailing lists...")
+ reset_call_counter()
+ p_t0 = time.time()
stats = collect_mailing_list_stats(project, config, mail_domain_map)
+ p_dt = time.time() - p_t0
+ month_calls = get_call_counter()
if stats:
active_count = len(stats["active_lists"])
total_msgs = sum(l["messages"] for l in stats["active_lists"])
write_json(project, stats, config)
collected += 1
- status(f"[{i}/{total}] {project}: {active_count} active lists, {total_msgs} messages")
+ # Use status_done so the timing/call-count line PERSISTS
+ # (status() uses \r and would be overwritten by the next
+ # project, hiding the very numbers we're measuring).
+ status_done(
+ f"[{i}/{total}] {project}: {active_count} active lists, "
+ f"{total_msgs} messages "
+ f"[{p_dt:.1f}s, {month_calls} per-month participant calls]"
+ )
else:
skipped += 1
- status(f"[{i}/{total}] {project}: no active lists")
+ status_done(f"[{i}/{total}] {project}: no active lists [{p_dt:.1f}s]")
+
+ ml_dt = time.time() - ml_t0
+ status(f" mailing lists total time: {ml_dt:.1f}s")
status_done(f"mailing lists: {collected} projects collected, {skipped} skipped (no activity)")
diff --git a/src/asfmetrics/collectors/cache.py b/src/asfmetrics/collectors/cache.py
index 3f998f1..666388b 100644
--- a/src/asfmetrics/collectors/cache.py
+++ b/src/asfmetrics/collectors/cache.py
@@ -36,6 +36,31 @@ def twelve_months_ago_str() -> str:
return f"{dt.year}-{dt.month:02d}"
+def participant_window_start_str() -> str:
+ """Return the earliest YYYY-MM the participant series must cover.
+
+ The frontend chart computes its left edge with JS
+ ``new Date(now); d.setMonth(d.getMonth() - 12)`` which, because JS
+ months are 0-based and the day can roll, lands ONE month earlier than
+ Python's ``now.replace(year=year-1)``. If the collector only emits
+ participants from ``twelve_months_ago_str()`` onward, the chart's
+ leftmost bar has no participant key and renders as 0 every month, on
+ every list (observed Sep 2026).
+
+ To guarantee coverage of the chart's leftmost month, the participant
+ window starts one month BEFORE the 12-month cutoff (i.e. 13 months
+ back). Extra months beyond what the chart shows are harmless — the
+ frontend only reads the keys it renders.
+ """
+ dt = twelve_months_ago()
+ # step back one more month (handle January -> previous December)
+ year, month = dt.year, dt.month - 1
+ if month < 1:
+ month = 12
+ year -= 1
+ return f"{year}-{month:02d}"
+
+
# --- Cache directory ---
def collector_cache_dir(config: dict, collector_name: str) -> Path:
diff --git a/src/asfmetrics/collectors/mailing_lists.py b/src/asfmetrics/collectors/mailing_lists.py
index 09b0274..8653268 100644
--- a/src/asfmetrics/collectors/mailing_lists.py
+++ b/src/asfmetrics/collectors/mailing_lists.py
@@ -17,6 +17,8 @@ See: incubator-ponymail-foal/docs/API.md
"""
from datetime import datetime
+import time
+import re
import httpx
from asfmetrics.collectors import cache as CACHE
@@ -32,6 +34,198 @@ ACTIVITY_THRESHOLD = "12M" # Full year — trends need context beyond a single
_COLLECTOR_NAME = "mailing_lists"
+def _widen_timespan_one_month(timespan: str) -> str:
+ """Return a Pony Mail timespan one month wider than ``timespan``.
+
+ e.g. "12M" -> "13M". Used so the fetched ``emails[]`` array reaches
+ back far enough to cover the participant window's boundary month (which
+ starts one month before the 12-month reporting cutoff). Without this the
+ boundary month's messages exist in ``active_months`` (full history) but
+ have no ``emails[]`` entries, so the client-side participant count for
+ that month is 0 on low-volume lists. Falls back to the input unchanged
+ if it is not the expected "<n>M" form.
+ """
+ m = re.fullmatch(r"(\d+)M", timespan.strip())
+ if not m:
+ return timespan
+ return f"{int(m.group(1)) + 1}M"
+
+# Above this many messages in a single month, the stats.json ``emails[]``
+# array is capped by the server and does NOT contain every message — so a
+# client-side distinct-sender count computed from it would undercount (and
+# can miss whole months entirely). For any such month we instead make one
+# lightweight per-month call and trust the server's ``numparts`` aggregate.
+# Months at or below this count return a complete ``emails[]`` and are
+# counted client-side with GitHub-identity normalization (cheaper: no extra
+# call, and honours the (via GitHub) dedupe). Tune if the observed cap moves.
+EMAILS_CAP_THRESHOLD = 500
+
+
+# Matches Pony Mail's "(via GitHub)" relay wrapper, e.g.
+# "rbowen (via GitHub)" <gi...@apache.org>
+# The underlying human is the leading token before the wrapper.
+_VIA_GITHUB_RE = re.compile(r"\s*\(via GitHub\)\s*", re.IGNORECASE)
+
+
+def _normalize_sender(raw_from: str) -> str | None:
+ """Reduce a raw ``emails[].from`` value to a stable participant key.
+
+ - Strips display-name quoting and the surrounding ``<addr>``.
+ - Collapses ``"name (via GitHub)"`` relayed senders to the underlying
+ username, so ``rbowen (via GitHub)`` and ``rbowen`` count as one
+ participant (GitHub relays ARE real people acting through GitHub).
+ - Prefers the email address when present (most stable identity);
+ otherwise falls back to the cleaned display name.
+
+ Returns a lowercased key, or None if nothing usable is found.
+
+ Known limitation (accepted, Sep 2026): the same human who posts both
+ via a GitHub relay (keyed on username) AND via direct email (keyed on
+ address) counts as two distinct participants, because there is no
+ reliable username->email mapping in the archive payload. Building such
+ a map across 10k+ participants over 300+ projects is not feasible, so
+ we accept modest over-counting. On GitHub-heavy lists the same person
+ rarely does both in a month, so the effect is small.
+ """
+ if not raw_from:
+ return None
+ s = raw_from.strip()
+
+ # Split display name from <address> if present.
+ addr = None
+ name = s
+ m = re.match(r"^(.*?)<([^>]+)>\s*$", s)
+ if m:
+ name = m.group(1).strip()
+ addr = m.group(2).strip().lower()
+
+ name = name.strip().strip('"').strip()
+ is_via_github = bool(_VIA_GITHUB_RE.search(name))
+ name = _VIA_GITHUB_RE.sub("", name).strip().strip('"').strip()
+
+ # For GitHub relays the address is a shared bot address (e.g.
+ # git@apache.org / *@github.com), so the username in the display name
+ # is the real identity — key on that.
+ if is_via_github and name:
+ return name.lower()
+
+ if addr:
+ return addr
+ return name.lower() or None
+
+
+def _participants_by_month(emails: list) -> dict[str, int]:
+ """Count distinct normalized senders per calendar month.
+
+ Args:
+ emails: The ``emails[]`` array from a full (non-quick) stats.json
+ response. Each entry has ``from`` and ``epoch``.
+
+ Returns:
+ Dict of {YYYY-MM: distinct_sender_count}.
+ """
+ buckets: dict[str, set] = {}
+ for e in emails or []:
+ epoch = e.get("epoch")
+ if epoch is None:
+ continue
+ dt = datetime.utcfromtimestamp(epoch)
+ month_key = f"{dt.year}-{dt.month:02d}"
+ key = _normalize_sender(e.get("from", ""))
+ if key is None:
+ continue
+ buckets.setdefault(month_key, set()).add(key)
+ return {m: len(s) for m, s in buckets.items()}
+
+
+# Module-level counter so the CLI can report how many per-month API calls
+# the hybrid strategy actually made on a run (timing/observability).
+_MONTH_CALLS = 0
+
+
+def reset_call_counter() -> None:
+ """Reset the per-month API call counter (call once at run start)."""
+ global _MONTH_CALLS
+ _MONTH_CALLS = 0
+
+
+def get_call_counter() -> int:
+ """Return the number of per-month numparts calls made so far."""
+ return _MONTH_CALLS
+
+
+def _fetch_month_numparts(
+ list_name: str,
+ domain: str,
+ month_key: str,
+ base_url: str = PONYMAIL_API,
+) -> int | None:
+ """Fetch the server-side distinct-participant count for ONE month.
+
+ Makes a single ``stats.json`` call scoped to ``month_key`` (YYYY-MM)
+ and returns its ``numparts``. Used for high-volume months where the
+ ``emails[]`` array is capped and a client-side count is unreliable.
+
+ The response for a single month is small regardless of the list's
+ overall volume, so this is a lightweight call.
+
+ Returns the month's numparts, or None on failure.
+ """
+ global _MONTH_CALLS
+ url = f"{base_url}stats.json"
+ payload = {"list": list_name, "domain": domain, "d": month_key}
+ try:
+ _MONTH_CALLS += 1
+ resp = httpx.post(url, json=payload, timeout=30)
+ resp.raise_for_status()
+ data = resp.json()
+ except (httpx.HTTPError, ValueError):
+ return None
+ return data.get("numparts", 0)
+
+
+def _participants_by_month_hybrid(
+ list_name: str,
+ domain: str,
+ active_months: dict[str, int],
+ emails: list,
+ base_url: str = PONYMAIL_API,
+ only_months: set[str] | None = None,
+) -> dict[str, int]:
+ """Compute distinct participants per month using the hybrid strategy.
+
+ - For months whose message count is at or below EMAILS_CAP_THRESHOLD,
+ the ``emails[]`` array is complete: count normalized distinct senders
+ client-side (cheap, honours (via GitHub) dedupe).
+ - For high-volume months (count > threshold), ``emails[]`` is capped,
+ so make one per-month ``numparts`` call each and trust the server.
+
+ Args:
+ active_months: {YYYY-MM: message_count} for this list (the window).
+ emails: the ``emails[]`` array from the same response.
+ only_months: if given, restrict computation to these month keys
+ (used by incremental refresh to only touch the current window).
+
+ Returns:
+ {YYYY-MM: distinct_participant_count}.
+ """
+ # Base client-side counts from the (possibly capped) emails[] array.
+ client_counts = _participants_by_month(emails)
+
+ result: dict[str, int] = {}
+ for month_key, msg_count in active_months.items():
+ if only_months is not None and month_key not in only_months:
+ continue
+ if msg_count > EMAILS_CAP_THRESHOLD:
+ # emails[] unreliable for this month → authoritative per-month call
+ np = _fetch_month_numparts(list_name, domain, month_key, base_url)
+ result[month_key] = np if np is not None else client_counts.get(month_key, 0)
+ else:
+ # Small month → emails[] is complete; client-side count is exact
+ result[month_key] = client_counts.get(month_key, 0)
+ return result
+
+
def invalidate_cache(config: dict) -> None:
"""Remove all mailing list cache files."""
@@ -66,7 +260,7 @@ def collect_list_stats(
domain: str,
base_url: str = PONYMAIL_API,
timespan: str = ACTIVITY_THRESHOLD,
- quick: bool = True,
+ quick: bool = False,
) -> dict | None:
"""Fetch stats for a single mailing list.
@@ -77,7 +271,10 @@ def collect_list_stats(
domain: List domain (e.g. "httpd.apache.org").
base_url: Pony Mail API base URL.
timespan: Date filter in Pony Mail format (e.g. "6M", "3M").
- quick: If True, return stats only (no email bodies).
+ quick: If True, return summary stats only. Must be False (the
+ default) to receive the per-message ``emails[]`` array that
+ participant-per-month counting relies on. quick=True sets
+ numparts=0 and omits emails[] entirely.
Returns:
Stats dict with hits, numparts, no_threads, etc. or None on failure.
@@ -117,7 +314,7 @@ def _fetch_current_month_stats(
final count may have increased since then. Two months guarantees
we pick up the tail end of the previous month.
"""
- return collect_list_stats(list_name, domain, base_url, timespan="2M", quick=True)
+ return collect_list_stats(list_name, domain, base_url, timespan="2M", quick=False)
def collect_mailing_list_stats(
@@ -186,6 +383,21 @@ def collect_mailing_list_stats(
# (current month + previous month)
cached_months[month_key] = count
list_data["active_months"] = cached_months
+ # Recompute participants for the refreshed months and merge.
+ # Past months are immutable; only the 2M window is overwritten.
+ # Use the hybrid strategy scoped to just the months this 2M
+ # window returned, so a high-volume current month still gets
+ # an authoritative per-month numparts call.
+ refreshed = set(months.keys())
+ fresh_parts = _participants_by_month_hybrid(
+ list_name, domain, cached_months,
+ stats.get("emails", []), base_url,
+ only_months=refreshed,
+ )
+ cached_parts = list_data.get("participants_by_month", {})
+ for month_key, pcount in fresh_parts.items():
+ cached_parts[month_key] = pcount
+ list_data["participants_by_month"] = cached_parts
# Update totals from the full month range
list_data["messages"] = sum(
v for k, v in cached_months.items()
@@ -214,11 +426,30 @@ def collect_mailing_list_stats(
cache_lists = {}
for list_name in project_lists:
- stats = collect_list_stats(list_name, domain, base_url, timespan)
+ # Fetch one month wider than the reporting window so the emails[]
+ # array covers the participant boundary month (see
+ # _widen_timespan_one_month). active_months is full history either
+ # way; only the emails[] coverage matters here.
+ fetch_span = _widen_timespan_one_month(timespan)
+ stats = collect_list_stats(list_name, domain, base_url, fetch_span)
if stats:
list_id = f"{list_name}@{domain}"
active_months = stats.get("active_months", {})
+ # Only compute participants for the reporting window; older
+ # months are never shown and must not trigger per-month calls.
+ # Use the participant-window start (one month earlier than the
+ # 12-month cutoff) so the chart's leftmost bar always has a
+ # participant value — see participant_window_start_str().
+ cutoff = CACHE.participant_window_start_str()
+ window_months = {
+ k: v for k, v in active_months.items() if k >= cutoff
+ }
+ pbm = _participants_by_month_hybrid(
+ list_name, domain, window_months,
+ stats.get("emails", []), base_url,
+ )
+
list_entry = {
"list_name": list_name,
"list_id": list_id,
@@ -226,6 +457,7 @@ def collect_mailing_list_stats(
"participants": stats.get("numparts", 0),
"threads": stats.get("no_threads", 0),
"active_months": active_months,
+ "participants_by_month": pbm,
}
active_lists.append(list_entry)
cache_lists[list_id] = list_entry
@@ -273,11 +505,26 @@ def _build_result_from_cache(
if not recent_months:
continue
+ all_parts = list_data.get("participants_by_month", {})
+ # Participants use a one-month-wider window than messages so the
+ # chart's leftmost bar always has a value (see
+ # participant_window_start_str()).
+ parts_cutoff = CACHE.participant_window_start_str()
+ recent_parts = {
+ k: v for k, v in all_parts.items() if k >= parts_cutoff
+ }
+
active_lists.append({
"list_name": list_data.get("list_name", list_id.split("@")[0]),
"list_id": list_id,
"messages": sum(recent_months.values()),
"participants": list_data.get("participants", 0),
+ "participants_by_month": recent_parts,
+ # Peak monthly participants across the window — a defensible
+ # single-number summary. (A true 12-month distinct union isn't
+ # derivable from per-month counts; numparts in "participants"
+ # is the API's window-wide distinct total.)
+ "participants_peak_month": max(recent_parts.values(), default=0),
"threads": list_data.get("threads", 0),
"active_months": recent_months,
})