← back to Rentv Sheet Enrich Refine
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
A add_lastmod.pyM public_live/index.html
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 Refine
·
Add 'Sheet Name' as first column on every tab, populated wit eb71907 →