← back to La Socrata Ingester
Viewer: loading spinner + empty-state + fetch-error overlay
57d3d5a3895c1a2c83cd1d7d2192791c6e29bd2b · 2026-08-12 11:14:06 -0700 · Steve Abrams
- Loading spinner overlays the results area during every fresh fetch (valuable:
the unfiltered lead_score sort over 1.58M rows takes a couple seconds).
- Empty state when a filter set returns 0 rows: clear message + a Clear-all-filters
button (wired to reset) instead of a blank table.
- Network-error state shows the failure inline instead of a silent blank view.
Author: Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M viewer/public/index.html
Diff
commit 57d3d5a3895c1a2c83cd1d7d2192791c6e29bd2b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Aug 12 11:14:06 2026 -0700
Viewer: loading spinner + empty-state + fetch-error overlay
- Loading spinner overlays the results area during every fresh fetch (valuable:
the unfiltered lead_score sort over 1.58M rows takes a couple seconds).
- Empty state when a filter set returns 0 rows: clear message + a Clear-all-filters
button (wired to reset) instead of a blank table.
- Network-error state shows the failure inline instead of a silent blank view.
Author: Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
viewer/public/index.html | 30 +++++++++++++++++++++++++++++-
1 file changed, 29 insertions(+), 1 deletion(-)
diff --git a/viewer/public/index.html b/viewer/public/index.html
index f65b1a4..d00a083 100644
--- a/viewer/public/index.html
+++ b/viewer/public/index.html
@@ -102,6 +102,18 @@
pre{background:#0f1720;color:#c5e4de;padding:12px;border-radius:8px;overflow:auto;font-size:11px;max-height:280px}
.rawtoggle{cursor:pointer;color:var(--accent);font-size:12px;user-select:none}
.hidden{display:none!important}
+ /* status overlay: loading + empty state */
+ .overlay{position:absolute;inset:0;z-index:60;display:flex;align-items:center;justify-content:center;
+ background:rgba(246,247,249,.72);backdrop-filter:blur(1px)}
+ .overlay .box{text-align:center;color:var(--muted);font-size:13px}
+ .overlay .box .big{font-size:15px;font-weight:650;color:var(--ink);margin-bottom:4px}
+ .overlay .box .sub{margin-bottom:10px}
+ .overlay .box button{border:1px solid var(--teal);background:#fff;color:var(--teal);border-radius:8px;
+ padding:6px 16px;font:inherit;font-weight:600;cursor:pointer}
+ .overlay .box button:hover{background:var(--teal);color:#fff}
+ .spin{width:26px;height:26px;border:3px solid var(--line);border-top-color:var(--teal);border-radius:50%;
+ margin:0 auto 10px;animation:spin .7s linear infinite}
+ @keyframes spin{to{transform:rotate(360deg)}}
</style>
</head>
<body>
@@ -129,6 +141,7 @@
</header>
<aside id="rail"></aside>
<main>
+ <div id="overlay" class="overlay hidden"></div>
<div id="listview">
<table id="tbl"><thead><tr id="head"></tr></thead><tbody id="rows"></tbody></table>
<button class="loadmore" id="more" style="display:none">Load more</button>
@@ -249,13 +262,28 @@ async function loadFacets(){
rail.querySelectorAll('li').forEach(li=>li.onclick=()=>{ const {k,v}=li.dataset; state.filters[k]=String(state.filters[k]||'')===v?'':v; reload(); });
}
+// ---- status overlay (loading + empty state) ----
+const overlay = () => $('#overlay');
+function showLoading(){ overlay().innerHTML='<div class="box"><div class="spin"></div>Loading permits…</div>'; overlay().classList.remove('hidden'); }
+function showEmpty(){ const filtered=Object.values(state.filters).some(Boolean);
+ overlay().innerHTML=`<div class="box"><div class="big">No permits match these filters</div>`+
+ (filtered?`<div class="sub">Try widening or clearing a filter.</div><button id="emptyreset">Clear all filters</button>`:`<div class="sub">Nothing to show.</div>`)+`</div>`;
+ overlay().classList.remove('hidden');
+ const b=$('#emptyreset'); if(b) b.onclick=()=>{ state.filters={}; $('#q').value=''; reload(); };
+}
+function hideOverlay(){ overlay().classList.add('hidden'); }
+
async function loadRows(append=false){
+ if(!append) showLoading();
const limit = state.view==='map' ? 1500 : 100;
- const d = await (await fetch(api('/api/permits?'+qs({page:state.page,limit})))).json();
+ let d;
+ try{ d = await (await fetch(api('/api/permits?'+qs({page:state.page,limit})))).json(); }
+ catch(e){ overlay().innerHTML='<div class="box"><div class="big">Couldn’t load permits</div><div class="sub">'+escapeHtml(e.message||'network error')+'</div></div>'; overlay().classList.remove('hidden'); return; }
(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)':'');
+ if(!append && state.rows.length===0) showEmpty(); else hideOverlay();
render();
const hasMore = state.rows.length < state.total && state.view!=='map';
$('#more').style.display = (state.view==='list'&&hasMore)?'block':'none';
← f56acfa chore: v0.2.1 (session close — rate-limit script finalize)
·
back to La Socrata Ingester
·
auto-data-snapshot: 2026-08-12T11:28:02 (1 data files) — pac 0d560bd →