Thank you :)

On Sun, Nov 1, 2026 at 3:26 PM Rich Bowen <[email protected]> wrote:

> 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