Thanks. Applied in r1939899. > On Oct 31, 2026, at 6:50 PM, Jarek Potiuk <[email protected]> wrote: > > 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] >
--------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
