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,
         })