Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions scripts/cronjobs/buildchooser.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,9 @@ def owner(pid, p):
text = ' '.join(str(p.get(k) or '') for k in ('name', 'shortdesc', 'description'))
langs = sorted({LANGUAGES.get(l.lower(), 'Other')
for l in as_list(p.get('programming-language'))})
committers = len(groups.get(pmc, [])) or None
# podlings have their own group; 'incubator' is the whole Incubator
group = pid[len('incubator-'):] if pmc == 'incubator' else pmc
committers = len(groups.get(group, [])) or None
est = (committees.get(pmc) or {}).get('established', '')
rel = last_release(pmc) or last_release(pid) # newest release of anything the committee ships
records.append({
Expand All @@ -212,13 +214,14 @@ def owner(pid, p):
if any(r['id'] == pid or r['id'] == 'incubator-' + pid for r in records):
continue
text = (p.get('name') or '') + ' ' + (p.get('description') or '')
committers = len(groups.get(pid, [])) or None
records.append({
'id': pid, 'name': p.get('name', pid),
'desc': ' '.join((p.get('description') or '').split())[:300],
'homepage': p.get('homepage'), 'pmc': 'incubator', 'podling': True,
'language': [], 'area': classify([], text, AREAS), 'kind': classify([], text, KINDS),
'maturity': ['incubating'], 'size': [], 'activity': [],
'lastRelease': None, 'committers': None,
'maturity': ['incubating'], 'size': [size_bucket(committers)] if committers else [],
'activity': [], 'lastRelease': None, 'committers': committers,
})

def opts(field, labels):
Expand Down
111 changes: 85 additions & 26 deletions site/chooser.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@
.result h3 { margin: 0 0 4px; }
.result .why { font-size: 85%; color: #666; margin-top: 6px; }
.result .links a { margin-right: 12px; font-size: 90%; }
.result.new { animation: fadein .4s; }
@keyframes fadein { from { opacity: 0; transform: translateY(-4px); } }
#matches-h { margin-top: 28px; margin-bottom: 4px; }
#shown { color: #666; font-size: 90%; margin-top: 0; }
.score { float: right; font-size: 85%; color: #c6262e; }
.mock { background: #fff6d5; border: 1px solid #e6c84f; padding: 8px 12px; border-radius: 4px; font-size: 90%; }
</style>
Expand All @@ -50,6 +54,13 @@ <h1>Help me choose a project</h1>
support, design, translation and outreach just as much. Skip anything you don't care about.</p>
<div class="progress"><div id="prog" style="width:0"></div></div>
<div id="stage">Loading project data&hellip;</div>
<div id="matches" hidden>
<h2 id="matches-h"></h2>
<p class="hint" id="shown"></p>
<div id="list"></div>
<p id="empty" hidden>No matches - try going back and loosening an answer.</p>
</div>
<div id="advice"></div>
<p class="answers" id="generated"></p>
</div>
</div>
Expand All @@ -71,7 +82,7 @@ <h1>Help me choose a project</h1>
* projects with incomplete metadata aren't thrown out entirely.
* - We stop when few enough projects remain, or questions run out.
*/
const STOP_AT = 8, SHOW = 12;
const STOP_AT = 8, SHOW = 10;
let DATA, answers = {}, history = [];

const $s = () => document.getElementById('stage');
Expand All @@ -86,13 +97,12 @@ <h1>Help me choose a project</h1>
(answers[qid] || []).some(v => vals.includes(v)));
}

function score(p) {
let s = 0, why = [];
for (const [qid, vals] of Object.entries(answers)) {
if (!vals.length || !isFilter(qid)) continue;
if (matches(p, qid, vals)) { s++; why.push(vals.filter(v => p[qid].includes(v)).map(labelFor.bind(null, qid)).join(', ')); }
function score(p, ans) {
let s = 0;
for (const [qid, vals] of Object.entries(ans)) {
if (vals.length && isFilter(qid) && matches(p, qid, vals)) s++;
}
return {s, why};
return s;
}

function labelFor(qid, v) {
Expand All @@ -102,9 +112,9 @@ <h1>Help me choose a project</h1>
}

// projects matching every non-skipped answer
function candidates() {
function candidates(ans = answers) {
return DATA.projects.filter(p =>
Object.entries(answers).every(([qid, vals]) => !vals.length || matches(p, qid, vals)));
Object.entries(ans).every(([qid, vals]) => !vals.length || matches(p, qid, vals)));
}

// entropy of a question's split over the current candidates
Expand Down Expand Up @@ -133,6 +143,7 @@ <h1>Help me choose a project</h1>
document.getElementById('prog').style.width =
Math.round(100 * history.length / DATA.questions.filter(eligible).length) + '%';
if (!q) return showResults();
document.getElementById('advice').innerHTML = '';

const sel = new Set();
const opts = q.options.map(o => {
Expand All @@ -149,19 +160,31 @@ <h2>${esc(q.text)}</h2>
<button class="btn sec" id="skip">Skip / no preference</button>
${history.length ? '<button class="btn sec" id="back">Back</button>' : ''}
<button class="btn sec" id="now">Show matches now</button>
<span class="remaining">${cands.length} communities match so far</span>
<span class="remaining" id="remaining"></span>
</div></div>` + answerSummary();

// Toggling an option previews its effect on the match list straight away;
// Next commits it and moves on to the following question.
const pending = () => sel.size ? {...answers, [q.id]: [...sel]} : answers;
const preview = () => {
document.getElementById('remaining').textContent =
`${candidates(pending()).length} communities match so far`;
showMatches(pending(), 'matches so far');
};
$s().querySelectorAll('.opt').forEach(el => el.onclick = () => {
const v = el.dataset.v;
if (!q.multi) { sel.clear(); $s().querySelectorAll('.opt').forEach(e => e.classList.remove('on')); }
if (sel.has(v)) { sel.delete(v); el.classList.remove('on'); } else { sel.add(v); el.classList.add('on'); }
if (!q.multi) answer(q, [...sel]);
if (!q.multi) answer(q, [...sel]); else preview();
});
document.getElementById('next').onclick = () => answer(q, [...sel]);
document.getElementById('skip').onclick = () => answer(q, []);
document.getElementById('now').onclick = showResults;
document.getElementById('now').onclick = () => {
if (sel.size) { answers[q.id] = [...sel]; history.push(q.id); }
showResults();
};
const b = document.getElementById('back'); if (b) b.onclick = back;
preview();
}

function answer(q, vals) { answers[q.id] = vals; history.push(q.id); render(); }
Expand All @@ -173,15 +196,15 @@ <h2>${esc(q.text)}</h2>
return parts.length ? `<p class="answers">Your choices: ${parts.join(' ')}</p>` : '';
}

function showResults() {
document.getElementById('prog').style.width = '100%';
const asked = Object.entries(answers).filter(([k, v]) => v.length && isFilter(k)).length;
const ranked = DATA.projects.map(p => ({p, ...score(p)}))
.filter(r => r.s > 0 || !asked)
.sort((a, b) => b.s - a.s || (b.p.committers || 0) - (a.p.committers || 0))
.slice(0, SHOW);
const html = ranked.map(({p, s, why}) => `<div class="result">
${asked ? `<span class="score">matches ${s} of ${asked}</span>` : ''}
// One card per project, built on first use and reused afterwards, so the
// list can be re-ranked by moving nodes instead of rebuilding the page.
const cards = new Map();
function card(p) {
if (cards.has(p.id)) return cards.get(p.id);
const el = document.createElement('div');
el.className = 'result';
el.dataset.id = p.id;
el.innerHTML = `<span class="score"></span>
<h3>${esc(p.name)}</h3>
<div>${esc(p.desc)}</div>
<div class="why">${p.language.length ? 'Languages: ' + esc(p.language.join(', ')) + ' · ' : ''}${
Expand All @@ -191,12 +214,48 @@ <h3>${esc(p.name)}</h3>
${p.homepage ? `<a href="${esc(p.homepage)}">Website</a>` : ''}
${p.podling ? '' : `<a href="project.html?${esc(p.id)}">Project details</a>`}
<a href="https://lists.apache.org/list.html?dev@${esc(p.podling ? p.id : p.pmc)}.apache.org">dev@ list</a>
</div></div>`).join('');
$s().innerHTML = `<h2>Projects you might like</h2>
${html || '<p>No matches - try going back and loosening an answer.</p>'}
${gettingStarted()}
<div class="bar">${history.length ? '<button class="btn sec" id="back">Back</button>' : ''}
</div>`;
cards.set(p.id, el);
return el;
}

// Update the match list in place: drop cards that fell out of the top
// SHOW, move the rest into rank order, and fade in newcomers.
function showMatches(ans, what) {
const asked = Object.entries(ans).filter(([k, v]) => v.length && isFilter(k)).length;
const all = DATA.projects.map(p => ({p, s: score(p, ans)}))
.filter(r => r.s > 0 || !asked)
.sort((a, b) => b.s - a.s || (b.p.committers || 0) - (a.p.committers || 0));
const ranked = all.slice(0, SHOW);
const list = document.getElementById('list');
const keep = new Set(ranked.map(r => r.p.id));
[...list.children].forEach(el => { if (!keep.has(el.dataset.id)) el.remove(); });
ranked.forEach(({p, s}, i) => {
const el = card(p);
el.querySelector('.score').textContent = asked ? `matches ${s} of ${asked}` : '';
if (!el.isConnected) { el.classList.remove('new'); void el.offsetWidth; el.classList.add('new'); }
if (list.children[i] !== el) list.insertBefore(el, list.children[i] || null);
});
document.getElementById('matches-h').textContent = all.length > SHOW
? `Top ${SHOW} ${what}` : what[0].toUpperCase() + what.slice(1);
// Always say that this is only the top of the list, not every match.
document.getElementById('shown').textContent = all.length <= SHOW
? `Showing all ${all.length} matching communities.`
: `Showing only the top ${SHOW} of ${all.length} ${asked
? 'communities that match at least one of your answers, ranked by how many they match, then by community size'
: 'communities, largest first'}.${
document.getElementById('next') ? ' Answer more questions to narrow it down.' : ''}`;
document.getElementById('shown').hidden = !all.length;
document.getElementById('empty').hidden = ranked.length > 0;
document.getElementById('matches').hidden = false;
}

function showResults() {
document.getElementById('prog').style.width = '100%';
$s().innerHTML = `<div class="bar">${history.length ? '<button class="btn sec" id="back">Back</button>' : ''}
<button class="btn" id="restart">Start over</button></div>` + answerSummary();
showMatches(answers, 'projects you might like');
document.getElementById('advice').innerHTML = gettingStarted();
document.getElementById('restart').onclick = () => { answers = {}; history = []; render(); };
const b = document.getElementById('back'); if (b) b.onclick = back;
}
Expand Down
Loading