Hi all,
Below is a patch for review against projects.apache.org trunk
(r1939852). It improves the UX of the project chooser
(site/chooser.html):
- The list of matching projects is shown while you answer the
questions. It updates in place as you toggle answers, so you can see
what each choice does before you click Next.
- At most 10 matches are shown, and the page says clearly when that is
only the top 10 of a longer list, and how it is ranked.
- Podling committer counts now come from the podling's own group, not
the whole Incubator. Podlings also get a size bucket, so the "size"
question works for them.
The patch only touches scripts/cronjobs/buildchooser.py and
site/chooser.html. The generated site/json/foundation/chooser.json is
not included; the daily cron will rebuild it.
Apply with: svn patch chooser.patch
If there are no objections, I'll commit it in a few days.
Generated-by: Claude Opus 5
Jarek
--- patch ---
Index: scripts/cronjobs/buildchooser.py
===================================================================
--- scripts/cronjobs/buildchooser.py (revision 1939852)
+++ scripts/cronjobs/buildchooser.py (working copy)
@@ -186,7 +186,9 @@
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({
@@ -211,13 +213,14 @@
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):
Index: site/chooser.html
===================================================================
--- site/chooser.html (revision 1939852)
+++ site/chooser.html (working copy)
@@ -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>
@@ -50,6 +54,13 @@
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…</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>
@@ -71,7 +82,7 @@
* 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');
@@ -86,13 +97,12 @@
(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) {
@@ -102,9 +112,9 @@
}
// 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
@@ -133,6 +143,7 @@
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 => {
@@ -149,19 +160,31 @@
<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(); }
@@ -173,15 +196,15 @@
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(', ')) + ' ยท ' : ''}${
@@ -191,12 +214,48 @@
${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;
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]