Paged lists: 25 rows, then a clearly marked next page

Marty (2026-09-19): once a membership gets large, My line and the other lists scroll forever.
One helper, pageList(), pages what each pane already renders: rows past the current page are
hidden and a solid-mint control bar (row range, Previous/Next 25, page count) goes under the
list. Row markup and bindings are untouched, so it goes on every growing list at once: My
line per level (the Activity drawer pages with its row, and a chip drill-down opens on the
right page), the Overview roster, the Campaigns table and its credit ledger, each Pipeline
column, and the Messages thread list. Asset tags bumped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-19 06:02:28 -05:00
parent 70c4053201
commit 5ebe890760
3 changed files with 55 additions and 2 deletions
+44
View File
@@ -225,6 +225,43 @@
}
// ── overview v3 charts: hand-rolled SVG/CSS, real data only ──
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
// Paged lists (Marty, 2026-09-19): 25 rows, then a clearly marked next page, on every list
// that grows with the membership. Works on what is already rendered: rows past the page are
// hidden and a control bar goes under the list, so a row's buttons and bindings never change.
// key remembers the page across re-renders of the same list
// host the element the bar is appended to (a wrapper, never a <table>)
// rowSel the rows, queried inside host
// opts per (default 25); tail: a selector for a drawer that follows its row and pages
// with it; focus: a row predicate, opens on whichever page holds that row
const PAGE_SIZE = 25;
const PAGE_AT = {};
function pageList(key, host, rowSel, opts) {
if (!host) return; opts = opts || {}; const per = opts.per || PAGE_SIZE;
const rows = [...host.querySelectorAll(rowSel)];
const old = host.querySelector(':scope > .pgr'); if (old) old.remove();
if (rows.length <= per) { rows.forEach(r => { r.hidden = false; }); return; }
const pages = Math.ceil(rows.length / per);
let p = PAGE_AT[key] || 1;
if (opts.focus) { const i = rows.findIndex(opts.focus); if (i >= 0) p = Math.floor(i / per) + 1; }
p = Math.min(Math.max(1, p), pages); PAGE_AT[key] = p;
const start = (p - 1) * per;
rows.forEach((r, i) => {
const on = i >= start && i < start + per; r.hidden = !on;
if (opts.tail) { const t = r.nextElementSibling; if (t && t.matches(opts.tail) && !on && !t.hidden) { t.hidden = true; const b = r.querySelector('[aria-expanded]'); if (b) { b.setAttribute('aria-expanded', 'false'); b.textContent = 'Activity \u25BE'; } } }
});
const bar = document.createElement('div'); bar.className = 'pgr';
bar.innerHTML = '<span class="pgr-n">Showing <b>' + (start + 1) + '\u2013' + Math.min(start + per, rows.length) + '</b> of ' + rows.length + '</span>'
+ '<span class="pgr-b"><button type="button" class="pgr-btn" data-go="' + (p - 1) + '"' + (p <= 1 ? ' disabled' : '') + '>\u2190 Previous ' + per + '</button>'
+ '<span class="pgr-pg">Page ' + p + ' of ' + pages + '</span>'
+ '<button type="button" class="pgr-btn" data-go="' + (p + 1) + '"' + (p >= pages ? ' disabled' : '') + '>Next ' + per + ' \u2192</button></span>';
bar.querySelectorAll('[data-go]').forEach(b => b.addEventListener('click', () => {
PAGE_AT[key] = Number(b.dataset.go);
pageList(key, host, rowSel, Object.assign({}, opts, { focus: null }));
host.scrollIntoView({ behavior: 'smooth', block: 'start' });
}));
host.appendChild(bar);
}
const CH = { mint: '#43e8c3', cyan: '#54ccff', violet: '#9d7dff', amber: '#ffb238', track: 'rgba(139,166,156,.18)' };
function bars(el, xel, data) { // data: [{v,label,tip,alt}]
if (!el) return;
@@ -564,6 +601,7 @@
+ '<td>' + new Date(r.joined).toLocaleDateString() + '</td>'
+ '<td><span class="badge' + (r.status === 'joined free' ? ' amber' : '') + '">' + r.status + '</span></td></tr>').join('');
wrap.appendChild(t);
pageList('roster', wrap, 'table.roster tr');
}
// overview recent-activity widget: chain events + line joins, newest first
try {
@@ -831,6 +869,7 @@
+ r.items.map(i => '<tr><td class="muted small" style="white-space:nowrap">' + when(i.ts) + '</td><td class="num" style="white-space:nowrap"><b style="color:' + (i.delta > 0 ? '#3ecf7a' : 'inherit') + '">' + (i.delta > 0 ? '+' : '') + i.delta.toLocaleString() + '</b></td><td>' + esc(i.note) + '</td></tr>').join('')
+ '</tbody></table></div>';
el.appendChild(box);
pageList('credits', box, 'tbody tr');
} catch (e) {}
}
async function loadCampaigns() {
@@ -883,6 +922,7 @@
: ' <button class="btn small sec" data-topup="' + c.id + '">Buy more views</button>')
+ '</td></tr>').join('') + '</tbody></table>';
el.appendChild(tbl);
pageList('camps', tbl, 'tbody tr');
const note = document.createElement('p'); note.className = 'muted small';
note.textContent = 'Credits you put into a campaign are set aside the moment you start it, so your Available number drops right away and the campaign spends from its own budget. Featured links and verified-visit packs are paid in full up front; a featured run is sold by the day, so use Extend run to keep it going. Whatever a campaign has not spent comes back to Available when it ends.';
el.appendChild(note);
@@ -1483,6 +1523,7 @@
$('pipeDue').innerHTML = r.due.length ? '<div class="pipe-due">' + r.due.map(c => pipeCardHtml(c, true)).join('') + '</div>' : '<p class="muted small">Nothing due. Set a follow-up date on any card and it shows up here.</p>';
$('pipeSummary').textContent = r.counts.total ? r.counts.total + ' people on your board' + (r.counts.stalled ? ', ' + r.counts.stalled + ' stalled' : '') + '. Cards move on their own when something happens on the ledger; open one to add a note, a follow-up date or a tag.' : 'Nobody on your board yet. Add prospects on My line, and everyone who joins through your link appears here on their own.';
$('pipeBoard').innerHTML = r.columns.map(col => '<div class="pipe-col"><h4>' + esc(col.label) + '<span>' + col.cards.length + '</span></h4><p class="hint">' + esc(col.hint) + '</p>' + col.cards.map(c => pipeCardHtml(c, false)).join('') + '</div>').join('');
$('pipeBoard').querySelectorAll('.pipe-col').forEach((col, i) => pageList('pipe-' + i, col, ':scope > [data-pk]'));
$('pipeLive').querySelectorAll('[data-pk]').forEach(el => {
el.addEventListener('click', () => openPipeCard(el.dataset.pk));
el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPipeCard(el.dataset.pk); } });
@@ -1599,6 +1640,8 @@
el.querySelectorAll('[data-cemail]').forEach(b =>
b.addEventListener('click', () => openConvo(b.dataset.cemail, b.dataset.cname)));
el.querySelectorAll('[data-act]').forEach(b => b.addEventListener('click', () => toggleLineActivity(b)));
const focusRef = LINE_FOCUS_REF ? LINE_FOCUS_REF.replace(/"/g, '') : null;
el.querySelectorAll('.lin-lvl').forEach((lv, i) => pageList('line-' + i, lv, ':scope > .lin-row', { tail: '.lin-act', focus: focusRef ? row => !!row.querySelector('[data-act="' + focusRef + '"]') : null }));
if (LINE_FOCUS_REF) {
const ref = LINE_FOCUS_REF; LINE_FOCUS_REF = null;
const b = el.querySelector('[data-act="' + ref.replace(/"/g, '') + '"]');
@@ -2657,6 +2700,7 @@
+ (t.unread ? '<span class="ct-un">' + t.unread + '</span>' : '') + '</div>').join('');
$('chatThreads').querySelectorAll('.chat-thread').forEach(el =>
el.addEventListener('click', () => openConvo(el.dataset.email, el.dataset.name)));
pageList('chat', $('chatThreads'), '.chat-thread');
} catch (e) { $('chatThreads').innerHTML = '<div class="chat-empty">Could not load messages.</div>'; }
}