[object Object]

← 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

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 →