[object Object]

← back to Rentv Sheet Enrich

Live Console: reflect enriched (green) cells — /api/format reads Sheets cell backgroundColor (14236 green in master), UI tints enriched cells + legend chip; newly-edited cells mark green live

50ef157c22762255c88a0be809bb259be990c376 · 2026-08-13 12:51:20 -0700 · Steve Abrams

Files touched

Diff

commit 50ef157c22762255c88a0be809bb259be990c376
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Aug 13 12:51:20 2026 -0700

    Live Console: reflect enriched (green) cells — /api/format reads Sheets cell backgroundColor (14236 green in master), UI tints enriched cells + legend chip; newly-edited cells mark green live
---
 live_server.py         | 33 +++++++++++++++++++++++++++++++++
 public_live/index.html | 41 ++++++++++++++++++++++++++++++++++-------
 2 files changed, 67 insertions(+), 7 deletions(-)

diff --git a/live_server.py b/live_server.py
index 19c0aff..042e421 100644
--- a/live_server.py
+++ b/live_server.py
@@ -45,6 +45,37 @@ def tab(gid):
     body = [[(r[i] if i < len(r) else "") for i in range(w)] for r in body]
     return {"gid": gid, "title": title, "headers": headers, "rows": body}
 
+def green_map(gid):
+    """Return sparse list of 'r,c' (0-based over DATA rows) whose cell background is the
+    light-green auto-fill color — so the UI can mark enriched (added) vs original cells."""
+    gid = int(gid)
+    title = {s["properties"]["sheetId"]: s["properties"]["title"]
+             for s in lib.get_meta(tok())["sheets"]}[gid]
+    rng = urllib.parse.quote("'" + title.replace("'", "''") + "'", safe="")
+    url = (f"{lib.API}/{lib.SID}?ranges={rng}&includeGridData=true"
+           "&fields=sheets.data.rowData.values.effectiveFormat.backgroundColor")
+    res = lib._req("GET", url, tok())
+    G = lib.GREEN
+    def is_green(bg):
+        if not bg:
+            return False
+        return (abs(bg.get("red", 1) - G["red"]) < 0.06 and
+                abs(bg.get("green", 1) - G["green"]) < 0.06 and
+                abs(bg.get("blue", 1) - G["blue"]) < 0.06)
+    green = []
+    try:
+        rowData = res["sheets"][0]["data"][0].get("rowData", [])
+    except Exception:
+        rowData = []
+    for ri, row in enumerate(rowData):
+        if ri == 0:  # header row -> data row index is ri-1
+            continue
+        for ci, cell in enumerate(row.get("values", []) or []):
+            bg = (cell.get("effectiveFormat") or {}).get("backgroundColor")
+            if is_green(bg):
+                green.append(f"{ri-1},{ci}")
+    return {"gid": gid, "green": green}
+
 def write_cell(gid, row0, col0, value, formula=False):
     # row0 is 0-based over the DATA rows shown in the UI; +1 for the header row.
     cell = {"row0": int(row0) + 1, "col0": int(col0), "value": value}
@@ -115,6 +146,8 @@ class H(BaseHTTPRequestHandler):
                 self._json(meta())
             elif path == "/api/tab":
                 self._json(tab(q.get("gid", "0")))
+            elif path == "/api/format":
+                self._json(green_map(q.get("gid", "0")))
             elif "/../" not in path and (path.startswith("/nav-agent/") or path.endswith((".css", ".js"))):
                 fp = os.path.join(HERE, "public_live", path.lstrip("/"))
                 if os.path.isfile(fp):
