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&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>
> @@ -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]

Reply via email to