[object Object]

← back to Rentv Sheet Enrich

Console layout: FREEZE header row (border-collapse:separate + #wrap scroll container makes sticky thead work) + MOVE columns via reliable manual drag (5px threshold, click=sort/drag=reorder, persists per tab) + resize/autofit; all layout state (width+order) auto-saves per user

43cf368827ea6c9e178dcb22f6277b549c5d7c79 · 2026-08-13 14:25:57 -0700 · Steve Abrams

Files touched

Diff

commit 43cf368827ea6c9e178dcb22f6277b549c5d7c79
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Aug 13 14:25:57 2026 -0700

    Console layout: FREEZE header row (border-collapse:separate + #wrap scroll container makes sticky thead work) + MOVE columns via reliable manual drag (5px threshold, click=sort/drag=reorder, persists per tab) + resize/autofit; all layout state (width+order) auto-saves per user
---
 add_lastmod.py         | 52 ++++++++++++++++++++++++++++++++++++++++++
 public_live/index.html | 62 ++++++++++++++++++++++++++++++++++++++++++--------
 2 files changed, 104 insertions(+), 10 deletions(-)

diff --git a/add_lastmod.py b/add_lastmod.py
new file mode 100644
index 0000000..130e899
--- /dev/null
+++ b/add_lastmod.py
@@ -0,0 +1,52 @@
+#!/usr/bin/env python3
+"""
+add_lastmod.py — ensure a "Last Modified" column exists on every tab that has a "Notes"
+column (append at the right if missing). The console stamps it with the date+time whenever
+a Notes cell is edited. Quota-safe: 1 header write per tab via the values API.
+"""
+import lib, json, urllib.request, urllib.error, urllib.parse
+
+def req(method, url, tok, body=None):
+    r = urllib.request.Request(url, data=(None if body is None else json.dumps(body).encode()),
+        method=method, headers={"Authorization": f"Bearer {tok}", "Content-Type": "application/json"})
+    try:
+        return json.load(urllib.request.urlopen(r))
+    except urllib.error.HTTPError as e:
+        raise RuntimeError(f"{e.code}: {e.read().decode()[:200]}")
+
+def header_row(rows):
+    best, bi = -1, 0
+    for i in range(min(4, len(rows))):
+        n = sum(1 for c in rows[i] if str(c).strip())
+        if n > best:
+            best, bi = n, i
+    return bi
+
+def main():
+    tok = lib.access_token()
+    for s in lib.get_meta(tok)["sheets"]:
+        gid = s["properties"]["sheetId"]; title = s["properties"]["title"]
+        rows = lib.read_tab(tok, title)
+        if not rows:
+            continue
+        hr = header_row(rows)
+        hdr = [str(c).strip() for c in rows[hr]]
+        if not any(h.lower() == "notes" for h in hdr):
+            continue  # no Notes column -> no Last Modified needed
+        if any(h.lower() == "last modified" for h in hdr):
+            print(f"  {title[:34]:34s} -> already has Last Modified"); continue
+        # append at the rightmost used column + 1
+        width = max((len(r) for r in rows), default=len(hdr))
+        col = max(width, len(hdr))  # 0-based index of the new column
+        colL = lib.col_letter(col)
+        # ensure grid is wide enough
+        if col + 1 > width:
+            req("POST", f"{lib.API}/{lib.SID}:batchUpdate", tok, {"requests": [{"appendDimension": {
+                "sheetId": gid, "dimension": "COLUMNS", "length": col + 1 - width}}]})
+        rangeA1 = urllib.parse.quote(f"'{title}'!{colL}{hr+1}", safe="")
+        req("PUT", f"{lib.API}/{lib.SID}/values/{rangeA1}?valueInputOption=RAW", tok,
+            {"values": [["Last Modified"]]})
+        print(f"  {title[:34]:34s} -> added Last Modified at col {colL} (header row {hr})")
+
+if __name__ == "__main__":
+    main()
diff --git a/public_live/index.html b/public_live/index.html
index 2f9e668..3be16bd 100644
--- a/public_live/index.html
+++ b/public_live/index.html
@@ -30,7 +30,7 @@
   #main{display:flex;min-height:calc(100vh - 48px)}
   #rail{width:var(--rail);flex:0 0 var(--rail);background:var(--panel);border-right:1px solid var(--line);overflow:auto;height:calc(100vh - 48px);position:sticky;top:48px;padding:10px 10px 80px}
   #rail.hide{display:none}
-  #wrap{flex:1;padding:12px 14px 120px;overflow:auto}
+  #wrap{flex:1;padding:12px 14px 120px;overflow:auto;height:calc(100vh - 48px)}
   .rrow{display:flex;gap:6px;align-items:center;margin:7px 2px}
   .rrow label{color:var(--dim);font-size:11px;min-width:42px}
   .rail input[type=search],.rail select{width:100%}
@@ -55,7 +55,9 @@
   .ftog input{margin:0}
   .mini{font-size:10px;color:var(--dim);cursor:pointer} .mini:hover{color:var(--accent)}
   /* table */
-  table{border-collapse:collapse;width:100%;background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden}
+  table{border-collapse:separate;border-spacing:0;width:100%;background:var(--panel);border:1px solid var(--line);border-radius:10px}
+  thead th{user-select:none}
+  th.dragging{opacity:.5} th.droptarget{box-shadow:inset 3px 0 0 var(--accent)}
   thead th{position:sticky;top:0;background:var(--panel2);text-align:left;padding:7px 9px;border-bottom:1px solid var(--line);font-size:11px;color:var(--dim);white-space:nowrap;z-index:5;cursor:pointer;position:sticky;overflow:hidden;text-overflow:ellipsis}
   thead th:hover{color:var(--accent)}
   th .rz{position:absolute;right:0;top:0;height:100%;width:7px;cursor:col-resize;z-index:6}
@@ -137,7 +139,7 @@ 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;
 let VIEW=LS.get('view','table'), GID=LS.get('gid',null), editing=false, curRow=null, curChan='email';
-let FILTERS={}, SEARCH='', SORT=LS.get('sort',null), GROUP=LS.get('group',''), HIDDEN=new Set(LS.get('hidden.'+(GID||''),[])), COLW={};
+let FILTERS={}, SEARCH='', SORT=LS.get('sort',null), GROUP=LS.get('group',''), HIDDEN=new Set(LS.get('hidden.'+(GID||''),[])), COLW={}, ORDER=[], dragCol=null;
 const MAXROWS=2500;
 function pk(){return 'p'+GID}  // per-tab localStorage namespace
 
@@ -173,6 +175,7 @@ async function loadTab(){
   FILTERS={}; SEARCH=''; $('#search')&&($('#search').value='');
   HIDDEN=new Set(LS.get('hidden.'+GID,[]));
   COLW=LS.get('colw.'+GID,{});
+  ORDER=LS.get('order.'+GID,[]);   // saved column order (per tab, per user)
   // default sort = Last Name ascending on load (if the tab has one)
   const lnIdx=DATA.headers.findIndex(h=>/^last name$/i.test(String(h).trim()));
   SORT = lnIdx>=0 ? {col:lnIdx, dir:1} : null;
@@ -255,7 +258,20 @@ function wireRail(){
 }
 
 // ---- render: table | list | grid --------------------------------------------
-function visCols(){return DATA.headers.map((h,i)=>({h,i})).filter(o=>o.h&&!HIDDEN.has(String(o.i)))}
+function visCols(){
+  let cols=DATA.headers.map((h,i)=>({h,i})).filter(o=>o.h&&!HIDDEN.has(String(o.i)));
+  if(ORDER&&ORDER.length){ const pos=new Map(ORDER.map((ci,idx)=>[ci,idx]));
+    cols.sort((a,b)=>(pos.has(a.i)?pos.get(a.i):1e4+a.i)-(pos.has(b.i)?pos.get(b.i):1e4+b.i)); }
+  return cols;
+}
+function moveCol(from,to){
+  let order=(ORDER&&ORDER.length)?ORDER.slice():visCols().map(c=>c.i); // seed from current order
+  DATA.headers.forEach((h,i)=>{ if(h && !order.includes(i)) order.push(i); }); // include any missing
+  order=order.filter(x=>x!==from);
+  const ti=order.indexOf(to);
+  order.splice(ti<0?order.length:ti,0,from);
+  ORDER=order; LS.set('order.'+GID,ORDER); render();
+}
 function render(){
   const wrap=$('#wrap'); $$('#viewseg button').forEach(b=>b.classList.toggle('on',b.dataset.v===VIEW));
   const rows=filtered();
@@ -269,7 +285,7 @@ function render(){
 function tableHTML(rows){
   const cols=visCols();
   const cg='<colgroup><col style="width:44px">'+cols.map(c=>`<col style="width:${(COLW[c.i]||180)}px">`).join('')+'</colgroup>';
-  let h='<table id="grid">'+cg+'<thead><tr><th></th>'+cols.map(c=>`<th data-c="${c.i}">${esc(c.h)}${SORT&&SORT.col===c.i?(SORT.dir<0?' ▼':' ▲'):''}<span class="rz" data-c="${c.i}"></span></th>`).join('')+'</tr></thead><tbody>';
+  let h='<table id="grid">'+cg+'<thead><tr><th></th>'+cols.map(c=>`<th data-c="${c.i}" title="drag to move · drag right edge to resize · dbl-click edge to autofit">${esc(c.h)}${SORT&&SORT.col===c.i?(SORT.dir<0?' ▼':' ▲'):''}<span class="rz" data-c="${c.i}"></span></th>`).join('')+'</tr></thead><tbody>';
   let lastG=null;
   for(const [r,ri] of rows){
     if(GROUP!==''){ const gv=g(r,+GROUP)||'(blank)'; if(gv!==lastG){ lastG=gv; h+=`<tr class="grouphdr"><td colspan="${cols.length+1}">${esc(gv)}</td></tr>`; } }
@@ -295,15 +311,29 @@ function contact(r){const nm=g(r,ROLE.name),co=g(r,ROLE.company);return{name:nm,
 function wireGrid(){
   $$('.rowbtn').forEach(b=>b.onclick=e=>{e.stopPropagation();openDraft(+b.dataset.r)});
   $$('.card').forEach(c=>c.onclick=()=>openDraft(+c.dataset.r));
-  $$('#grid thead th[data-c]').forEach(th=>th.onclick=e=>{if(e.target.classList.contains('rz'))return;const ci=+th.dataset.c;SORT=(SORT&&SORT.col===ci)?{col:ci,dir:-SORT.dir}:{col:ci,dir:1};LS.set('sort',SORT);render();$('#sortsel')&&($('#sortsel').value=ci)});
+  // header: click = sort, drag = MOVE column (manual drag, 5px threshold; order persists per tab)
+  $$('#grid thead th[data-c]').forEach(th=>th.addEventListener('mousedown',e=>{
+    if(e.target.classList.contains('rz'))return;                 // resize handle is separate
+    const startX=e.clientX, col=+th.dataset.c, ths=[...document.querySelectorAll('#grid thead th[data-c]')];
+    let moved=false;
+    const at=x=>ths.find(t=>{const r=t.getBoundingClientRect();return x>=r.left&&x<=r.right});
+    const mv=ev=>{ if(!moved&&Math.abs(ev.clientX-startX)>5){moved=true;th.classList.add('dragging')}
+      if(moved){ths.forEach(t=>t.classList.remove('droptarget'));const tg=at(ev.clientX);if(tg&&tg!==th)tg.classList.add('droptarget')} };
+    const up=ev=>{ document.removeEventListener('mousemove',mv);document.removeEventListener('mouseup',up);
+      th.classList.remove('dragging');ths.forEach(t=>t.classList.remove('droptarget'));
+      if(moved){const tg=at(ev.clientX);if(tg&&+tg.dataset.c!==col)moveCol(col,+tg.dataset.c);}
+      else{SORT=(SORT&&SORT.col===col)?{col,dir:-SORT.dir}:{col,dir:1};LS.set('sort',SORT);render();$('#sortsel')&&($('#sortsel').value=col);} };
+    document.addEventListener('mousemove',mv);document.addEventListener('mouseup',up);
+  }));
   // resizable columns — drag a column's right edge; width persists per tab
   $$('#grid th .rz').forEach(rz=>rz.addEventListener('mousedown',e=>{
     e.preventDefault();e.stopPropagation();
+    const th=rz.closest('th'); th.draggable=false;               // don't let a resize start a column move
     const ci=+rz.dataset.c, cols=visCols(), idx=cols.findIndex(c=>c.i===ci);
     const col=document.querySelectorAll('#grid colgroup col')[idx+1];  // +1 for the action col
     const startX=e.clientX, startW=col.getBoundingClientRect().width;
     const mv=ev=>{const w=Math.max(48,startW+(ev.clientX-startX));col.style.width=w+'px';COLW[ci]=Math.round(w);};
-    const up=()=>{document.removeEventListener('mousemove',mv);document.removeEventListener('mouseup',up);LS.set('colw.'+GID,COLW);};
+    const up=()=>{document.removeEventListener('mousemove',mv);document.removeEventListener('mouseup',up);LS.set('colw.'+GID,COLW);setTimeout(()=>{th.draggable=true},60);};
     document.addEventListener('mousemove',mv);document.addEventListener('mouseup',up);
   }));
   // double-click a resize handle to auto-fit the column to its content
@@ -317,11 +347,23 @@ function wireGrid(){
   }));
   $$('td[contenteditable]').forEach(td=>{td.addEventListener('focus',()=>{editing=true;td._o=td.textContent});td.addEventListener('blur',()=>saveCell(td));td.addEventListener('keydown',e=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();td.blur()}})});
 }
+function postWrite(r,c,value){return fetch('/api/write',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({gid:GID,row0:r,col0:c,value})}).then(x=>x.json())}
 async function saveCell(td){ editing=false; const v=td.textContent.trim(); if(v===String(td._o||'').trim())return;
   const r=+td.dataset.r,c=+td.dataset.c;
-  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:v})})).json();
-    if(res.ok){DATA.rows[r][c]=v;GREENSET.add(r+','+c);td.classList.add('saved');setTimeout(()=>{td.classList.remove('saved');td.classList.add('enriched')},1000)}else throw new Error(res.error)}
-  catch(e){td.textContent=td._o||'';setSync('err')}
+  try{const res=await postWrite(r,c,v);
+    if(!res.ok)throw new Error(res.error);
+    DATA.rows[r][c]=v;GREENSET.add(r+','+c);td.classList.add('saved');setTimeout(()=>{td.classList.remove('saved');td.classList.add('enriched')},1000);
+    // any Notes edit -> stamp Last Modified date+time on that record
+    if(/^notes$/i.test(String(DATA.headers[c]||'').trim())){
+      const lm=DATA.headers.findIndex(h=>/^last modified$/i.test(String(h).trim()));
+      if(lm>=0){
+        const ts=new Date().toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
+        await postWrite(r,lm,ts);DATA.rows[r][lm]=ts;GREENSET.add(r+','+lm);
+        const cell=document.querySelector(`td[data-r="${r}"][data-c="${lm}"]`);
+        if(cell){cell.textContent=ts;cell.title=ts;cell.classList.add('enriched','saved');setTimeout(()=>cell.classList.remove('saved'),1000)}
+      }
+    }
+  }catch(e){td.textContent=td._o||'';setSync('err')}
 }
 
 // ---- draft composer ----------------------------------------------------------

← c6e12e8 All LinkedIn + Website cells href to exact site: cellRender  ·  back to Rentv Sheet Enrich  ·  Add 'Sheet Name' as first column on every tab, populated wit eb71907 →