[object Object]

← back to Rentv Sheet Enrich Refine

Live console: email cells are now clickable mailto links; color-coded Status field (colors pulled from the sheet) + click-to-set status picker that writes the value to the sheet

dfed233d681f82e5beb5be8e5c5bdea5ba0918d5 · 2026-08-13 16:41:20 -0700 · Steve Abrams

- snapshot.py: extract Status(color) value->color map + canonical value list from cell fills
- live_server.py meta(): expose status_field/status_colors/status_values
- UI: colored status chips + picker popover, email mailcell

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

Files touched

Diff

commit dfed233d681f82e5beb5be8e5c5bdea5ba0918d5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Aug 13 16:41:20 2026 -0700

    Live console: email cells are now clickable mailto links; color-coded Status field (colors pulled from the sheet) + click-to-set status picker that writes the value to the sheet
    
    - snapshot.py: extract Status(color) value->color map + canonical value list from cell fills
    - live_server.py meta(): expose status_field/status_colors/status_values
    - UI: colored status chips + picker popover, email mailcell
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 live_server.py         |  3 +++
 public_live/index.html | 64 ++++++++++++++++++++++++++++++++++++++++++++++----
 snapshot.py            | 48 +++++++++++++++++++++++++++++++++++--
 3 files changed, 108 insertions(+), 7 deletions(-)

diff --git a/live_server.py b/live_server.py
index 5d19869..7872d0e 100644
--- a/live_server.py
+++ b/live_server.py
@@ -75,6 +75,9 @@ def refresh_snap():
 
 def meta():
     return {"generated": SNAP["generated"],
+            "status_field": SNAP.get("status_field"),
+            "status_colors": SNAP.get("status_colors", {}),
+            "status_values": SNAP.get("status_values", []),
             "tabs": [{"gid": t["gid"], "title": t["title"], "rows": len(t["rows"])} for t in SNAP["tabs"]]}
 
 def tab(gid):
