[object Object]

← back to La Socrata Ingester

Viewer: CSV export of current filtered view + fix blank Issued date column

d5d5fec305a3dc985e589e8908a3536b6f844359 · 2026-08-12 11:07:56 -0700 · Steve Abrams

- ⬇ CSV button exports the current filtered+sorted permits as CSV (client-side,
  pages /api/permits at 200/req up to a 5,000-row cap, UTF-8 BOM, quoted escaping,
  filter-named file). Surfaces an explicit notice when the filtered total exceeds
  the cap (no silent truncation).
- Fix real bug: API sends the date as issue_date but List/Grid column key is
  'issued' -> the Issued date rendered blank everywhere. Normalize issued from
  issue_date on load (List, Grid, and CSV). Verified: column now shows the date.

Author: Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit d5d5fec305a3dc985e589e8908a3536b6f844359
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Aug 12 11:07:56 2026 -0700

    Viewer: CSV export of current filtered view + fix blank Issued date column
    
    - ⬇ CSV button exports the current filtered+sorted permits as CSV (client-side,
      pages /api/permits at 200/req up to a 5,000-row cap, UTF-8 BOM, quoted escaping,
      filter-named file). Surfaces an explicit notice when the filtered total exceeds
      the cap (no silent truncation).
    - Fix real bug: API sends the date as issue_date but List/Grid column key is
      'issued' -> the Issued date rendered blank everywhere. Normalize issued from
      issue_date on load (List, Grid, and CSV). Verified: column now shows the date.
    
    Author: Steve Abrams
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 viewer/public/index.html | 33 +++++++++++++++++++++++++++++++++
 1 file changed, 33 insertions(+)

diff --git a/viewer/public/index.html b/viewer/public/index.html
index 7a5876f..f65b1a4 100644
--- a/viewer/public/index.html
+++ b/viewer/public/index.html
@@ -123,6 +123,7 @@
       <option value="work_type">Work type</option>
     </select></span>
     <span class="ctl" id="densctl">Density <input id="density" type="range" min="26" max="46" value="34"></span>
+    <span class="ctl"><button id="csv" class="loadmore" style="margin:0;padding:5px 12px" title="Download the current filtered + sorted results as a CSV (up to 5,000 rows)">⬇ CSV</button></span>
     <span class="ctl"><button id="copylink" class="loadmore" style="margin:0;padding:5px 12px" title="Copy a shareable link to this exact view (filters + sort + view + color)">🔗 Copy link</button></span>
     <span class="ctl"><button id="reset" class="loadmore" style="margin:0;padding:5px 12px">Reset</button></span>
   </header>
@@ -251,6 +252,7 @@ async function loadFacets(){
 async function loadRows(append=false){
   const limit = state.view==='map' ? 1500 : 100;
   const d = await (await fetch(api('/api/permits?'+qs({page:state.page,limit})))).json();
+  (d.rows||[]).forEach(r=>{ if(r.issued==null) r.issued=r.issue_date; }); // API sends issue_date; the List/Grid/CSV column key is `issued`
   state.total = d.total;
   state.rows = append ? state.rows.concat(d.rows) : d.rows;
   $('#count').textContent = state.total.toLocaleString()+' permits'+(Object.values(state.filters).some(Boolean)?' (filtered)':'');
@@ -389,6 +391,37 @@ $('#more').onclick=$('#more2').onclick=()=>{ state.page++; loadRows(true); };
 let t; $('#q').oninput=e=>{clearTimeout(t);t=setTimeout(()=>{state.filters.q=e.target.value.trim();reload();},280);};
 $('#density').oninput=e=>{document.documentElement.style.setProperty('--rowh',e.target.value+'px');const c=Math.max(2,Math.round((72-e.target.value)/8));document.documentElement.style.setProperty('--cols',c);LS.set('density',e.target.value);};
 $('#reset').onclick=()=>{state.filters={};$('#q').value='';reload();};
+// ---- CSV export of the current filtered + sorted view (client-side, capped) ----
+const CSV_CAP = 5000; // hard ceiling; if the filtered total exceeds this we export the top N and say so
+const CSV_COLS = ['lead_score','issued','address','zip','cd','work_type','building_type','status','project_value','assessed_value','year_built','sqft','use_desc','era','permit_nbr'];
+const CSV_HEAD = ['Lead score','Issued','Address','ZIP','CD','Work type','Building type','Status','Project value','Assessed value','Year built','SqFt','Use','Era','Permit #'];
+const csvCell = v => { if(v==null) return ''; const s=String(v); return /[",\n]/.test(s)?'"'+s.replace(/"/g,'""')+'"':s; };
+$('#csv').onclick=async()=>{
+  const btn=$('#csv'), was=btn.textContent;
+  try{
+    const want = Math.min(state.total||CSV_CAP, CSV_CAP);
+    const rows=[]; let page=1;
+    while(rows.length < want){
+      btn.textContent='… '+rows.length.toLocaleString();
+      const d = await (await fetch(api('/api/permits?'+qs({page,limit:200})))).json();
+      if(!d.rows || !d.rows.length) break;
+      d.rows.forEach(r=>{ if(r.issued==null) r.issued=r.issue_date; }); // match List/Grid: API sends issue_date
+      rows.push(...d.rows);
+      if(d.rows.length < 200) break; // last page
+      page++;
+    }
+    const clip = rows.slice(0, want);
+    const lines = [CSV_HEAD.join(',')].concat(clip.map(r=>CSV_COLS.map(k=>csvCell(r[k])).join(',')));
+    const blob = new Blob([''+lines.join('\r\n')], {type:'text/csv;charset=utf-8;'});
+    const active = Object.entries(state.filters).filter(([,v])=>v).map(([k,v])=>k+'-'+v).join('_').replace(/[^\w-]+/g,'').slice(0,60);
+    const a=document.createElement('a'); a.href=URL.createObjectURL(blob);
+    a.download = 'la-permits'+(active?'_'+active:'')+'.csv'; document.body.appendChild(a); a.click();
+    setTimeout(()=>{URL.revokeObjectURL(a.href);a.remove();},1000);
+    btn.textContent='✓ '+clip.length.toLocaleString();
+    if((state.total||0) > CSV_CAP) alert('Exported the top '+CSV_CAP.toLocaleString()+' rows (by current sort) of '+state.total.toLocaleString()+' filtered permits. Narrow the filters to capture the rest.');
+  }catch(e){ btn.textContent='✕ error'; console.error('CSV export failed',e); }
+  setTimeout(()=>btn.textContent=was,1800);
+};
 $('#copylink').onclick=async()=>{ // URL already mirrors state via syncURL(); copy the live href
   syncURL();
   const btn=$('#copylink'), was=btn.textContent, url=location.href;

← eb45025 rate-limit: prompt for token interactively when none supplie  ·  back to La Socrata Ingester  ·  chore: v0.2.1 (session close — rate-limit script finalize) f56acfa →