← 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
M viewer/public/index.html
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 →