diff --git a/public_live/index.html b/public_live/index.html
index 32c8e78..de0b794 100644
--- a/public_live/index.html
+++ b/public_live/index.html
@@ -96,13 +96,23 @@
   a.callbtn{color:#16a34a} a.iconbtn.web{color:var(--accent)} a.iconbtn.li{color:#0a66c2}
   .linkval{color:inherit}
   /* whole-cell clickable links: icon on the LEFT (never clipped), entire cell opens popup / dials */
-  td.linkcell,td.callcell{cursor:pointer}
-  td.linkcell .cicon,td.callcell .cicon{display:inline-flex;vertical-align:middle;margin-right:5px}
-  td.linkcell .cicon svg,td.callcell .cicon svg{width:1.05em;height:1.05em;display:block}
+  td.linkcell,td.callcell,td.mailcell{cursor:pointer}
+  td.linkcell .cicon,td.callcell .cicon,td.mailcell .cicon{display:inline-flex;vertical-align:middle;margin-right:5px}
+  td.linkcell .cicon svg,td.callcell .cicon svg,td.mailcell .cicon svg{width:1.05em;height:1.05em;display:block}
   td.linkcell.web .cicon,td.linkcell.web .linkval{color:var(--accent)}
   td.linkcell.li .cicon,td.linkcell.li .linkval{color:var(--linkval-li)}
   td.callcell .cicon{color:#16a34a}
-  td.linkcell:hover,td.callcell:hover{background:var(--linkhover)}
+  td.mailcell .cicon,td.mailcell .linkval{color:var(--accent)}
+  td.linkcell:hover,td.callcell:hover,td.mailcell:hover{background:var(--linkhover)}
+  /* color-coded Status field + click-to-set picker */
+  td.statuscell{cursor:pointer} td.statuscell:hover{background:var(--rowhover)}
+  .statuschip{display:inline-block;max-width:100%;padding:2px 10px;border-radius:12px;font-size:.92em;font-weight:600;line-height:1.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+  td.statuscell.empty{color:var(--dim);font-size:.9em}
+  .statusmenu{position:fixed;z-index:70;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 12px 34px #000a;padding:5px;min-width:230px;max-height:70vh;overflow:auto}
+  .statusmenu .smhead{color:var(--dim);font-size:10px;text-transform:uppercase;letter-spacing:.5px;padding:5px 8px 6px}
+  .smi{display:flex;align-items:center;gap:9px;padding:7px 8px;border-radius:7px;cursor:pointer;font-size:13px}
+  .smi:hover{background:var(--rowhover)} .smi.cur{font-weight:700}
+  .smi .sw{width:16px;height:16px;border-radius:4px;flex:0 0 16px;border:1px solid rgba(0,0,0,.15)} .smi .sl{flex:1} .smi .smck{color:var(--accent)}
   td.linkcell:hover .linkval,td.callcell:hover .linkval{text-decoration:underline}
   /* FIELD LOOKUP: click a field in the left rail -> its full value list opens here in the main area */
   .lkhead{display:flex;align-items:center;gap:10px;padding:8px 10px;position:sticky;top:0;background:var(--panel2);border-bottom:1px solid var(--line);z-index:6}
@@ -194,6 +204,9 @@
 const $=s=>document.querySelector(s), $$=s=>document.querySelectorAll(s);
 const LS={get:(k,d)=>{try{return JSON.parse(localStorage.getItem('rc2.'+k))??d}catch(e){return d}},set:(k,v)=>localStorage.setItem('rc2.'+k,JSON.stringify(v))};
 let DATA={gid:0,title:'',headers:[],rows:[],green:[]}, GREENSET=new Set(), ROLE={}, META=null;
+// color-coded Status field pulled from the spreadsheet (value->color) + the canonical value list
+let STATUS_FIELD=null, STATUS_COLORS={}, STATUS_VALUES=[];
+const STATUS_DEFAULTS={'Active / Contacted':'#cfe2f3','Needs Email / Outreach Pending':'#fff2a8','Prospect / Follow-up':'#d9ead3','In Progress / Left Message':'#fce5cd','On Hold / Inactive':'#e6e6e6','Flag / Priority':'#f4cccc','Section / Category':'#c9daf8'};
 let VIEW=LS.get('view','table'), GID=LS.get('gid',null), editing=false, curRow=null, curChan='email';
 let SELECTED=new Set(LS.get('sheets',[]));   // selected sheet gids (1..all) shown combined
 function curKey(){ return SELECTED.size===1 ? String([...SELECTED][0]) : ('combo.'+[...SELECTED].sort().join('-')); }
@@ -220,6 +233,7 @@ function hrefOf(v){ v=String(v||'').trim();
 // ---- load (from OUR local copy) ---------------------------------------------
 async function loadMeta(){
   META=await (await fetch('/api/meta')).json();
+  STATUS_FIELD=META.status_field||null; STATUS_COLORS=META.status_colors||{}; STATUS_VALUES=META.status_values||[];
   const sel=$('#tab');sel.innerHTML='';
   META.tabs.forEach(t=>{const o=document.createElement('option');o.value=t.gid;o.textContent=`${t.title} (${t.rows})`;sel.appendChild(o)});
   if(GID==null||!META.tabs.find(t=>t.gid==GID)){
@@ -496,15 +510,51 @@ function iconLink(url,kind){
   return `<a class="iconbtn ${kind}" href="${esc(url)}" target="_blank" rel="noopener" contenteditable="false" title="${label}">${svg}</a>`;
 }
 // digits of a cell whose WHOLE value is one phone number (anchored, so a phone inside Notes prose is ignored)
+// a cell whose WHOLE value is one email address -> mailto:
+function emailOf(v){ v=String(v==null?'':v).trim(); return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v)?v:null; }
 function telDigits(v){ v=String(v==null?'':v).trim();
   if(!/^(\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}(\s*(x|ext\.?)\s*\d+)?$/i.test(v))return null;
   const core=v.match(/(\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/)[0];
   let d=core.replace(/\D/g,''); if(d.length===10)d='1'+d; return d; }
 // build one <td>. Website/LinkedIn/phone => whole cell is CLICKABLE (icon on the LEFT so it's never clipped,
 // entire cell opens a popup / dials). Everything else stays contenteditable + expand-on-click.
+// ---- color-coded Status field (colors pulled from the sheet) ----
+function hashColor(s){ let h=0; for(let i=0;i<s.length;i++)h=(h*31+s.charCodeAt(i))>>>0; return `hsl(${h%360} 68% 84%)`; }
+function statusColor(v){ return (STATUS_COLORS&&STATUS_COLORS[v]) || STATUS_DEFAULTS[v] || hashColor(v); }
+function txtOn(color){ const m=color.match(/^#([0-9a-f]{6})$/i); if(!m)return '#111';   // hsl fallbacks are light -> dark text
+  const n=parseInt(m[1],16), r=n>>16, g=(n>>8)&255, b=n&255; return (0.299*r+0.587*g+0.114*b)>150?'#111':'#fff'; }
+function isStatusCol(ci){ return STATUS_FIELD && DATA.headers[ci]===STATUS_FIELD; }
+function openStatusPicker(td){
+  closeStatusPicker();
+  const r=+td.dataset.r, c=+td.dataset.c, cur=(DATA.rows[r]&&DATA.rows[r][c])||'';
+  const vals=(STATUS_VALUES&&STATUS_VALUES.length)?STATUS_VALUES:Object.keys(STATUS_DEFAULTS);
+  const m=document.createElement('div'); m.className='statusmenu'; m.id='statusmenu';
+  m.innerHTML=`<div class="smhead">Set status — writes to the sheet</div>`+vals.map(v=>{const col=statusColor(v);
+      return `<div class="smi${v===cur?' cur':''}" data-v="${esc(v)}"><span class="sw" style="background:${col}"></span><span class="sl">${esc(v)}</span>${v===cur?'<span class="smck">✓</span>':''}</div>`;}).join('')
+    +`<div class="smi smclear" data-v=""><span class="sw" style="background:transparent;border:1px dashed var(--line)"></span><span class="sl">Clear</span></div>`;
+  document.body.appendChild(m);
+  const b=td.getBoundingClientRect(), mw=250;
+  m.style.left=Math.max(6,Math.min(b.left,window.innerWidth-mw-6))+'px';
+  m.style.top=Math.min(b.bottom+4, window.innerHeight-m.offsetHeight-10)+'px';
+  m.querySelectorAll('.smi').forEach(el=>el.onclick=async()=>{ const v=el.dataset.v; closeStatusPicker();
+    try{ const res=await postWrite(r,c,v); if(!res.ok)throw new Error(res.error||'write failed');
+      DATA.rows[r][c]=v; if(v)GREENSET.add(r+','+c); render(); setSync('ok'); }
+    catch(err){ setSync('err'); alert('Status write failed: '+err.message); } });
+  setTimeout(()=>document.addEventListener('mousedown',closeStatusPickerOutside),0);
+}
+function closeStatusPicker(){const m=$('#statusmenu');if(m)m.remove();document.removeEventListener('mousedown',closeStatusPickerOutside);}
+function closeStatusPickerOutside(e){ if(!e.target.closest('#statusmenu')&&!e.target.closest('.statuscell'))closeStatusPicker(); }
 function cellTD(ri,ci,val,isN,gr){
   const cls=[isN?'namecell':'',gr?'enriched':''].filter(Boolean).join(' ');
-  const s=String(val==null?'':val), u=hrefOf(s);
+  const s=String(val==null?'':val);
+  if(isStatusCol(ci)){                                          // color-coded status chip + click-to-set picker
+    if(!s) return `<td data-r="${ri}" data-c="${ci}" contenteditable="false" class="statuscell empty ${cls}" title="Click to set status">+ set status</td>`;
+    const col=statusColor(s);
+    return `<td data-r="${ri}" data-c="${ci}" contenteditable="false" class="statuscell ${cls}" title="Click to change status"><span class="statuschip" style="background:${col};color:${txtOn(col)}">${esc(s)}</span></td>`;
+  }
+  const em=emailOf(s);
+  if(em){ return `<td data-r="${ri}" data-c="${ci}" contenteditable="false" data-mail="${esc(em)}" title="Email ${esc(em)}" class="mailcell ${cls}"><span class="cicon">${ICON.mail}</span><span class="linkval">${esc(em)}</span></td>`; }
+  const u=hrefOf(s);
   if(u){ const li=/linkedin\.com/i.test(u), disp=s.replace(/^https?:\/\/(www\.)?/i,'').replace(/\/+$/,'');
     return `<td data-r="${ri}" data-c="${ci}" contenteditable="false" data-href="${esc(u)}" title="Open ${esc(u)}" class="linkcell ${li?'li':'web'} ${cls}"><span class="cicon">${li?ICON.li:ICON.web}</span><span class="linkval">${esc(disp)}</span></td>`; }
   const d=telDigits(s);
@@ -533,10 +583,14 @@ function wireGrid(){
   const gridEl=$('#grid');
   if(gridEl){
     gridEl.addEventListener('click',e=>{
+      const st=e.target.closest('td.statuscell');                          // Status -> color-coded picker (writes to sheet)
+      if(st){ e.preventDefault(); openStatusPicker(st); return; }
       const lc=e.target.closest('td.linkcell');                            // Website / LinkedIn -> popup window
       if(lc){ e.preventDefault(); window.open(lc.dataset.href,'linkpop','popup=yes,noopener,width=1100,height=850,left=140,top=90'); return; }
       const cc=e.target.closest('td.callcell');                            // phone -> dial
       if(cc){ location.href='tel:'+cc.dataset.tel; return; }
+      const mc=e.target.closest('td.mailcell');                            // email -> mail client
+      if(mc){ location.href='mailto:'+mc.dataset.mail; return; }
     });
   }
   $$('.card').forEach(c=>c.onclick=()=>openDraft(+c.dataset.r));
diff --git a/snapshot.py b/snapshot.py
index 09dcc86..d6675e1 100644
--- a/snapshot.py
+++ b/snapshot.py
@@ -36,6 +36,14 @@ def build(tok=None):
     def is_green(bg):
         return bool(bg) and 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
+    def hexc(bg):
+        """gsheets {red,green,blue} float color -> #rrggbb, or None for white/no-fill."""
+        if not bg:
+            return None
+        r, g, b = bg.get("red", 0.0), bg.get("green", 0.0), bg.get("blue", 0.0)
+        if r > 0.96 and g > 0.96 and b > 0.96:   # white / default = not a color code
+            return None
+        return "#%02x%02x%02x" % (round(r * 255), round(g * 255), round(b * 255))
     fmt = api(f"{lib.API}/{lib.SID}?{qs}&includeGridData=true"
               "&fields=sheets(properties(sheetId),data.rowData.values.effectiveFormat.backgroundColor)", tok)
     green_rows = {sh["properties"]["sheetId"]: (sh.get("data") or [{}])[0].get("rowData", [])
@@ -49,7 +57,22 @@ def build(tok=None):
             if n > best:
                 best, bi = n, i
         return bi
+    def find_status(hs):
+        """prefer the literal 'Status (color)' / 'Status' column over anything merely
+        CONTAINING the word status (e.g. 'Inferred meaning (Status column)')."""
+        low = [((h or "").strip().lower(), i) for i, h in enumerate(hs)]
+        for want in ("status (color)", "status"):
+            for h, i in low:
+                if h == want:
+                    return i
+        for h, i in low:
+            if h.startswith("status"):
+                return i
+        return None
     tabs = []
+    scolor_tally = {}   # status VALUE -> {hex: count}, resolved to a dominant color per value after the loop
+    svalue_tally = {}   # status VALUE -> count (all cells, for the canonical ordered list)
+    status_field = None
     for (gid, title), vr in zip(sheets, value_ranges):
         rows = vr.get("values", [])
         if not rows:
@@ -59,15 +82,36 @@ def build(tok=None):
         width = max((len(r) for r in rows), default=0)
         headers = [(str(rows[hr][i]).strip() if i < len(rows[hr]) else "") for i in range(width)]
         body = [[unlink(r[i]) if i < len(r) else "" for i in range(width)] for r in rows[hr + 1:]]
+        sidx = find_status(headers)   # color-coded status column on this tab
+        if sidx is not None and status_field is None:
+            status_field = headers[sidx]
         green = []
         for ri, row in enumerate(green_rows.get(gid, [])):
             if ri <= hr:  # header + any title rows above it
                 continue
             for ci, cell in enumerate(row.get("values", []) or []):
-                if is_green((cell.get("effectiveFormat") or {}).get("backgroundColor")):
+                bg = (cell.get("effectiveFormat") or {}).get("backgroundColor")
+                if is_green(bg):
                     green.append(f"{ri-hr-1},{ci}")
+                if sidx is not None and ci == sidx:   # learn the value->color coding from the sheet
+                    hx = hexc(bg)
+                    di = ri - hr - 1
+                    if hx and 0 <= di < len(body) and ci < len(body[di]):
+                        val = str(body[di][ci]).strip()
+                        if val:
+                            scolor_tally.setdefault(val, {})
+                            scolor_tally[val][hx] = scolor_tally[val].get(hx, 0) + 1
+        if sidx is not None:   # tally every status value (colored or not) for the canonical list
+            for r in body:
+                v = str(r[sidx]).strip() if sidx < len(r) else ""
+                if v:
+                    svalue_tally[v] = svalue_tally.get(v, 0) + 1
         tabs.append({"gid": gid, "title": title, "headers": headers, "rows": body, "green": green})
-    return {"generated": int(time.time()), "tabs": tabs}
+    status_colors = {v: max(cnts, key=cnts.get) for v, cnts in scolor_tally.items()}
+    status_values = [v for v, _ in sorted(svalue_tally.items(), key=lambda kv: -kv[1])]
+    return {"generated": int(time.time()), "tabs": tabs,
+            "status_field": status_field, "status_colors": status_colors,
+            "status_values": status_values}
 
 def main():
     snap = build()

← 00fc8a6 Live console: Width presets (Tight/Cozy/Wide/Fit + auto-refi  ·  back to Rentv Sheet Enrich Refine  ·  rentv :9797 draft composer: richer Follow-up (thank-you + qu 1098075 →