diff --git a/public_live/index.html b/public_live/index.html
index 9bb2f53..57b2d84 100644
--- a/public_live/index.html
+++ b/public_live/index.html
@@ -40,6 +40,11 @@
   td[contenteditable]{outline:none}
   td[contenteditable]:focus{background:#0d2b3f;box-shadow:inset 0 0 0 2px var(--accent)}
   td.saved{background:var(--green);color:var(--greenink);transition:background .2s}
+  td.enriched{box-shadow:inset 3px 0 0 #7bc043;background:rgba(123,192,67,.10)}
+  td.enriched:focus{box-shadow:inset 0 0 0 2px var(--accent)}
+  .card.enriched-card{box-shadow:inset 3px 0 0 #7bc043}
+  .legend{display:inline-flex;align-items:center;gap:6px;color:var(--dim);font-size:12px}
+  .legend .sw{width:12px;height:12px;border-radius:3px;background:rgba(123,192,67,.25);box-shadow:inset 3px 0 0 #7bc043}
   td a{color:var(--accent);text-decoration:none}
   .rowbtn{background:var(--accent);color:#04121f;border:0;border-radius:6px;padding:3px 9px;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap}
   .namecell{font-weight:600}
@@ -89,6 +94,7 @@
     <button data-v="list">☰ List</button>
     <button data-v="grid">▦ Grid</button>
   </div>
+  <span class="legend" title="Cells filled by enrichment (or edited here) are light-green in the sheet"><span class="sw"></span>enriched</span>
   <span class="grow"></span>
   <span class="sync"><span class="dot" id="dot"></span><span id="syncmsg">connecting…</span></span>
   <button id="refresh" title="Pull latest now">↻ Refresh</button>
@@ -155,6 +161,7 @@ const LS = { get:(k,d)=>{try{return JSON.parse(localStorage.getItem('rentv.'+k))
              set:(k,v)=>localStorage.setItem('rentv.'+k,JSON.stringify(v)) };
 let DATA={gid:0,title:'',headers:[],rows:[]}, ROLE={}, VIEW=LS.get('view','table'), GID=LS.get('gid',null);
 let editing=false, lastPull=0, curRow=null, curChan='email';
+let GREENSET=new Set();  // "r,c" cells that are light-green (enriched/added) in the sheet
 
 // ---- field role detection (map header labels -> semantic roles) --------------
 function findCol(preds){ for(const p of preds){ const i=DATA.headers.findIndex(h=>p.test(h)); if(i>=0) return i; } return -1; }
@@ -205,6 +212,24 @@ async function pull(force){
     if(changed) render();
   }catch(e){ setSync('err',e.message); }
 }
+async function pullFormat(){
+  // heavy (includeGridData) — only on tab-load / manual refresh, never on the 12s poll
+  try{
+    const d=await (await fetch('/api/format?gid='+GID)).json();
+    if(d && d.green){ GREENSET=new Set(d.green); applyGreen(); }
+  }catch(e){ /* non-fatal — enrichment tint is cosmetic */ }
+}
+function applyGreen(){
+  document.querySelectorAll('td[data-r]').forEach(td=>{
+    td.classList.toggle('enriched', GREENSET.has(td.dataset.r+','+td.dataset.c));
+  });
+  // card mode: flag a card if any of its cells are enriched
+  document.querySelectorAll('.card[data-r]').forEach(c=>{
+    let any=false; const r=c.dataset.r;
+    GREENSET.forEach(k=>{ if(k.split(',')[0]===r) any=true; });
+    c.classList.toggle('enriched-card', any);
+  });
+}
 function setSync(state,msg){
   const dot=$('#dot');
   dot.className='dot'+(state==='err'?' err':state==='stale'?' stale':'');
@@ -222,7 +247,7 @@ function render(){
   const capped = DATA.rows.length>MAXROWS;
   const banner = capped ? '<div class="empty" style="padding:10px;color:#d29922">Showing first '+MAXROWS+' of '+DATA.rows.length+' rows — use ⚙ search/filter (bottom-left) to narrow, or pick a smaller tab.</div>' : '';
   wrap.innerHTML = banner + (VIEW==='table' ? tableHTML() : cardsHTML(VIEW));
-  wireGrid();
+  wireGrid(); applyGreen();
   // let Nav Agent (re)model the new grid
   if(window.NavAgent && window.NavAgent.mount){ try{ window.NavAgent.mount({target: VIEW==='table'?'#grid':'#cards', title:DATA.title}); }catch(e){} }
 }
@@ -232,9 +257,10 @@ function tableHTML(){
   DATA.rows.slice(0,MAXROWS).forEach((r,ri)=>{
     h+='<tr><td><button class="rowbtn" data-r="'+ri+'">✍ Draft</button></td>';
     r.forEach((c,ci)=>{
-      const isName=ci===ROLE.name;
+      const isName=ci===ROLE.name, gr=GREENSET.has(ri+','+ci);
+      const cls=[isName?'namecell':'', gr?'enriched':''].filter(Boolean).join(' ');
       h+='<td data-field="'+esc(DATA.headers[ci]||'')+'" data-r="'+ri+'" data-c="'+ci+'" contenteditable="true"'+
-         (isName?' class="namecell"':'')+'>'+cellRender(c)+'</td>';
+         (cls?' class="'+cls+'"':'')+'>'+cellRender(c)+'</td>';
     });
     h+='</tr>';
   });
@@ -277,7 +303,8 @@ async function saveCell(td){
   try{
     const res=await (await fetch('/api/write',{method:'POST',headers:{'Content-Type':'application/json'},
       body:JSON.stringify({gid:GID,row0:r,col0:c,value:val})})).json();
-    if(res.ok){ DATA.rows[r][c]=val; td.classList.add('saved'); setTimeout(()=>td.classList.remove('saved'),1200); }
+    if(res.ok){ DATA.rows[r][c]=val; GREENSET.add(r+','+c); td.classList.add('saved');
+      setTimeout(()=>{td.classList.remove('saved'); td.classList.add('enriched');},1200); }
     else throw new Error(res.error||'write failed');
   }catch(e){ td.textContent=td._orig||''; setSync('err',e.message); }
 }
@@ -353,9 +380,9 @@ function doGo(){
 }
 
 // ---- events ------------------------------------------------------------------
-$('#tab').onchange=e=>{ GID=+e.target.value; LS.set('gid',GID); pull(true); };
+$('#tab').onchange=e=>{ GID=+e.target.value; LS.set('gid',GID); GREENSET=new Set(); pull(true).then(pullFormat); };
 document.querySelectorAll('#viewseg button').forEach(b=> b.onclick=()=>{ VIEW=b.dataset.v; LS.set('view',VIEW); render(); });
-$('#refresh').onclick=()=>pull(true);
+$('#refresh').onclick=()=>pull(true).then(pullFormat);
 $('#dclose').onclick=closeDraft; $('#scrim').onclick=closeDraft;
 document.querySelectorAll('#chanseg button').forEach(b=> b.onclick=()=>{ if(b.classList.contains('chdis'))return; curChan=b.dataset.c; syncChanUI(); regen(); });
 $('#purpose').onchange=regen; $('#tone').onchange=regen; $('#regen').onclick=regen;
@@ -364,7 +391,7 @@ $('#act-copy').onclick=()=>{ navigator.clipboard?.writeText(($('#d-subject').val
 document.addEventListener('keydown',e=>{ if(e.key==='Escape')closeDraft(); });
 
 // ---- boot --------------------------------------------------------------------
-(async()=>{ await loadMeta(); await pull(true); setInterval(()=>pull(false),12000); })();
+(async()=>{ await loadMeta(); await pull(true); pullFormat(); setInterval(()=>pull(false),12000); })();
 </script>
 </body>
 </html>

← 45a7dba RENTV Live Console: two-way live Sheet viewer (pull+green wr  ·  back to Rentv Sheet Enrich  ·  auto-data-snapshot: 2026-08-13T12:57:05 (1 data files) — dat 6916f76 →