| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Project 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: 1100px; |
| margin: 0 auto; |
| padding: 2rem; |
| background: #f8f9fa; |
| } |
| a { color: #d45500; text-decoration: none; } |
| a:hover { text-decoration: underline; } |
| h1 { margin-bottom: 0.25rem; color: #1a1a2e; } |
| .breadcrumb { color: #666; margin-bottom: 1.5rem; font-size: 0.9rem; } |
| .stats-bar { |
| display: flex; gap: 1.5rem; margin-bottom: 2rem; flex-wrap: wrap; |
| } |
| .stat-card { |
| background: white; border-radius: 8px; padding: 1.2rem 1.5rem; |
| box-shadow: 0 1px 3px rgba(0,0,0,0.1); min-width: 140px; flex: 1; |
| } |
| .stat-card .number { font-size: 1.8rem; font-weight: 700; color: #d45500; } |
| .stat-card .label { color: #666; font-size: 0.85rem; } |
| .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; font-size: 1.2rem; } |
| table { width: 100%; border-collapse: collapse; } |
| th, td { text-align: left; padding: 0.4rem 0.8rem; border-bottom: 1px solid #eee; } |
| th { font-weight: 600; color: #666; font-size: 0.8rem; text-transform: uppercase; } |
| .sparkline { |
| display: inline-flex; align-items: flex-end; gap: 2px; height: 30px; |
| } |
| .sparkline .bar { |
| width: 8px; background: #d45500; border-radius: 2px 2px 0 0; |
| min-height: 2px; |
| } |
| .chart-container { |
| width: 100%; height: 200px; display: flex; align-items: flex-end; |
| gap: 2px; padding: 1rem 0; |
| } |
| .trend-overlay { |
| position: absolute; |
| top: 0; left: 0; right: 0; bottom: 0; |
| pointer-events: none; |
| } |
| .trend-line { |
| stroke: #ff6600; |
| stroke-width: 2; |
| stroke-dasharray: 6 3; |
| opacity: 0.7; |
| } |
| .chart-bar { |
| flex: 1; background: #d45500; border-radius: 3px 3px 0 0; |
| min-height: 2px; position: relative; |
| } |
| .chart-bar:hover { background: #ff6600; } |
| .chart-bar .tooltip { |
| display: none; position: absolute; bottom: 100%; left: 50%; |
| transform: translateX(-50%); background: #333; color: white; |
| padding: 2px 6px; border-radius: 3px; font-size: 0.75rem; |
| white-space: nowrap; |
| } |
| .chart-bar:hover .tooltip { display: block; } |
| .chart-labels { |
| display: flex; gap: 2px; font-size: 0.7rem; color: #999; |
| } |
| .chart-labels span { flex: 1; text-align: center; } |
| #loading { color: #999; font-style: italic; padding: 2rem; text-align: center; } |
| #error { color: #dc3545; display: none; padding: 2rem; text-align: center; } |
| </style> |
| </head> |
| <body> |
| <div class="breadcrumb"><a href="index.html">← All Projects</a></div> |
| <h1 id="project-title">Loading...</h1> |
| <div id="loading">Loading project data...</div> |
| <div id="error"></div> |
| <div id="app" style="display:none"> |
| |
| <div class="stats-bar"> |
| <div class="stat-card"> |
| <div class="number" id="stat-messages">—</div> |
| <div class="label">Messages (12 months)</div> |
| </div> |
| <div class="stat-card"> |
| <div class="number" id="stat-lists">—</div> |
| <div class="label">Active Lists</div> |
| </div> |
| <div class="stat-card"> |
| <div class="number" id="stat-roster">—</div> |
| <div class="label">PMC Size</div> |
| </div> |
| <div class="stat-card"> |
| <div class="number" id="stat-releases">—</div> |
| <div class="label">Releases (12 months)</div> |
| </div> |
| <div class="stat-card"> |
| <div class="number" id="stat-established">—</div> |
| <div class="label">Established</div> |
| </div> |
| </div> |
| |
| <div id="trend-badge" style="display:none;margin-bottom:1.5rem;padding:0.8rem 1.2rem;border-radius:8px;font-size:0.95rem"> |
| </div> |
| |
| <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> |
| <tbody></tbody> |
| </table> |
| </div> |
| |
| <div class="section" id="chart-section" style="display:none"> |
| <h2 id="chart-title">dev@ — Monthly Messages</h2> |
| <div id="chart-wrapper" style="position:relative;height:200px"><div class="chart-container" id="chart"></div></div> |
| <div class="chart-labels" id="chart-labels"></div> |
| </div> |
| |
| <div class="section" id="git-section" style="display:none"> |
| <h2>Git/VCS Activity (12 Months)</h2> |
| <div class="stats-bar" id="git-stats" style="margin-bottom:1rem"></div> |
| <table id="git-repos-table"> |
| <thead><tr><th>Repository</th><th style="text-align:right">Commits</th><th style="text-align:right">Committers</th><th style="width:100px">Trend</th></tr></thead> |
| <tbody></tbody> |
| </table> |
| <div id="git-chart-section" style="display:none;margin-top:1rem"> |
| <h3 id="git-chart-title" style="font-size:0.95rem;color:#666;margin-bottom:0.5rem"></h3> |
| <div id="git-chart-wrapper" style="position:relative;height:200px"> |
| <div class="chart-container" id="git-chart"></div> |
| </div> |
| <div class="chart-labels" id="git-chart-labels"></div> |
| <div id="git-pr-wrapper" style="position:relative;height:150px;margin-top:1rem;display:none"> |
| <h3 style="font-size:0.95rem;color:#666;margin-bottom:0.5rem">Pull Requests</h3> |
| <div class="chart-container" id="git-pr-chart" style="height:120px"></div> |
| </div> |
| <p id="git-pr-legend" style="display:none;font-size:0.8rem;color:#666;margin-top:0.3rem"><span style="display:inline-block;width:12px;height:12px;background:#2e7d32;border-radius:2px;vertical-align:middle"></span> Merged <span style="display:inline-block;width:12px;height:12px;background:#d45500;border-radius:2px;vertical-align:middle"></span> Opened (not yet merged)</p> |
| <div class="chart-labels" id="git-pr-labels" style="display:none"></div> |
| </div> |
| </div> |
| |
| <div class="section" id="releases-section" style="display:none"> |
| <h2>Recent Releases</h2> |
| <table id="releases-table"> |
| <thead><tr><th>Release</th><th>Date</th></tr></thead> |
| <tbody></tbody> |
| </table> |
| </div> |
| |
| <div class="section" id="project-links-section" style="display:none"> |
| <h2>Project Info</h2> |
| <p id="project-links"></p> |
| </div> |
| |
| <div class="section" id="growth-section" style="display:none"> |
| <h2>Community Growth (Last 12 Months)</h2> |
| <table id="growth-table"> |
| <thead><tr><th>Name</th><th>Date Added</th><th>Type</th></tr></thead> |
| <tbody></tbody> |
| </table> |
| <p id="growth-none" style="display:none;color:#666;font-style:italic">No new committers or PMC members added in the last 12 months.</p> |
| </div> |
| |
| </div><!-- #app --> |
| |
| <script> |
| const params = new URLSearchParams(window.location.search); |
| const projectId = params.get('id'); |
| |
| if (!projectId) { |
| document.getElementById('loading').style.display = 'none'; |
| document.getElementById('error').style.display = 'block'; |
| document.getElementById('error').textContent = 'No project specified. Use ?id=projectname'; |
| } else { |
| loadProject(projectId); |
| } |
| |
| async function loadProject(id) { |
| document.title = `${id} — ASF Community Metrics`; |
| document.getElementById('project-title').textContent = `Apache ${id.charAt(0).toUpperCase() + id.slice(1)}`; |
| |
| // 12-month cutoff for trend context |
| const now = new Date(); |
| const yearAgo = new Date(now); |
| yearAgo.setMonth(yearAgo.getMonth() - 12); |
| const cutoffStr = `${yearAgo.getFullYear()}-${String(yearAgo.getMonth()+1).padStart(2,'0')}`; |
| |
| try { |
| // Load mailing list data |
| const [mlResp, committeesResp, releasesResp] = await Promise.all([ |
| fetch(`data/${id}.json`), |
| fetch('data/_cache/committees.json'), |
| fetch('data/_cache/releases.json'), |
| ]); |
| |
| document.getElementById('loading').style.display = 'none'; |
| document.getElementById('app').style.display = 'block'; |
| |
| // --- Mailing lists --- |
| let totalMessages = 0; |
| if (mlResp.ok) { |
| const ml = await mlResp.json(); |
| const listsTbody = document.querySelector('#lists-table tbody'); |
| |
| ml.active_lists.forEach(list => { |
| const months = list.active_months || {}; |
| // Build full month range (fill gaps with 0) |
| let quarterTotal = 0; |
| const recentMonths = []; |
| const sparkStart = new Date(cutoffStr + '-01'); |
| const sparkNow = new Date(); |
| const sparkCursor = new Date(sparkStart); |
| while (sparkCursor <= sparkNow) { |
| const key = `${sparkCursor.getFullYear()}-${String(sparkCursor.getMonth()+1).padStart(2,'0')}`; |
| const count = months[key] || 0; |
| quarterTotal += count; |
| recentMonths.push({month: key, count}); |
| sparkCursor.setMonth(sparkCursor.getMonth() + 1); |
| } |
| totalMessages += quarterTotal; |
| |
| // Skip lists with no traffic in the quarter |
| if (quarterTotal === 0) return; |
| |
| // Sparkline |
| const maxInRecent = Math.max(...recentMonths.map(m => m.count), 1); |
| const sparkHtml = recentMonths.map(m => { |
| const h = m.count > 0 ? Math.max(2, (m.count / maxInRecent) * 28) : 1; |
| return `<div class="bar" style="height:${h}px" title="${m.month}: ${m.count}"></div>`; |
| }).join(''); |
| |
| const row = document.createElement('tr'); |
| 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><div class="sparkline">${sparkHtml}</div></td> |
| `; |
| row.style.cursor = 'pointer'; |
| row.addEventListener('click', () => showChart(list, ml.domain, cutoffStr)); |
| listsTbody.appendChild(row); |
| }); |
| |
| document.getElementById('stat-messages').textContent = totalMessages; |
| document.getElementById('stat-lists').textContent = ml.active_lists.length; |
| |
| // Show chart for first list by default |
| if (ml.active_lists.length > 0) { |
| showChart(ml.active_lists[0], ml.domain, cutoffStr); |
| } |
| } else { |
| 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>'; |
| } |
| |
| // --- Committee / roster --- |
| if (committeesResp.ok) { |
| const committees = await committeesResp.json(); |
| const pmc = committees.find(c => c.id === id); |
| if (pmc) { |
| document.getElementById('project-title').textContent = pmc.name; |
| document.getElementById('stat-established').textContent = pmc.established || '—'; |
| |
| const roster = pmc.roster || {}; |
| document.getElementById('stat-roster').textContent = Object.keys(roster).length; |
| |
| // Link to projects.apache.org for full project metadata |
| const linksSection = document.getElementById('project-links-section'); |
| linksSection.style.display = 'block'; |
| document.getElementById('project-links').innerHTML = |
| `<a href="https://projects.apache.org/committee.html?${id}" target="_blank">` + |
| `View full project details on projects.apache.org →</a>` + |
| ` (roster, description, repositories, homepage)`; |
| } |
| } |
| |
| // --- Releases --- |
| if (releasesResp.ok) { |
| const releases = await releasesResp.json(); |
| const projectReleases = releases[id] || {}; |
| const recentReleases = Object.entries(projectReleases) |
| .filter(([_, date]) => date >= cutoffStr) |
| .sort(([_,a],[__,b]) => b.localeCompare(a)); |
| |
| document.getElementById('stat-releases').textContent = recentReleases.length; |
| |
| if (recentReleases.length > 0) { |
| const relSection = document.getElementById('releases-section'); |
| relSection.style.display = 'block'; |
| const relTbody = document.querySelector('#releases-table tbody'); |
| const MAX_VISIBLE = 10; |
| recentReleases.forEach(([name, date], idx) => { |
| const row = document.createElement('tr'); |
| row.innerHTML = `<td>${name}</td><td>${date}</td>`; |
| if (idx >= MAX_VISIBLE) row.style.display = 'none'; |
| if (idx >= MAX_VISIBLE) row.className = 'release-overflow'; |
| relTbody.appendChild(row); |
| }); |
| if (recentReleases.length > MAX_VISIBLE) { |
| const moreRow = document.createElement('tr'); |
| moreRow.innerHTML = `<td colspan="2" style="color:#d45500;cursor:pointer;font-size:0.9rem">▸ Show ${recentReleases.length - MAX_VISIBLE} more releases...</td>`; |
| moreRow.addEventListener('click', () => { |
| document.querySelectorAll('.release-overflow').forEach(r => r.style.display = ''); |
| moreRow.remove(); |
| }); |
| relTbody.appendChild(moreRow); |
| } |
| } |
| } |
| |
| // --- Git/VCS activity --- |
| const gitResp = await fetch(`data/${id}_git.json`); |
| if (gitResp.ok) { |
| const git = await gitResp.json(); |
| const activeRepos = git.active_repos || []; |
| |
| if (activeRepos.length > 0) { |
| const gitSection = document.getElementById('git-section'); |
| gitSection.style.display = 'block'; |
| |
| // Stats bar |
| const totals = git.totals || {}; |
| const statsHtml = ` |
| <div class="stat-card"><div class="number">${(totals.commits||0).toLocaleString()}</div><div class="label">Commits</div></div> |
| <div class="stat-card"><div class="number">${totals.committers||0}</div><div class="label">Unique Committers</div></div> |
| <div class="stat-card"><div class="number">${activeRepos.length}</div><div class="label">Active Repos</div></div> |
| ${totals.prs_opened !== undefined ? `<div class="stat-card"><div class="number">${totals.prs_opened||0}</div><div class="label">PRs Opened</div></div>` : ''} |
| ${totals.prs_merged !== undefined ? `<div class="stat-card"><div class="number">${totals.prs_merged||0}</div><div class="label">PRs Merged</div></div>` : ''} |
| `; |
| document.getElementById('git-stats').innerHTML = statsHtml; |
| |
| // Repos table (like mailing lists) |
| const gitTbody = document.querySelector('#git-repos-table tbody'); |
| const org = git.org || 'apache'; |
| |
| activeRepos.forEach(repo => { |
| const monthly = repo.monthly || {}; |
| const monthKeys = Object.keys(monthly).sort(); |
| |
| // Build sparkline |
| const sparkMonths = []; |
| const sparkStart = new Date(cutoffStr + '-01'); |
| const sparkNow = new Date(); |
| const sparkCursor = new Date(sparkStart); |
| while (sparkCursor <= sparkNow) { |
| const key = `${sparkCursor.getFullYear()}-${String(sparkCursor.getMonth()+1).padStart(2,'0')}`; |
| sparkMonths.push((monthly[key] || {}).commits || 0); |
| sparkCursor.setMonth(sparkCursor.getMonth() + 1); |
| } |
| const sparkMax = Math.max(...sparkMonths, 1); |
| const sparkBars = sparkMonths.map(v => |
| `<div style="display:inline-block;width:4px;margin-right:1px;vertical-align:bottom;background:#1a73e8;height:${Math.max(1, (v/sparkMax)*20)}px"></div>` |
| ).join(''); |
| |
| const repoLink = git.vcs === 'svn' |
| ? repo.repo_name |
| : `${repo.repo_name} <a href="https://github.com/${org}/${repo.repo_name}" target="_blank" title="View on GitHub" onclick="event.stopPropagation()"><svg height="14" width="14" viewBox="0 0 16 16" style="vertical-align:middle;opacity:0.5"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg></a>`; |
| |
| const row = document.createElement('tr'); |
| row.style.cursor = 'pointer'; |
| row.innerHTML = `<td style="white-space:nowrap">${repoLink}</td><td style="text-align:right">${repo.commits.toLocaleString()}</td><td style="text-align:right">${repo.committers}</td><td>${sparkBars}</td>`; |
| row.addEventListener('click', () => showGitChart(repo, org, git.vcs, cutoffStr)); |
| gitTbody.appendChild(row); |
| }); |
| |
| // Auto-show chart for the first (most active) repo |
| if (activeRepos.length > 0) { |
| showGitChart(activeRepos[0], org, git.vcs, cutoffStr); |
| } |
| } |
| } |
| |
| // --- Activity trend badge --- |
| try { |
| const trendResp = await fetch('data/_cache/project_health.json'); |
| if (trendResp.ok) { |
| const trendData = await trendResp.json(); |
| const badge = document.getElementById('trend-badge'); |
| const win = trendData.window; |
| |
| const inDeclining = trendData.declining.find(e => e.project === id); |
| const inAtRisk = trendData.at_risk.find(e => e.project === id); |
| const inDormant = trendData.dormant.find(e => e.project === id); |
| |
| if (inDormant) { |
| badge.style.display = 'block'; |
| badge.style.background = '#f0f0f0'; |
| badge.style.borderLeft = '4px solid #555'; |
| badge.innerHTML = `<strong>⚫ Dormant</strong> — Near-zero activity in ${win.recent_label}: fewer than 5 commits and 5 mailing list messages.`; |
| } else if (inDeclining) { |
| badge.style.display = 'block'; |
| badge.style.background = '#fdf2f2'; |
| badge.style.borderLeft = '4px solid #c0392b'; |
| const mlStr = inDeclining.ml_trend_pct !== null ? `ML: ${inDeclining.ml_trend_pct}%` : ''; |
| const gitStr = inDeclining.git_trend_pct !== null ? `Git: ${inDeclining.git_trend_pct}%` : ''; |
| badge.innerHTML = `<strong>🔴 Sharp Decline</strong> — Both mailing list and git activity down ≥ 30% (${win.prior_label} → ${win.recent_label}). ${mlStr} ${gitStr}`; |
| } else if (inAtRisk) { |
| badge.style.display = 'block'; |
| badge.style.background = '#fef9e7'; |
| badge.style.borderLeft = '4px solid #e67e22'; |
| const mlStr = inAtRisk.ml_trend_pct !== null ? `ML: ${inAtRisk.ml_trend_pct}%` : ''; |
| const gitStr = inAtRisk.git_trend_pct !== null ? `Git: ${inAtRisk.git_trend_pct}%` : ''; |
| badge.innerHTML = `<strong>🟡 Declining</strong> — One or both metrics down ≥ 30% (${win.prior_label} → ${win.recent_label}). ${mlStr} ${gitStr}`; |
| } else { |
| // No badge shown for projects without notable trends — the data speaks for itself |
| } |
| } |
| } catch(trendErr) { console.warn('Trend data not available:', trendErr); } |
| |
| // --- Community growth (new committers + PMC members) --- |
| try { |
| const ncResp = await fetch('data/_cache/new_committers.json'); |
| if (ncResp.ok) { |
| const nc = await ncResp.json(); |
| const projectCommitters = nc.by_project[id] || []; |
| |
| // Also check PMC roster for recent additions |
| const growthSection = document.getElementById('growth-section'); |
| growthSection.style.display = 'block'; |
| |
| if (projectCommitters.length === 0) { |
| document.getElementById('growth-none').style.display = 'block'; |
| document.querySelector('#growth-table').style.display = 'none'; |
| } else { |
| const tbody = document.querySelector('#growth-table tbody'); |
| projectCommitters.sort((a, b) => b.date.localeCompare(a.date)); |
| projectCommitters.forEach(p => { |
| const row = document.createElement('tr'); |
| row.innerHTML = `<td>${p.name}</td><td>${p.date}</td><td>Committer</td>`; |
| tbody.appendChild(row); |
| }); |
| } |
| } |
| } catch(ncErr) { console.warn('New committers data not available:', ncErr); } |
| |
| } catch(e) { |
| document.getElementById('error').style.display = 'block'; |
| document.getElementById('error').textContent = `Error: ${e.message}`; |
| } |
| } |
| |
| function showChart(list, domain, cutoffStr) { |
| const section = document.getElementById('chart-section'); |
| section.style.display = 'block'; |
| document.getElementById('chart-title').textContent = |
| `${list.list_name}@${domain} — Monthly Messages`; |
| |
| const months = list.active_months || {}; |
| |
| // Generate ALL months in the 12-month window (fill gaps with 0) |
| const allMonths = []; |
| const startDate = new Date(cutoffStr + '-01'); |
| const now = new Date(); |
| const cursor = new Date(startDate); |
| while (cursor <= now) { |
| const key = `${cursor.getFullYear()}-${String(cursor.getMonth()+1).padStart(2,'0')}`; |
| allMonths.push([key, months[key] || 0]); |
| cursor.setMonth(cursor.getMonth() + 1); |
| } |
| |
| const maxCount = Math.max(...allMonths.map(([_,c]) => c), 1); |
| const chart = document.getElementById('chart'); |
| const labels = document.getElementById('chart-labels'); |
| chart.innerHTML = ''; |
| labels.innerHTML = ''; |
| const existingTrend = document.getElementById('chart-wrapper').querySelector('.trend-overlay'); |
| if (existingTrend) existingTrend.remove(); |
| |
| allMonths.forEach(([month, count]) => { |
| 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>`; |
| chart.appendChild(bar); |
| |
| const lbl = document.createElement('span'); |
| lbl.textContent = month.slice(5); // just "01", "02", etc. |
| labels.appendChild(lbl); |
| }); |
| |
| // Draw trend line (linear regression) over the chart |
| if (allMonths.length >= 2) { |
| const n = allMonths.length; |
| const values = allMonths.map(([m, c]) => { |
| const now = new Date(); |
| const currentMonthStr = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}`; |
| if (m === currentMonthStr && c > 0) { |
| const dayOfMonth = now.getDate(); |
| const daysInMonth = new Date(now.getFullYear(), now.getMonth()+1, 0).getDate(); |
| if (dayOfMonth > 0 && dayOfMonth < daysInMonth) |
| return Math.round(c * (daysInMonth / dayOfMonth)); |
| } |
| return c; |
| }); |
| |
| let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0; |
| for (let i = 0; i < n; i++) { |
| sumX += i; |
| sumY += values[i]; |
| sumXY += i * values[i]; |
| sumX2 += i * i; |
| } |
| const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX); |
| const intercept = (sumY - slope * sumX) / n; |
| |
| // Map to pixel Y: 0→bottom(184), maxCount→top(4). Chart=200px, bars max 180px. |
| const yForVal = (v) => 184 - (v / maxCount) * 180; |
| const y1 = yForVal(intercept); |
| 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.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); |
| } |
| } |
| |
| function showGitChart(repo, org, vcs, cutoffStr) { |
| const section = document.getElementById('git-chart-section'); |
| section.style.display = 'block'; |
| |
| const title = vcs === 'svn' |
| ? `${repo.repo_name} — Monthly Commits` |
| : `<a href="https://github.com/${org}/${repo.repo_name}" target="_blank">${repo.repo_name}</a> — Monthly Commits`; |
| document.getElementById('git-chart-title').innerHTML = title; |
| |
| const monthly = repo.monthly || {}; |
| |
| // Generate full month range |
| const allMonths = []; |
| const startDate = new Date(cutoffStr + '-01'); |
| const now = new Date(); |
| const cursor = new Date(startDate); |
| while (cursor <= now) { |
| const key = `${cursor.getFullYear()}-${String(cursor.getMonth()+1).padStart(2,'0')}`; |
| allMonths.push([key, (monthly[key] || {}).commits || 0]); |
| cursor.setMonth(cursor.getMonth() + 1); |
| } |
| |
| const maxCount = Math.max(...allMonths.map(([_,c]) => c), 1); |
| const chart = document.getElementById('git-chart'); |
| const labels = document.getElementById('git-chart-labels'); |
| chart.innerHTML = ''; |
| labels.innerHTML = ''; |
| |
| // Remove old trend line |
| const existingTrend = document.getElementById('git-chart-wrapper').querySelector('.trend-overlay'); |
| if (existingTrend) existingTrend.remove(); |
| |
| allMonths.forEach(([month, count]) => { |
| 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} commits</div>`; |
| chart.appendChild(bar); |
| |
| const lbl = document.createElement('span'); |
| lbl.textContent = month.slice(5); |
| labels.appendChild(lbl); |
| }); |
| |
| // PR chart (if data exists) |
| const hasPRs = Object.values(monthly).some(d => d.prs_opened > 0 || d.prs_merged > 0); |
| const prWrapper = document.getElementById('git-pr-wrapper'); |
| const prLabels = document.getElementById('git-pr-labels'); |
| if (hasPRs) { |
| prWrapper.style.display = 'block'; |
| prLabels.style.display = 'flex'; |
| document.getElementById('git-pr-legend').style.display = 'block'; |
| const prChart = document.getElementById('git-pr-chart'); |
| prChart.innerHTML = ''; |
| prLabels.innerHTML = ''; |
| |
| const prValues = allMonths.map(([m]) => ((monthly[m] || {}).prs_merged || 0) + ((monthly[m] || {}).prs_opened || 0)); |
| const maxPr = Math.max(...prValues, 1); |
| |
| allMonths.forEach(([month]) => { |
| const merged = (monthly[month] || {}).prs_merged || 0; |
| const opened = (monthly[month] || {}).prs_opened || 0; |
| const total = merged + opened; |
| const h = total > 0 ? Math.max(2, (total / maxPr) * 100) : 0; |
| const bar = document.createElement('div'); |
| bar.className = 'chart-bar'; |
| bar.style.height = `${h}px`; |
| bar.style.background = merged > 0 ? '#2e7d32' : '#d45500'; |
| if (total === 0) { bar.style.minHeight = '0'; bar.style.background = '#eee'; } |
| bar.innerHTML = `<div class="tooltip">${month}: ${opened} opened, ${merged} merged</div>`; |
| prChart.appendChild(bar); |
| |
| const lbl = document.createElement('span'); |
| lbl.textContent = month.slice(5); |
| prLabels.appendChild(lbl); |
| }); |
| } else { |
| prWrapper.style.display = 'none'; |
| prLabels.style.display = 'none'; |
| document.getElementById('git-pr-legend').style.display = 'none'; |
| } |
| } |
| </script> |
| </body> |
| </html> |