[object Object]

← 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 →