blob: 6402adaa7ded9e9477f39ac5a89e23ff51338333 [file]
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ASF Community Metrics</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
background: #f8f9fa;
}
h1 { margin-bottom: 0.5rem; color: #1a1a2e; }
.subtitle { color: #666; margin-bottom: 2rem; }
.stats-bar {
display: flex;
gap: 1.5rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.stat-card {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
min-width: 160px;
flex: 1;
}
.stat-card .number {
font-size: 2rem;
font-weight: 700;
color: #d45500;
}
.stat-card .label { color: #666; font-size: 0.9rem; }
.section {
background: white;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.section h2 { margin-bottom: 1rem; color: #1a1a2e; }
table {
width: 100%;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: 0.5rem 1rem;
border-bottom: 1px solid #eee;
}
th { font-weight: 600; color: #666; font-size: 0.85rem; text-transform: uppercase; }
td a { color: #d45500; text-decoration: none; }
td a:hover { text-decoration: underline; }
.bar {
background: #fff3e0;
border-radius: 3px;
height: 20px;
position: relative;
}
.bar-fill {
background: #d45500;
border-radius: 3px;
height: 100%;
min-width: 2px;
}
.bar-label {
position: absolute;
right: 6px;
top: 1px;
font-size: 0.75rem;
color: #333;
}
#loading { color: #999; font-style: italic; padding: 2rem; text-align: center; }
.tab-bar { display: flex; gap: 0; margin-bottom: 0; }
.tab {
padding: 0.6rem 1.2rem;
cursor: pointer;
border: 1px solid #ddd;
border-bottom: none;
border-radius: 6px 6px 0 0;
background: #f0f0f0;
font-size: 0.9rem;
}
.tab.active { background: white; font-weight: 600; border-bottom: 1px solid white; margin-bottom: -1px; }
.tab-content { display: none; }
.tab-content.active { display: block; }
</style>
</head>
<body>
<h1>ASF Community Metrics</h1>
<p class="subtitle">Apache project metrics — <a href="about.html" style="color:#666;text-decoration:underline">about the data</a></p>
<div id="loading">Loading data...</div>
<div id="app" style="display:none">
<div class="stats-bar" id="stats-bar">
<div class="stat-card">
<div class="number" id="project-count"></div>
<div class="label">Active PMCs</div>
</div>
<div class="stat-card">
<div class="number" id="committer-count"></div>
<div class="label">Total Roster Entries</div>
</div>
<div class="stat-card">
<div class="number" id="new-committers-6mo"></div>
<div class="label">New Committers/PMC members (12 mo)</div>
</div>
<div class="stat-card">
<div class="number" id="releases-6mo"></div>
<div class="label">Releases (12 months)</div>
</div>
</div>
<div class="tab-bar">
<div class="tab" data-tab="growth">Community Growth</div>
<div class="tab" data-tab="releases">Recent Releases</div>
<div class="tab" data-tab="mailing">Mailing Lists</div>
<div class="tab active" data-tab="projects">All Projects</div>
<div class="tab" data-tab="health">Activity Trends</div>
</div>
<div class="section" style="border-radius: 0 8px 8px 8px;">
<div class="tab-content" id="tab-growth">
<h2>Community Growth (Last 12 Months)</h2>
<p style="color:#666;font-size:0.9rem;margin-bottom:1rem"><span style="display:inline-block;width:12px;height:12px;background:#1a73e8;border-radius:2px;vertical-align:middle"></span> New Committers &nbsp; <span style="display:inline-block;width:12px;height:12px;background:#d45500;border-radius:2px;vertical-align:middle"></span> New PMC members</p>
<table id="growth-table">
<thead><tr><th>Project</th><th>Committers</th><th>PMC</th><th></th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="tab-content" id="tab-releases">
<h2>Releases in Last 6 Months</h2>
<table id="releases-table">
<thead><tr><th>Project</th><th>Releases</th><th></th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="tab-content" id="tab-mailing">
<h2>Mailing List Activity (Last 12 Months)</h2>
<p style="color:#666;margin-bottom:1rem">Messages per month on dev@ lists (12-month window, with trend line)</p>
<table id="mailing-table">
<thead><tr><th>Project</th><th>Messages (12mo)</th><th>Trend</th></tr></thead>
<tbody></tbody>
</table>
<p id="mailing-note" style="color:#999;font-size:0.85rem;margin-top:1rem;display:none">
Run <code>uv run asfmetrics</code> (without --skip-mailing-lists) to collect per-project mailing list data.</p>
</div>
<div class="tab-content active" id="tab-projects">
<h2>Active Projects</h2>
<table id="projects-table">
<thead><tr><th>Project</th><th>Established</th><th>PMC Size</th><th>Chair</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="tab-content" id="tab-health">
<h2 id="health-title">Activity Trends</h2>
<p id="health-window" style="color:#666;font-size:0.9rem;margin-bottom:0.5rem"></p>
<p style="color:#666;font-size:0.85rem;margin-bottom:1.5rem" id="health-methodology"></p>
<h3 style="color:#555">⚫ Dormant <span id="dormant-count" style="font-weight:normal;color:#666"></span></h3>
<p style="color:#666;font-size:0.85rem;margin-bottom:0.5rem">Near-zero activity across all metrics.</p>
<table id="dormant-table">
<thead><tr><th>Project</th><th id="dormant-ml-header">Mailing List Msgs</th><th id="dormant-git-header">Git Commits</th></tr></thead>
<tbody></tbody>
</table>
<h3 style="color:#c0392b;margin-top:2rem">🔴 Sharp Decline <span id="at-risk-count" style="font-weight:normal;color:#666"></span></h3>
<p style="color:#666;font-size:0.85rem;margin-bottom:0.5rem">Both mailing list discussion and git commits down ≥ 30% quarter-over-quarter.</p>
<table id="at-risk-table">
<thead><tr><th>Project</th><th class="health-ml-header">Mailing List (Q1 vs Q2)</th><th class="health-git-header">Git Commits (Q1 vs Q2)</th></tr></thead>
<tbody></tbody>
</table>
<h3 style="color:#e67e22;margin-top:2rem">🟡 Declining <span id="declining-count" style="font-weight:normal;color:#666"></span></h3>
<p style="color:#666;font-size:0.85rem;margin-bottom:0.5rem">One or both metrics declining significantly, with meaningful prior activity.</p>
<table id="declining-table">
<thead><tr><th>Project</th><th class="health-ml-header">Mailing List (Q1 vs Q2)</th><th class="health-git-header">Git Commits (Q1 vs Q2)</th></tr></thead>
<tbody></tbody>
</table>
<div style="margin-top:2rem;padding:1rem;background:#f8f9fa;border-radius:6px;font-size:0.85rem;color:#555">
<strong>Methodology:</strong> <span id="health-method-detail"></span><br>
<strong>Thresholds:</strong> Sharp Decline = both axes ≤ <span id="threshold-decline"></span>%.
Declining = one axis ≤ <span id="threshold-decline2"></span>% (min <span id="threshold-prior"></span> prior activity).
Dormant = ≤ <span id="threshold-dormant-commits"></span> commits + ≤ <span id="threshold-dormant-msgs"></span> messages in recent quarter.
</div>
</div>
</div>
<p id="generated-date" style="color:#999;font-size:0.8rem;text-align:right;margin-top:0.5rem"></p>
</div><!-- #app -->
<script>
// Tab switching
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
});
});
async function loadData() {
try {
const [committeesResp, releasesResp, newCommResp] = await Promise.all([
fetch('data/_cache/committees.json'),
fetch('data/_cache/releases.json'),
fetch('data/_cache/new_committers.json'),
]);
if (!committeesResp.ok) {
document.getElementById('loading').textContent =
'No data found. Run: uv run asfmetrics --skip-mailing-lists';
return;
}
const committees = await committeesResp.json();
const releases = releasesResp.ok ? await releasesResp.json() : {};
const newCommData = newCommResp && newCommResp.ok ? await newCommResp.json() : {by_project: {}, total: 0};
document.getElementById('loading').style.display = 'none';
document.getElementById('app').style.display = 'block';
// Show data generation date from Last-Modified header
const lastMod = committeesResp.headers.get('Last-Modified');
if (lastMod) {
document.getElementById('generated-date').textContent = `Data generated: ${new Date(lastMod).toLocaleDateString('en-US', {weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'})}`;
}
const sixMonthsAgo = new Date();
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 12);
const sixMonthStr = sixMonthsAgo.toISOString().slice(0, 10);
const quarterStr = `${sixMonthsAgo.getFullYear()}-${String(sixMonthsAgo.getMonth()+1).padStart(2,'0')}`;
// Compute metrics
let totalRoster = 0;
let newIn6Mo = 0;
const growthByProject = {};
committees.forEach(pmc => {
const roster = pmc.roster || {};
const size = Object.keys(roster).length;
totalRoster += size;
let newCount = 0;
Object.values(roster).forEach(entry => {
if (entry.date && entry.date >= sixMonthStr) {
newCount++;
newIn6Mo++;
}
});
if (newCount > 0) {
growthByProject[pmc.id] = { count: newCount, id: pmc.id, name: pmc.name };
}
});
// Releases in last 6 months
let releasesIn6Mo = 0;
const releasesByProject = {};
Object.entries(releases).forEach(([project, rels]) => {
let count = 0;
Object.entries(rels).forEach(([name, date]) => {
if (date >= sixMonthStr) {
count++;
releasesIn6Mo++;
}
});
if (count > 0) {
releasesByProject[project] = count;
}
});
// Update stat cards
document.getElementById('project-count').textContent = committees.length;
document.getElementById('committer-count').textContent = totalRoster.toLocaleString();
document.getElementById('new-committers-6mo').textContent = `${newCommData.total || 0} / ${newIn6Mo}`;
document.getElementById('releases-6mo').textContent = releasesIn6Mo.toLocaleString();
// Growth table — merge PMC members + new committers
const newCommByProject = newCommData.by_project || {};
// Build id->name lookup from all committees (not just ones with growth)
const projectNames = {};
committees.forEach(pmc => { projectNames[pmc.id] = pmc.name; });
const allGrowthProjects = new Set([
...Object.keys(growthByProject),
...Object.keys(newCommByProject),
]);
const growthMerged = [];
allGrowthProjects.forEach(proj => {
const pmcEntry = growthByProject[proj];
const pmcCount = pmcEntry ? pmcEntry.count : 0;
const commCount = newCommByProject[proj] ? newCommByProject[proj].length : 0;
const id = pmcEntry ? pmcEntry.id : proj;
const displayName = pmcEntry ? pmcEntry.name : (projectNames[proj] || proj);
growthMerged.push({name: displayName, id, pmcCount, commCount, total: pmcCount + commCount});
});
growthMerged.sort((a, b) => b.total - a.total);
const maxGrowth = growthMerged.length ? growthMerged[0].total : 1;
const growthTbody = document.querySelector('#growth-table tbody');
growthMerged.forEach(data => {
const pmcPct = (data.pmcCount / maxGrowth * 100).toFixed(0);
const commPct = (data.commCount / maxGrowth * 100).toFixed(0);
const row = document.createElement('tr');
row.innerHTML = `
<td><a href="project.html?id=${data.id}">${data.name}</a></td>
<td style="width:80px;text-align:right">${data.commCount > 0 ? data.commCount : ''}</td>
<td style="width:80px;text-align:right">${data.pmcCount > 0 ? data.pmcCount : ''}</td>
<td style="width:35%"><div class="bar" style="display:flex"><div style="background:#1a73e8;border-radius:3px 0 0 3px;height:100%;width:${commPct}%"></div><div style="background:#d45500;height:100%;width:${pmcPct}%"></div></div></td>
`;
growthTbody.appendChild(row);
});
// Releases table
const relSorted = Object.entries(releasesByProject)
.sort((a, b) => b[1] - a[1]);
const maxRel = relSorted.length ? relSorted[0][1] : 1;
const relTbody = document.querySelector('#releases-table tbody');
relSorted.forEach(([project, count]) => {
const pct = (count / maxRel * 100).toFixed(0);
const row = document.createElement('tr');
row.innerHTML = `
<td>${project}</td>
<td style="width:60px;text-align:right">${count}</td>
<td style="width:40%"><div class="bar"><div class="bar-fill" style="width:${pct}%"></div></div></td>
`;
relTbody.appendChild(row);
});
// Projects table
const projTbody = document.querySelector('#projects-table tbody');
committees
.sort((a, b) => a.name.localeCompare(b.name))
.forEach(c => {
const rosterSize = Object.keys(c.roster || {}).length;
const row = document.createElement('tr');
row.innerHTML = `
<td><a href="project.html?id=${c.id}">${c.name}</a></td>
<td>${c.established || '—'}</td>
<td>${rosterSize}</td>
<td>${c.chair || '—'}</td>
`;
projTbody.appendChild(row);
});
// Mailing list data — load per-project JSON files
// Load single summary file (written by collector) instead of 200+ individual fetches
const mailingTbody = document.querySelector('#mailing-table tbody');
const mailingNote = document.getElementById('mailing-note');
const sixMonthsAgoDate = new Date();
sixMonthsAgoDate.setMonth(sixMonthsAgoDate.getMonth() - 12);
const cutoffYear = sixMonthsAgoDate.getFullYear();
const cutoffMonth = sixMonthsAgoDate.getMonth() + 1;
const cutoffStr = `${cutoffYear}-${String(cutoffMonth).padStart(2, '0')}`;
const mailingResults = [];
try {
const summaryResp = await fetch('data/_cache/mailing_summary.json');
if (summaryResp.ok) {
const summaryData = await summaryResp.json();
summaryData.forEach(entry => {
const months = entry.active_months || {};
let recent = 0;
Object.entries(months).forEach(([month, count]) => {
if (month >= cutoffStr) recent += count;
});
if (recent > 0) {
mailingResults.push({
project: entry.project,
list: entry.list_name,
recent: recent,
months: months,
});
}
});
}
} catch(e) {}
if (mailingResults.length === 0) {
mailingNote.style.display = 'block';
} else {
mailingResults.sort((a, b) => b.recent - a.recent);
const maxMail = mailingResults[0].recent;
mailingResults.forEach(({project, list, recent}) => {
const pct = (recent / maxMail * 100).toFixed(0);
const row = document.createElement('tr');
row.innerHTML = `
<td>${project} (${list}@)</td>
<td style="width:80px;text-align:right">${recent}</td>
<td style="width:40%"><div class="bar"><div class="bar-fill" style="width:${pct}%"></div></div></td>
`;
mailingTbody.appendChild(row);
});
}
// --- Activity Trends tab ---
let healthData = null;
try {
const healthResp = await fetch('data/_cache/project_health.json');
if (healthResp.ok) {
healthData = await healthResp.json();
}
} catch (healthErr) { console.warn('project_health.json not available:', healthErr); }
if (healthData) {
const win = healthData.window;
// Update table headers with actual quarter labels
const qLabel = `(${win.prior_label} ${win.recent_label})`;
document.querySelectorAll('.health-ml-header').forEach(el => { el.textContent = `Mailing List ${qLabel}`; });
document.querySelectorAll('.health-git-header').forEach(el => { el.textContent = `Git Commits ${qLabel}`; });
document.getElementById('dormant-ml-header').textContent = `Mailing List Msgs (${win.recent_label})`;
document.getElementById('dormant-git-header').textContent = `Git Commits (${win.recent_label})`;
document.getElementById('health-title').textContent =
`Activity Trends ${win.recent_label} vs ${win.prior_label}`;
document.getElementById('health-window').textContent = win.description;
document.getElementById('health-methodology').textContent =
'Mailing list counts include only human discussion lists (dev, user, general, discuss). ' +
'Automated lists (notifications, commits, issues) are excluded. Git commits are summed across all repos.';
const thr = healthData.thresholds;
document.getElementById('threshold-decline').textContent = thr.decline_pct;
document.getElementById('threshold-decline2').textContent = thr.decline_pct;
document.getElementById('threshold-prior').textContent = thr.min_prior_activity;
document.getElementById('threshold-dormant-commits').textContent = thr.dormant_commits;
document.getElementById('threshold-dormant-msgs').textContent = thr.dormant_messages;
document.getElementById('health-method-detail').textContent =
`Quarter-over-quarter comparison: ${win.recent_label} (recent) vs ${win.prior_label} (prior). ` +
`Current partial month excluded. Only projects with data files are assessed.`;
// In JSON: "declining" = both axes down (severe), "at_risk" = one axis down (moderate)
// On page: "Sharp Decline" (red) = severe, "Declining" (orange) = moderate
document.getElementById('at-risk-count').textContent = `(${healthData.declining.length})`;
document.getElementById('declining-count').textContent = `(${healthData.at_risk.length})`;
document.getElementById('dormant-count').textContent = `(${healthData.dormant.length})`;
function trendCell(pct, prior, recent, label) {
if (pct === null) return `<span style="color:#999">no data</span>`;
const color = pct <= -30 ? '#c0392b' : pct <= -15 ? '#e67e22' : '#27ae60';
return `<span style="color:${color};font-weight:600">${pct > 0 ? '+' : ''}${pct}%</span> ` +
`<span style="color:#888;font-size:0.85rem">(${prior}→${recent} ${label})</span>`;
}
function fillHealthTable(tableId, entries) {
const tbody = document.querySelector(`#${tableId} tbody`);
entries.forEach(e => {
const row = document.createElement('tr');
const mlCell = trendCell(e.ml_trend_pct, e.ml_prior, e.ml_recent, 'msgs');
const gitCell = trendCell(e.git_trend_pct, e.git_prior, e.git_recent, 'commits');
row.innerHTML = `
<td><a href="project.html?id=${e.project}">${projectNames[e.project] || e.project}</a></td>
<td>${mlCell}</td>
<td>${gitCell}</td>
`;
tbody.appendChild(row);
});
}
// "declining" in JSON = both axes down = "At Risk" on the page (red, severe)
// "at_risk" in JSON = one axis down = "Declining" on the page (orange, warning)
fillHealthTable('at-risk-table', healthData.declining);
fillHealthTable('declining-table', healthData.at_risk);
// Dormant table is simpler
const dormantTbody = document.querySelector('#dormant-table tbody');
healthData.dormant.forEach(e => {
const row = document.createElement('tr');
row.innerHTML = `
<td><a href="project.html?id=${e.project}">${projectNames[e.project] || e.project}</a></td>
<td>${e.ml_recent}</td>
<td>${e.git_recent}</td>
`;
dormantTbody.appendChild(row);
});
}
} catch (e) {
console.error('Failed to load data:', e);
document.getElementById('loading').textContent = 'Error loading data: ' + e.message;
}
}
loadData();
</script>
</body>
</html>