← back to Ventura Corridor
iter 66: /pitches.html multi-select checkboxes for surgical bulk actions — header gets a 'select all visible' master checkbox (with indeterminate state when partial); each row gets a checkbox cell at left; SELECTED Set tracks pitch ids in client state; clicking a row checkbox no longer expands the row (event.stopPropagation + handler-skip); bulkApply now uses SELECTED if non-empty, else falls back to 'all visible'; mass-toolbar gets Select-all-visible + Clear buttons + dynamic label that switches between 'applies to N selected' and 'applies to all N visible'; selected rows highlight with metal-tinted bg (.tr.sel); SELECTED clears after a successful bulk update
45573470be9059254741599b413c24473def9eea · 2026-05-06 15:03:27 -0700 · SteveStudio2
Files touched
Diff
commit 45573470be9059254741599b413c24473def9eea
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Wed May 6 15:03:27 2026 -0700
iter 66: /pitches.html multi-select checkboxes for surgical bulk actions — header gets a 'select all visible' master checkbox (with indeterminate state when partial); each row gets a checkbox cell at left; SELECTED Set tracks pitch ids in client state; clicking a row checkbox no longer expands the row (event.stopPropagation + handler-skip); bulkApply now uses SELECTED if non-empty, else falls back to 'all visible'; mass-toolbar gets Select-all-visible + Clear buttons + dynamic label that switches between 'applies to N selected' and 'applies to all N visible'; selected rows highlight with metal-tinted bg (.tr.sel); SELECTED clears after a successful bulk update
---
public/pitches.html | 77 +++++++++++++++++++++++++++++++++++++++++++++--------
1 file changed, 66 insertions(+), 11 deletions(-)
diff --git a/public/pitches.html b/public/pitches.html
index d04e543..8df5386 100644
--- a/public/pitches.html
+++ b/public/pitches.html
@@ -50,6 +50,8 @@
th { font-size: 9px; letter-spacing: .26em; text-transform: uppercase; color: var(--ink-mute); font-weight: 400; background: var(--noir); position: sticky; top: 60px; z-index: 1; }
tr:hover { background: var(--noir-rise); cursor: pointer; }
tr.expanded { background: var(--noir-rise); }
+ tr.sel { background: rgba(212,182,131,0.06); }
+ tr.sel.expanded { background: rgba(212,182,131,0.1); }
.nm { font-weight: 400; color: var(--ink); }
.addr { font-family: var(--mono); font-size: 9px; color: var(--ink-mute); letter-spacing: .08em; }
.ptag { font-size: 8px; letter-spacing: .22em; text-transform: uppercase; padding: 2px 5px; border: 1px solid var(--rule); color: var(--ink-mute); display: inline-block; }
@@ -197,8 +199,10 @@
</div>
<div id="mass-toolbar" style="padding:14px 24px;border-bottom:1px solid var(--rule);background:rgba(184,153,104,0.04);display:flex;gap:12px;flex-wrap:wrap;align-items:center;font-size:11px">
- <span style="font-family:var(--serif);font-style:italic;font-size:16px;color:var(--metal-glow)">Mass actions on filtered cohort</span>
- <span style="color:var(--ink-mute);letter-spacing:.18em;text-transform:uppercase;font-size:9px">applies to all <b id="mass-count" style="color:var(--metal)">—</b> visible</span>
+ <span style="font-family:var(--serif);font-style:italic;font-size:16px;color:var(--metal-glow)">Mass actions</span>
+ <span style="color:var(--ink-mute);letter-spacing:.18em;text-transform:uppercase;font-size:9px" id="mass-mode-label">applies to all <b id="mass-count" style="color:var(--metal)">—</b> visible</span>
+ <button onclick="selectAllVisible()" style="background:transparent;border:1px solid var(--rule);color:var(--ink-mute);padding:4px 9px;font-size:9px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer;font-family:var(--sans)">Select all visible</button>
+ <button onclick="clearSelection()" style="background:transparent;border:1px solid var(--rule);color:var(--ink-mute);padding:4px 9px;font-size:9px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer;font-family:var(--sans)">Clear</button>
<span style="flex:1"></span>
<select id="mass-field" style="background:var(--noir);border:1px solid var(--rule);color:var(--ink);padding:6px 10px;font-size:11px">
<option value="status">Set status</option>
@@ -214,6 +218,7 @@
<table id="grid">
<thead>
<tr>
+ <th><input type="checkbox" id="cb-all" onchange="toggleAllVisible(this.checked)" style="accent-color:var(--metal);cursor:pointer"></th>
<th>★</th><th>Status</th><th>Vertical</th><th>Name</th><th>Address</th><th>City</th><th>Ready</th>
</tr>
</thead>
@@ -223,6 +228,7 @@
<script>
let DATA = [];
+const SELECTED = new Set(); // pitch ids selected for surgical bulk action
let EXPANDED = null;
function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }
@@ -292,8 +298,10 @@ function render() {
].join('');
const filled = [r.contact_name, r.email, r.phone, r.observation, r.body].filter(Boolean).length;
const dotColor = filled === 5 ? 'var(--metal-glow)' : filled >= 3 ? 'var(--metal)' : 'var(--ink-mute)';
+ const checked = SELECTED.has(r.id) ? 'checked' : '';
const main = `
- <tr class="${isExp ? 'expanded' : ''}" data-id="${r.id}">
+ <tr class="${isExp ? 'expanded' : ''} ${SELECTED.has(r.id) ? 'sel' : ''}" data-id="${r.id}">
+ <td style="text-align:center" data-cb="1"><input type="checkbox" ${checked} data-cb-id="${r.id}" onclick="event.stopPropagation()" style="accent-color:var(--metal);cursor:pointer"></td>
<td style="color:${proxColor};font-size:14px;text-align:center" title="${r.dw_proximity || ''}">${proxIcon}</td>
<td><span class="stag s-${r.status}">${r.status}</span></td>
<td><span class="ptag t-${r.pitch_type}">${r.pitch_type}</span></td>
@@ -303,7 +311,7 @@ function render() {
<td style="color:${dotColor};font-family:var(--mono);font-size:13px;letter-spacing:0.1em" title="contact·email·phone·observation·body — ${filled}/5">${dots}</td>
</tr>`;
const detail = isExp ? `
- <tr><td colspan="7">
+ <tr><td colspan="8">
<div class="detail">
<div class="why">${escapeHtml(r.why_dw_fits || '')}</div>
<div class="links">
@@ -378,6 +386,8 @@ document.getElementById('rows').addEventListener('click', (e) => {
patchPitch(t.dataset.id, { status: t.dataset.status });
return;
}
+ // Checkbox handles its own click (stopPropagation) but defend anyway
+ if (t.tagName === 'INPUT' && t.type === 'checkbox' && t.dataset.cbId) return;
if (t.tagName === 'TEXTAREA' || t.tagName === 'A') return;
const tr = t.closest('tr[data-id]');
if (!tr) return;
@@ -387,6 +397,15 @@ document.getElementById('rows').addEventListener('click', (e) => {
});
document.getElementById('rows').addEventListener('change', (e) => {
+ // Selection checkbox
+ if (e.target.tagName === 'INPUT' && e.target.type === 'checkbox' && e.target.dataset.cbId) {
+ const id = parseInt(e.target.dataset.cbId, 10);
+ if (e.target.checked) SELECTED.add(id); else SELECTED.delete(id);
+ e.target.closest('tr').classList.toggle('sel', e.target.checked);
+ updateMassCount();
+ return;
+ }
+ // Field edit
if ((e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') && e.target.dataset.id) {
const f = e.target.dataset.field;
const v = e.target.value;
@@ -425,23 +444,58 @@ document.getElementById('mass-field').addEventListener('change', refreshMassValu
const _origRender = render;
window.render = function() { _origRender(); updateMassCount(); };
function updateMassCount() {
- // Re-derive the same filter logic by counting from rendered tbody
const visibleIds = Array.from(document.querySelectorAll('#rows tr[data-id]')).map(tr => parseInt(tr.dataset.id, 10));
- document.getElementById('mass-count').textContent = visibleIds.length.toLocaleString();
+ const selN = SELECTED.size;
+ const lbl = document.getElementById('mass-mode-label');
+ if (selN > 0) {
+ lbl.innerHTML = `applies to <b style="color:var(--metal-glow)">${selN}</b> selected · <span style="color:var(--ink-mute);text-transform:none;letter-spacing:0">click checkbox to deselect</span>`;
+ } else {
+ lbl.innerHTML = `applies to all <b id="mass-count" style="color:var(--metal)">${visibleIds.length.toLocaleString()}</b> visible · <span style="color:var(--ink-mute);text-transform:none;letter-spacing:0">tick rows for surgical mode</span>`;
+ }
+ // Update header "select all" checkbox state
+ const cbAll = document.getElementById('cb-all');
+ if (cbAll) {
+ const allChecked = visibleIds.length > 0 && visibleIds.every(id => SELECTED.has(id));
+ const someChecked = visibleIds.some(id => SELECTED.has(id));
+ cbAll.checked = allChecked;
+ cbAll.indeterminate = !allChecked && someChecked;
+ }
}
setInterval(updateMassCount, 500);
+function selectAllVisible() {
+ const visibleIds = Array.from(document.querySelectorAll('#rows tr[data-id]')).map(tr => parseInt(tr.dataset.id, 10));
+ visibleIds.forEach(id => SELECTED.add(id));
+ render();
+ updateMassCount();
+}
+function clearSelection() {
+ SELECTED.clear();
+ render();
+ updateMassCount();
+}
+function toggleAllVisible(check) {
+ if (check) selectAllVisible();
+ else clearSelection();
+}
+
async function bulkApply(dryRun) {
- const ids = Array.from(document.querySelectorAll('#rows tr[data-id]')).map(tr => parseInt(tr.dataset.id, 10));
- if (!ids.length) { alert('No visible rows.'); return; }
+ // Use SELECTED if non-empty, else fall back to all visible
+ let ids = Array.from(SELECTED);
+ let scope = 'selected';
+ if (ids.length === 0) {
+ ids = Array.from(document.querySelectorAll('#rows tr[data-id]')).map(tr => parseInt(tr.dataset.id, 10));
+ scope = 'visible';
+ }
+ if (!ids.length) { alert('No rows selected and none visible.'); return; }
const field = document.getElementById('mass-field').value;
let value = document.getElementById('mass-value').value;
if (field === 'priority') value = parseInt(value, 10);
if (dryRun) {
- alert(`Preview: would set ${field}=${value} on ${ids.length} pitches. No changes made.`);
+ alert(`Preview: would set ${field}=${value} on ${ids.length} ${scope} pitch${ids.length === 1 ? '' : 'es'}. No changes made.`);
return;
}
- if (!confirm(`Set ${field}=${value} on ALL ${ids.length} visible pitches? This is a one-shot bulk update.`)) return;
+ if (!confirm(`Set ${field}=${value} on ${ids.length} ${scope} pitch${ids.length === 1 ? '' : 'es'}?`)) return;
try {
const r = await fetch('/api/pitches/bulk', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -450,11 +504,12 @@ async function bulkApply(dryRun) {
if (!r.ok) throw new Error(await r.text());
const d = await r.json();
alert(`Updated ${d.updated} of ${d.ids} pitches.`);
- // Reload data
+ SELECTED.clear();
const fresh = await fetch('/api/pitches').then(r => r.json());
DATA = fresh.rows || [];
updateStats();
render();
+ updateMassCount();
} catch (e) {
alert('Bulk update failed: ' + e.message);
}
← 10506e0 iter 65: /api/buildings.csv export + activity-feed event-typ
·
back to Ventura Corridor
·
iter 67: 'Walk here next' priority strip on /today.html — to eb0c998 →