← back to Rentv Sheet Enrich
Rearchitect console: serve from OUR OWN local snapshot (snapshot.py, whole workbook in ~3 batch reads) instead of live-reading the sheet per tab-switch — kills the 60/min Sheets read-quota that blanked tabs (tab reads 0.9s->0.001s); rebuilt UI with full left filter panel (search + sort + group + density + per-field toggles + per-value click-to-filter facets), List/Table/Grid, live write-back + snapshot patch, /api/refresh; smart header-row detection for title-row tabs
ef8ce09fb83bbdc77b1980139890b1827e613356 · 2026-08-13 13:57:54 -0700 · Steve Abrams
Files touched
M .gitignoreA __pycache__/snapshot.cpython-314.pycA data/ocbatches/oc_10_li.jsonA data/ocbatches/oc_11_li.jsonA data/ocbatches/oc_12_li.jsonM live_server.pyM public_live/index.htmlA snapshot.py
Diff
commit ef8ce09fb83bbdc77b1980139890b1827e613356
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Aug 13 13:57:54 2026 -0700
Rearchitect console: serve from OUR OWN local snapshot (snapshot.py, whole workbook in ~3 batch reads) instead of live-reading the sheet per tab-switch — kills the 60/min Sheets read-quota that blanked tabs (tab reads 0.9s->0.001s); rebuilt UI with full left filter panel (search + sort + group + density + per-field toggles + per-value click-to-filter facets), List/Table/Grid, live write-back + snapshot patch, /api/refresh; smart header-row detection for title-row tabs
---
.gitignore | 2 +
__pycache__/snapshot.cpython-314.pyc | Bin 0 -> 7775 bytes
data/ocbatches/oc_10_li.json | 1 +
data/ocbatches/oc_11_li.json | 1 +
data/ocbatches/oc_12_li.json | 1 +
live_server.py | 119 ++++---
public_live/index.html | 583 +++++++++++++++--------------------
snapshot.py | 72 +++++
8 files changed, 406 insertions(+), 373 deletions(-)
diff --git a/.gitignore b/.gitignore
index 9a673bd..811e5b3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,5 @@ data/*.json
# live console
.liveviewer.port
+
+data/snapshot.json
diff --git a/__pycache__/snapshot.cpython-314.pyc b/__pycache__/snapshot.cpython-314.pyc
new file mode 100644
index 0000000..d8d224f
Binary files /dev/null and b/__pycache__/snapshot.cpython-314.pyc differ
diff --git a/data/ocbatches/oc_10_li.json b/data/ocbatches/oc_10_li.json
new file mode 100644
index 0000000..063b356
--- /dev/null
+++ b/data/ocbatches/oc_10_li.json
@@ -0,0 +1 @@
+[{"company": "Triwell Properties", "company_li": "https://www.linkedin.com/company/triwell-properties-inc"}, {"company": "Plaza Bank", "company_li": "https://www.linkedin.com/company/southwestusa-bank"}, {"company": "Rio Company", "company_li": "https://www.linkedin.com/company/rio-company"}, {"company": "Caladan Investments/Mount Auburn Partners", "company_li": "https://www.linkedin.com/company/montana-avenue-capital-partners"}, {"company": "Greenbridge Finance", "company_li": "https://www.linkedin.com/company/greenbridge-capital-llc"}, {"company": "Woodbridge Realty Advisors", "company_li": "https://www.linkedin.com/company/woodbridge-real-estate"}, {"company": "HPA", "company_li": "https://www.linkedin.com/company/hpaonline"}, {"company": "Signal Retail Properties", "company_li": "https://www.linkedin.com/company/signalbrands"}, {"company": "Price Mortgage", "company_li": "https://www.linkedin.com/company/price-mortgage-llc"}, {"company": "Marshall Property & Dev", "company_li": "https://www.linkedin.com/company/marshall-property-"}, {"company": "Upland Group", "company_li": "https://www.linkedin.com/company/usgi-upland-group"}, {"company": "ValueRock Realty Partners", "company_li": "https://www.linkedin.com/company/valuerock-realty-partners"}, {"company": "Arizona Partners", "company_li": "https://www.linkedin.com/company/arizona-partners"}, {"company": "Landmark Commercial Group", "company_li": "https://www.linkedin.com/company/california-landmark-group"}, {"company": "Majestic Asset Management", "company_li": "https://www.linkedin.com/company/majestic-asset-management-inc"}, {"company": "Property Nine Development", "company_li": "https://www.linkedin.com/company/property-nine-development"}, {"company": "Sandstone", "company_li": "https://www.linkedin.com/company/sandstonela"}, {"company": "Graystone Capital Advisors | Matthews", "company_li": "https://www.linkedin.com/company/graystoneca"}, {"company": "Carlton West Properties", "company_li": "https://www.linkedin.com/company/carlton-group"}, {"company": "Cashen Commercial", "company_li": "https://www.linkedin.com/company/cashen-realty-advisors"}, {"company": "GHP", "company_li": "https://www.linkedin.com/company/ghp-management"}, {"company": "Albany Road RE Partners", "company_li": "https://www.linkedin.com/company/albany-road-real-estate-partners"}, {"company": "Cap Rate Commercial", "company_li": "https://www.linkedin.com/company/mahercommercialrealty"}, {"company": "Clearinghouse CDFI", "company_li": "https://www.linkedin.com/company/clearinghouse-cdfi"}, {"company": "EN Capital", "company_li": "https://www.linkedin.com/company/en-capital"}, {"company": "Hasz Companies", "company_li": "https://www.linkedin.com/company/hasz-fund-control-inc"}, {"company": "Westminster Capital", "company_li": "https://www.linkedin.com/company/westminster-capital-inc"}, {"company": "CalmWater | EMAIL", "company_li": "https://www.linkedin.com/company/calmwater-capital"}, {"company": "Schnitzer | Schnitzer Investment Properties", "company_li": "https://www.linkedin.com/company/schnitzer-properties"}]
\ No newline at end of file
diff --git a/data/ocbatches/oc_11_li.json b/data/ocbatches/oc_11_li.json
new file mode 100644
index 0000000..7385e23
--- /dev/null
+++ b/data/ocbatches/oc_11_li.json
@@ -0,0 +1 @@
+[{"company": "DRA Collective", "company_li": "https://www.linkedin.com/company/dra-collective"}, {"company": "Arroyo Seco Dev Group", "company_li": "https://www.linkedin.com/company/arroyo-seco-development-group"}, {"company": "Barrett Real Estate", "company_li": "https://www.linkedin.com/company/barrettrealestate"}, {"company": "Bullseye Property management", "company_li": "https://www.linkedin.com/company/bullseye-property-management"}, {"company": "Caliber Construction", "company_li": "https://www.linkedin.com/company/caliber-construction-inc"}, {"company": "Chandler Partners", "company_li": "https://www.linkedin.com/company/p-chandler-partners"}, {"company": "Chicago Title", "company_li": "https://www.linkedin.com/company/chicago-title-national-commercial-services-southern-california"}, {"company": "Columbia Pacific", "company_li": "https://www.linkedin.com/company/columbia-pacific-management-inc"}, {"company": "CommerceWest Bank", "company_li": "https://www.linkedin.com/company/commercewest-bank"}, {"company": "Condor Commercial", "company_li": "https://www.linkedin.com/company/condor-entertainment-group"}, {"company": "Duncan Bergman Mandell", "company_li": "https://www.linkedin.com/company/duncan-bergman-mandell"}, {"company": "Empire USA LLC", "company_li": "https://www.linkedin.com/company/empire-usa-llc"}, {"company": "Frontline United Real Estate", "company_li": "https://www.linkedin.com/company/united-real-estate-los-angeles"}, {"company": "GDM Realty Advisors", "company_li": "https://www.linkedin.com/company/gm-realty-advisors-llc"}, {"company": "identity", "company_li": "https://www.linkedin.com/company/therealidentity"}, {"company": "Interpacific Asset Management", "company_li": "https://www.linkedin.com/company/pacificassetmanagement"}, {"company": "JFA Real Estate", "company_li": "https://www.linkedin.com/company/jfa-realty-company"}, {"company": "Keller Williams realty Utah / Arizona", "company_li": "https://www.linkedin.com/company/keller-williams-arizona-realty"}, {"company": "Live Oak Properties", "company_li": "https://www.linkedin.com/company/live-oak-cre"}, {"company": "Marabella Commercial Finance", "company_li": "https://www.linkedin.com/company/marabella-commercial-finance-inc-"}, {"company": "Mark II Properties", "company_li": "https://www.linkedin.com/company/mark-2-developments"}, {"company": "Modus Development", "company_li": "https://www.linkedin.com/company/modus-create"}, {"company": "Orum Capital", "company_li": "https://www.linkedin.com/company/orum-capital"}, {"company": "Pacific Reach", "company_li": "https://www.linkedin.com/company/pacific-reach"}, {"company": "Pegasus Retail", "company_li": "https://www.linkedin.com/company/pegasusinvestments"}, {"company": "Pinnacle Lending", "company_li": "https://www.linkedin.com/company/pinnacle-capital-mortgage"}, {"company": "RCG Ventures", "company_li": "https://www.linkedin.com/company/rcgventures"}]
\ No newline at end of file
diff --git a/data/ocbatches/oc_12_li.json b/data/ocbatches/oc_12_li.json
new file mode 100644
index 0000000..bc69eaf
--- /dev/null
+++ b/data/ocbatches/oc_12_li.json
@@ -0,0 +1 @@
+[{"company": "Rein & Grossoehme CRE", "company_li": "https://www.linkedin.com/company/rein-"}, {"company": "Rellion Inc", "company_li": "https://www.linkedin.com/company/rellion-inc"}, {"company": "Shapell Properties", "company_li": "https://www.linkedin.com/company/shapell-properties-inc"}, {"company": "Skyline Capital Ventures", "company_li": "https://www.linkedin.com/company/skyline-capital-management"}, {"company": "Stoneridge Development Services", "company_li": "https://www.linkedin.com/company/stoneridgemgmt"}, {"company": "Susank Capital", "company_li": "https://www.linkedin.com/company/susank-capital"}, {"company": "The Molasky Group", "company_li": "https://www.linkedin.com/company/molasky-corporate-center-owner"}, {"company": "Theopacific Holdings", "company_li": "https://www.linkedin.com/company/pacific-holdings-group"}, {"company": "Venture West Funding", "company_li": "https://www.linkedin.com/company/venture-west-funding"}, {"company": "Wilshire Skyline", "company_li": "https://www.linkedin.com/company/wilshire-skyline-inc"}, {"company": "WR Gorman & Associates", "company_li": "https://www.linkedin.com/company/gorman-"}, {"company": "xyz", "company_li": "https://www.linkedin.com/company/wearexyz"}]
\ No newline at end of file
diff --git a/live_server.py b/live_server.py
index 042e421..a9dd189 100644
--- a/live_server.py
+++ b/live_server.py
@@ -29,59 +29,81 @@ def tok():
with _tok_lock:
return lib.access_token()
+class QuotaError(Exception):
+ """Raised when the Sheets read/write quota (429) is hit after retries."""
+
+def retry(fn, *a, tries=5):
+ """Call a Sheets-reading fn, retrying on a 429/RESOURCE_EXHAUSTED. lib._req raises
+ SystemExit (a BaseException) on any API error, so we catch BaseException, inspect the
+ message, back off on quota errors, and re-raise everything else."""
+ import time as _t
+ for i in range(tries):
+ try:
+ return fn(*a)
+ except BaseException as e: # noqa: BLE001 - lib._req raises SystemExit
+ m = str(e)
+ if ("429" in m or "RESOURCE_EXHAUSTED" in m or "RATE_LIMIT" in m) and i < tries - 1:
+ _t.sleep(1.5 * (i + 1)); continue
+ if "429" in m or "RESOURCE_EXHAUSTED" in m or "RATE_LIMIT" in m:
+ raise QuotaError("Sheets read quota (60/min) hit — retrying shortly")
+ raise
+
+import snapshot as snap_mod # our own local-copy builder
+
+SNAP = None
+_snap_lock = threading.Lock()
+
+def load_snap():
+ """Load our OWN local copy (data/snapshot.json) into memory — no live Sheets reads."""
+ global SNAP
+ with open(os.path.join(HERE, "data", "snapshot.json")) as f:
+ s = json.load(f)
+ s["_by_gid"] = {t["gid"]: t for t in s["tabs"]}
+ SNAP = s
+
+def refresh_snap():
+ """Rebuild the local copy from the live sheet (a few batch reads) and swap it in."""
+ global SNAP
+ s = snap_mod.build(tok())
+ s["_by_gid"] = {t["gid"]: t for t in s["tabs"]}
+ with _snap_lock:
+ SNAP = s
+ json.dump({k: v for k, v in s.items() if k != "_by_gid"},
+ open(os.path.join(HERE, "data", "snapshot.json"), "w"))
+ return {"ok": True, "generated": s["generated"], "tabs": len(s["tabs"]),
+ "rows": sum(len(t["rows"]) for t in s["tabs"])}
+
def meta():
- return [{"gid": s["properties"]["sheetId"], "title": s["properties"]["title"]}
- for s in lib.get_meta(tok())["sheets"]]
+ return {"generated": SNAP["generated"],
+ "tabs": [{"gid": t["gid"], "title": t["title"], "rows": len(t["rows"])} for t in SNAP["tabs"]]}
def tab(gid):
- gid = int(gid)
- title = {s["properties"]["sheetId"]: s["properties"]["title"]
- for s in lib.get_meta(tok())["sheets"]}[gid]
- rows = lib.read_tab(tok(), title)
- headers = [h.strip() for h in rows[0]] if rows else []
- body = rows[1:] if rows else []
- # normalize ragged rows to header width
- w = len(headers)
- 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}
+ t = SNAP["_by_gid"][int(gid)]
+ return {"gid": t["gid"], "title": t["title"], "headers": t["headers"],
+ "rows": t["rows"], "green": t["green"], "generated": SNAP["generated"]}
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}
+ """Enriched (green) cells for a tab — precomputed in the local snapshot."""
+ t = SNAP["_by_gid"][int(gid)]
+ return {"gid": t["gid"], "green": t["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.
+ # 1) LIVE write to the sheet (row0 is 0-based over DATA rows; +1 for the header)
cell = {"row0": int(row0) + 1, "col0": int(col0), "value": value}
if formula:
cell["formula"] = True
- lib.batch_fill(tok(), int(gid), [cell])
+ retry(lambda: lib.batch_fill(tok(), int(gid), [cell]))
+ # 2) patch the in-memory local copy so the UI reflects it instantly + marks green
+ t = SNAP["_by_gid"].get(int(gid))
+ if t:
+ r, c = int(row0), int(col0)
+ while len(t["rows"]) <= r:
+ t["rows"].append([])
+ while len(t["rows"][r]) <= c:
+ t["rows"][r].append("")
+ t["rows"][r][c] = value
+ if f"{r},{c}" not in t["green"]:
+ t["green"].append(f"{r},{c}")
return {"ok": True}
def enhance(prompt):
@@ -158,7 +180,9 @@ class H(BaseHTTPRequestHandler):
self._json({"error": "not found"}, 404)
else:
self._json({"error": "not found"}, 404)
- except Exception as e:
+ except QuotaError as e:
+ self._json({"error": str(e), "retry": True}, 503)
+ except BaseException as e: # incl. SystemExit from lib._req on API errors
self._json({"error": str(e)}, 500)
def do_POST(self):
@@ -172,14 +196,19 @@ class H(BaseHTTPRequestHandler):
body.get("value", ""), body.get("formula", False)))
elif self.path == "/api/enhance":
self._json(enhance(body.get("prompt", "")))
+ elif self.path == "/api/refresh":
+ self._json(refresh_snap())
else:
self._json({"error": "not found"}, 404)
- except Exception as e:
+ except QuotaError as e:
+ self._json({"error": str(e), "retry": True}, 503)
+ except BaseException as e: # incl. SystemExit from lib._req on API errors
self._json({"error": str(e)}, 500)
import urllib.parse # noqa: E402 (used in handlers)
def main():
+ load_snap() # serve from OUR local copy, not the live sheet
port = int(sys.argv[1]) if len(sys.argv) > 1 else 0
srv = ThreadingHTTPServer(("127.0.0.1", port), H)
real = srv.server_address[1]
diff --git a/public_live/index.html b/public_live/index.html
index 57b2d84..5e0dd18 100644
--- a/public_live/index.html
+++ b/public_live/index.html
@@ -4,394 +4,321 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RENTV Live Console</title>
-<link rel="stylesheet" href="/nav-agent/nav-agent.css">
<style>
:root{
- --bg:#0e1116; --panel:#161b22; --panel2:#1c232d; --line:#2b333d;
- --ink:#e6edf3; --dim:#9aa7b4; --accent:#4c9be8; --green:#cbe6b4; --greenink:#1a2e10;
- --chip:#232c37; --fs:14px;
+ --bg:#0e1116; --panel:#161b22; --panel2:#1c232d; --line:#2b333d; --ink:#e6edf3;
+ --dim:#9aa7b4; --accent:#4c9be8; --green:#cbe6b4; --greenink:#1a2e10; --chip:#232c37; --fs:13px; --rail:300px;
}
*{box-sizing:border-box}
- body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
- background:var(--bg);color:var(--ink);font-size:var(--fs)}
- header{position:sticky;top:0;z-index:40;background:var(--panel);border-bottom:1px solid var(--line);
- display:flex;align-items:center;gap:14px;padding:10px 16px;flex-wrap:wrap}
- header h1{font-size:16px;margin:0;font-weight:700;letter-spacing:.3px}
+ body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--ink);font-size:var(--fs)}
+ header{position:sticky;top:0;z-index:40;background:var(--panel);border-bottom:1px solid var(--line);display:flex;align-items:center;gap:12px;padding:9px 14px;flex-wrap:wrap}
+ header h1{font-size:15px;margin:0;font-weight:700;letter-spacing:.3px;white-space:nowrap}
header h1 span{color:var(--accent)}
- select,button,input{font:inherit;color:var(--ink);background:var(--panel2);border:1px solid var(--line);
- border-radius:8px;padding:6px 10px}
- button{cursor:pointer}
- button:hover{border-color:var(--accent)}
+ select,button,input{font:inherit;color:var(--ink);background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:5px 9px}
+ button{cursor:pointer} button:hover{border-color:var(--accent)}
.seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
- .seg button{border:0;border-radius:0;background:var(--panel2)}
- .seg button.on{background:var(--accent);color:#04121f;font-weight:700}
+ .seg button{border:0;border-radius:0;background:var(--panel2)} .seg button.on{background:var(--accent);color:#04121f;font-weight:700}
.grow{flex:1}
- .sync{display:flex;align-items:center;gap:8px;color:var(--dim);font-size:12px}
- .dot{width:9px;height:9px;border-radius:50%;background:#3fb950;box-shadow:0 0 8px #3fb95088}
- .dot.stale{background:#d29922;box-shadow:none}
- .dot.err{background:#f85149}
- #wrap{padding:14px 16px 120px}
+ .sync{display:flex;align-items:center;gap:7px;color:var(--dim);font-size:12px}
+ .dot{width:9px;height:9px;border-radius:50%;background:#3fb950} .dot.stale{background:#d29922} .dot.err{background:#f85149}
+ .chips{display:flex;gap:6px;flex-wrap:wrap}
+ .fchip{background:var(--accent);color:#04121f;border-radius:14px;padding:2px 9px;font-size:11px;font-weight:600;cursor:pointer;white-space:nowrap}
+ .fchip b{opacity:.7;margin-left:5px}
+ /* layout */
+ #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}
+ .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%}
+ #search{width:100%;margin-bottom:6px}
+ .sec{border-top:1px solid var(--line);margin-top:8px;padding-top:6px}
+ .sec>summary{cursor:pointer;color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.5px;list-style:none;user-select:none;padding:3px 0}
+ .sec>summary::-webkit-details-marker{display:none}
+ .sec>summary:before{content:'▸ ';color:var(--accent)} .sec[open]>summary:before{content:'▾ '}
+ details.field{margin:2px 0;border:1px solid transparent;border-radius:6px}
+ details.field>summary{cursor:pointer;padding:4px 6px;border-radius:6px;list-style:none;display:flex;align-items:center;gap:6px;font-size:12px}
+ details.field>summary::-webkit-details-marker{display:none}
+ details.field>summary:hover{background:var(--panel2)}
+ details.field>summary .cnt{color:var(--dim);font-size:10px;margin-left:auto}
+ details.field.active>summary{color:var(--accent);font-weight:600}
+ .vals{padding:2px 6px 6px 14px;max-height:230px;overflow:auto}
+ .val{display:flex;align-items:center;gap:6px;padding:2px 4px;border-radius:5px;cursor:pointer;font-size:12px}
+ .val:hover{background:var(--panel2)} .val.on{color:var(--accent)}
+ .val .n{color:var(--dim);font-size:10px;margin-left:auto}
+ .val .bx{width:12px;height:12px;border:1px solid var(--line);border-radius:3px;flex:0 0 12px}
+ .val.on .bx{background:var(--accent);border-color:var(--accent)}
+ .ftog{display:flex;align-items:center;gap:6px;padding:2px 4px;font-size:12px;cursor:pointer}
+ .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}
- thead th{position:sticky;top:56px;background:var(--panel2);text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);
- font-size:12px;color:var(--dim);white-space:nowrap;z-index:5}
- tbody td{padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:top;max-width:340px}
+ 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}
+ thead th:hover{color:var(--accent)}
+ tbody td{padding:6px 9px;border-bottom:1px solid var(--line);vertical-align:top;max-width:320px}
tbody tr:hover{background:#1a212b}
- 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)}
+ td[contenteditable]:focus{outline:none;background:#0d2b3f;box-shadow:inset 0 0 0 2px var(--accent)}
+ td.saved{background:var(--green);color:var(--greenink)}
+ td.enriched{box-shadow:inset 3px 0 0 #7bc043;background:rgba(123,192,67,.09)}
+ td a{color:var(--accent);text-decoration:none} .namecell{font-weight:600}
+ .rowbtn{background:var(--accent);color:#04121f;border:0;border-radius:6px;padding:3px 8px;font-size:11px;font-weight:700;cursor:pointer;white-space:nowrap}
+ .grouphdr td{background:#12161c;color:var(--accent);font-weight:700;font-size:12px;padding:6px 9px;position:sticky;top:26px}
+ /* cards */
+ .cards{display:grid;gap:11px} .cards.grid{grid-template-columns:repeat(auto-fill,minmax(300px,1fr))} .cards.list{grid-template-columns:1fr}
+ .card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:13px 15px;position:relative} .card:hover{border-color: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}
- /* cards (list + grid modes) */
- .cards{display:grid;gap:12px}
- .cards.grid{grid-template-columns:repeat(auto-fill,minmax(300px,1fr))}
- .cards.list{grid-template-columns:1fr}
- .card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 16px;position:relative}
- .card:hover{border-color:var(--accent)}
- .card .co{font-weight:700;font-size:15px}
- .card .nm{color:var(--ink);margin-top:2px}
- .card .ro{color:var(--dim);font-size:13px}
- .card .meta{margin-top:8px;display:flex;flex-wrap:wrap;gap:6px}
- .badge{background:var(--chip);border:1px solid var(--line);border-radius:20px;padding:2px 9px;font-size:11px;color:var(--dim)}
- .badge.ok{color:var(--green);border-color:#3a5228}
- .card .act{position:absolute;top:12px;right:12px}
- .empty{color:var(--dim);padding:40px;text-align:center}
- /* composer drawer */
- #scrim{position:fixed;inset:0;background:#000a;z-index:60;display:none}
- #drawer{position:fixed;top:0;right:0;height:100%;width:min(560px,100%);background:var(--panel);
- border-left:1px solid var(--line);z-index:61;transform:translateX(100%);transition:transform .22s;
- display:flex;flex-direction:column}
- #drawer.open{transform:none}
- #drawer header{position:static;background:var(--panel2)}
- .dbody{padding:16px;overflow:auto;flex:1}
- .dbody h2{margin:0 0 2px;font-size:18px}
- .dbody .sub{color:var(--dim);font-size:13px;margin-bottom:12px}
- .fieldgrid{display:grid;grid-template-columns:auto 1fr;gap:4px 12px;font-size:13px;margin-bottom:14px}
- .fieldgrid .k{color:var(--dim)}
- .row{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:10px 0}
- label.lbl{color:var(--dim);font-size:12px;min-width:64px}
- textarea{width:100%;min-height:230px;background:var(--panel2);border:1px solid var(--line);border-radius:10px;
- color:var(--ink);padding:12px;font:inherit;line-height:1.5;resize:vertical}
- .subj{width:100%;margin-bottom:8px}
- .actions{display:flex;gap:8px;flex-wrap:wrap;padding:12px 16px;border-top:1px solid var(--line);background:var(--panel2)}
- .actions .primary{background:var(--accent);color:#04121f;font-weight:700;border:0}
- .chdis{opacity:.4;pointer-events:none}
- .hint{color:var(--dim);font-size:12px}
+ .card .co{font-weight:700;font-size:15px} .card .nm{margin-top:2px} .card .ro{color:var(--dim);font-size:12px}
+ .card .meta{margin-top:8px;display:flex;flex-wrap:wrap;gap:5px} .badge{background:var(--chip);border:1px solid var(--line);border-radius:20px;padding:2px 8px;font-size:10px;color:var(--dim)} .badge.ok{color:var(--green);border-color:#3a5228}
+ .card .act{position:absolute;top:11px;right:11px}
+ .empty{color:var(--dim);padding:36px;text-align:center}
+ .legend{display:inline-flex;align-items:center;gap:5px;color:var(--dim);font-size:11px}
+ .legend .sw{width:11px;height:11px;border-radius:3px;background:rgba(123,192,67,.25);box-shadow:inset 3px 0 0 #7bc043}
+ /* composer */
+ #scrim{position:fixed;inset:0;background:#000a;z-index:60;display:none} #drawer{position:fixed;top:0;right:0;height:100%;width:min(560px,100%);background:var(--panel);border-left:1px solid var(--line);z-index:61;transform:translateX(100%);transition:transform .2s;display:flex;flex-direction:column}
+ #drawer.open{transform:none} #drawer header{position:static;background:var(--panel2)}
+ .dbody{padding:15px;overflow:auto;flex:1} .dbody h2{margin:0 0 2px;font-size:18px} .dbody .sub{color:var(--dim);font-size:13px;margin-bottom:12px}
+ .fieldgrid{display:grid;grid-template-columns:auto 1fr;gap:4px 12px;font-size:13px;margin-bottom:12px} .fieldgrid .k{color:var(--dim)}
+ .drow{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:9px 0} label.lbl{color:var(--dim);font-size:12px;min-width:60px}
+ textarea{width:100%;min-height:210px;background:var(--panel2);border:1px solid var(--line);border-radius:10px;color:var(--ink);padding:11px;font:inherit;line-height:1.5;resize:vertical}
+ .subj{width:100%;margin-bottom:8px} .actions{display:flex;gap:8px;flex-wrap:wrap;padding:11px 15px;border-top:1px solid var(--line);background:var(--panel2)} .actions .primary{background:var(--accent);color:#04121f;font-weight:700;border:0}
+ .chdis{opacity:.4;pointer-events:none} .hint{color:var(--dim);font-size:12px}
</style>
</head>
<body>
<header>
+ <button id="railtog" title="Toggle filters">☰</button>
<h1>RENTV <span>Live Console</span></h1>
<select id="tab" title="Sheet tab"></select>
<div class="seg" id="viewseg" title="View mode">
- <button data-v="table">▤ Table</button>
- <button data-v="list">☰ List</button>
- <button data-v="grid">▦ Grid</button>
+ <button data-v="table">▤ Table</button><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="legend"><span class="sw"></span>enriched</span>
+ <span class="chips" id="chips"></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>
+ <span class="sync"><span class="dot" id="dot"></span><span id="syncmsg">loading…</span></span>
+ <button id="refresh" title="Rebuild local copy from the sheet">↻ Refresh</button>
</header>
-<div id="wrap"><div class="empty">Loading the sheet…</div></div>
+<div id="main">
+ <aside id="rail"></aside>
+ <div id="wrap"><div class="empty">Loading our local copy…</div></div>
+</div>
<div id="scrim"></div>
<div id="drawer">
- <header>
- <strong>✍ Draft outreach</strong><span class="grow"></span>
- <button id="dclose">✕ Close</button>
- </header>
+ <header><strong>✍ Draft outreach</strong><span class="grow"></span><button id="dclose">✕ Close</button></header>
<div class="dbody">
- <h2 id="d-company">—</h2>
- <div class="sub" id="d-who">—</div>
+ <h2 id="d-company">—</h2><div class="sub" id="d-who">—</div>
<div class="fieldgrid" id="d-fields"></div>
-
- <div class="row">
- <label class="lbl">Contact via</label>
- <div class="seg" id="chanseg">
- <button data-c="email">✉ Email</button>
- <button data-c="linkedin">in LinkedIn</button>
- <button data-c="phone">☎ Phone</button>
- </div>
- </div>
- <div class="row">
- <label class="lbl">Purpose</label>
- <select id="purpose">
- <option value="partnership">Partnership / intro</option>
- <option value="sponsorship">Conference sponsorship</option>
- <option value="speaker">Speaker / panel invite</option>
- <option value="followup">Follow-up</option>
- </select>
- <label class="lbl">Tone</label>
- <select id="tone">
- <option value="warm">Warm</option>
- <option value="direct">Direct</option>
- <option value="formal">Formal</option>
- </select>
- <button id="regen">↻ Regenerate</button>
- </div>
-
+ <div class="drow"><label class="lbl">Contact via</label>
+ <div class="seg" id="chanseg"><button data-c="email">✉ Email</button><button data-c="linkedin">in LinkedIn</button><button data-c="phone">☎ Phone</button></div></div>
+ <div class="drow"><label class="lbl">Purpose</label>
+ <select id="purpose"><option value="partnership">Partnership / intro</option><option value="sponsorship">Conference sponsorship</option><option value="speaker">Speaker / panel invite</option><option value="followup">Follow-up</option></select>
+ <label class="lbl">Tone</label><select id="tone"><option value="warm">Warm</option><option value="direct">Direct</option><option value="formal">Formal</option></select>
+ <button id="regen">↻ Regenerate</button></div>
<input class="subj" id="d-subject" placeholder="Subject line">
<textarea id="d-body"></textarea>
- <div class="row"><span class="hint" id="d-target"></span></div>
- <div class="row">
- <button id="enhance">✨ Enhance (local AI · $0)</button>
- <span class="hint">Polishes the draft with a local model — nothing sent.</span>
- </div>
- </div>
- <div class="actions">
- <button class="primary" id="act-go">Open in Email</button>
- <button id="act-copy">⧉ Copy draft</button>
- <span class="grow"></span>
- <span class="hint">Draft only — you send it. Contacts © Steve Bloom / RENTV.</span>
+ <div class="drow"><span class="hint" id="d-target"></span></div>
+ <div class="drow"><button id="enhance">✨ Enhance (local AI · $0)</button><span class="hint">Polishes with a local model — nothing sent.</span></div>
</div>
+ <div class="actions"><button class="primary" id="act-go">Open in Email</button><button id="act-copy">⧉ Copy draft</button><span class="grow"></span><span class="hint">Draft only. Contacts © Steve Bloom / RENTV.</span></div>
</div>
-<script src="/nav-agent/nav-agent.js" defer></script>
<script>
-const $ = s => document.querySelector(s);
-const LS = { get:(k,d)=>{try{return JSON.parse(localStorage.getItem('rentv.'+k))??d}catch(e){return d}},
- 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
+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||''),[]));
+const MAXROWS=2500;
+function pk(){return 'p'+GID} // per-tab localStorage namespace
-// ---- 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; }
-function detectRoles(){
- ROLE = {
- name: findCol([/^contact name$/i,/\bname\b/i]),
- company: findCol([/company\/venue/i,/^company$/i,/venue/i,/firm/i]),
- position:findCol([/^position$/i,/title/i,/role/i]),
- email: findCol([/^email$/i]),
- email2: findCol([/updated email/i]),
- phone: findCol([/^phone 1$/i,/^phone$/i]),
- cphone: findCol([/company phone/i]),
- liC: findCol([/linkedin \(contact\)/i]),
- liCo: findCol([/linkedin \(company\)/i]),
- mvpN: findCol([/marketing\/vp name/i]),
- mvpP: findCol([/marketing\/vp position/i]),
- mvpL: findCol([/marketing\/vp linkedin/i]),
- };
-}
-const g=(r,i)=> (i>=0 && i<r.length)? String(r[i]||'').trim() : '';
-function contact(r){
- const nm=g(r,ROLE.name), co=g(r,ROLE.company);
- return {
- name:nm, first:(nm.split(/[\s,]+/)[0]||'').trim(), company:co, position:g(r,ROLE.position),
- email:g(r,ROLE.email)||g(r,ROLE.email2), phone:g(r,ROLE.phone)||g(r,ROLE.cphone),
- liC:hrefOf(g(r,ROLE.liC)), liCo:hrefOf(g(r,ROLE.liCo)),
- mvpN:g(r,ROLE.mvpN), mvpP:g(r,ROLE.mvpP), mvpL:hrefOf(g(r,ROLE.mvpL)),
- };
-}
-function hrefOf(v){ const m=String(v).match(/https?:\/\/[^\s"')]+/i); return m?m[0]:(/linkedin\.com/i.test(v)?('https://'+v.replace(/^https?:\/\//,'')):''); }
+// ---- field roles (for the composer) -----------------------------------------
+function findCol(preds){for(const p of preds){const i=DATA.headers.findIndex(h=>p.test(h));if(i>=0)return i}return -1}
+function detectRoles(){ROLE={name:findCol([/^contact name$/i,/contact name/i,/\bname\b/i]),company:findCol([/company\/venue/i,/^company$/i,/venue/i,/firm/i]),position:findCol([/^position$/i,/title/i]),email:findCol([/^email$/i]),email2:findCol([/updated email/i]),phone:findCol([/^phone 1$/i,/^phone\(s\)$/i,/^phone$/i]),cphone:findCol([/company phone/i]),liC:findCol([/linkedin \(contact\)/i]),liCo:findCol([/linkedin \(company\)/i]),mvpN:findCol([/marketing\/vp name/i]),mvpP:findCol([/marketing\/vp position/i]),mvpL:findCol([/marketing\/vp linkedin/i])}}
+const g=(r,i)=> (i>=0&&i<r.length)?String(r[i]||'').trim():'';
+function hrefOf(v){const m=String(v).match(/https?:\/\/[^\s"')]+/i);return m?m[0]:(/linkedin\.com/i.test(v)?('https://'+v.replace(/^https?:\/\//,'')):'')}
-// ---- load / live pull --------------------------------------------------------
+// ---- load (from OUR local copy) ---------------------------------------------
async function loadMeta(){
- const tabs=await (await fetch('/api/meta')).json();
- const sel=$('#tab'); sel.innerHTML='';
- tabs.forEach(t=>{const o=document.createElement('option');o.value=t.gid;o.textContent=t.title;sel.appendChild(o);});
- if(GID==null || !tabs.find(t=>t.gid==GID)) GID=tabs[0].gid;
+ META=await (await fetch('/api/meta')).json();
+ 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))GID=META.tabs[0].gid;
sel.value=GID;
}
-async function pull(force){
- if(editing && !force) return;
- try{
- const d=await (await fetch('/api/tab?gid='+GID)).json();
- if(d.error) throw new Error(d.error);
- const changed = JSON.stringify(d)!==JSON.stringify(DATA);
- DATA=d; detectRoles(); lastPull=Date.now();
- setSync('ok');
- if(changed) render();
- }catch(e){ setSync('err',e.message); }
+async function loadTab(){
+ setSync('load');
+ const d=await (await fetch('/api/tab?gid='+GID)).json();
+ DATA=d; GREENSET=new Set(d.green||[]); detectRoles();
+ FILTERS={}; SEARCH=''; $('#search')&&($('#search').value='');
+ HIDDEN=new Set(LS.get('hidden.'+GID,[]));
+ buildRail(); render(); setSync('ok');
}
-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 setSync(state){
+ $('#dot').className='dot'+(state==='err'?' err':state==='load'?' stale':'');
+ const age=META?Math.round((Date.now()/1000)-META.generated):0;
+ $('#syncmsg').textContent = state==='load'?'loading…':state==='err'?'error':
+ `local copy · ${DATA.rows.length} rows · as of ${age<90?age+'s':Math.round(age/60)+'m'} ago`;
}
-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);
+
+// ---- filtering ---------------------------------------------------------------
+function norm(s){return String(s||'').trim()}
+function passes(r){
+ for(const ci in FILTERS){ const set=FILTERS[ci]; if(set.size && !set.has(norm(r[ci])||'(blank)')) return false; }
+ if(SEARCH){ const q=SEARCH.toLowerCase(); if(!r.some(c=>String(c||'').toLowerCase().includes(q))) return false; }
+ return true;
+}
+function filtered(){ let rows=DATA.rows.map((r,i)=>[r,i]).filter(([r])=>passes(r));
+ if(SORT){ const ci=SORT.col,dir=SORT.dir; rows.sort((a,b)=>{const x=g(a[0],ci).toLowerCase(),y=g(b[0],ci).toLowerCase(); return (x<y?-1:x>y?1:0)*dir}); }
+ return rows;
+}
+
+// ---- LEFT RAIL: search + sort + group + density + field toggles + per-field FILTERS ----
+function buildRail(){
+ const rail=$('#rail'); if(rail.classList.contains('hide')){return}
+ const H=DATA.headers, opts=H.map((h,i)=>h?`<option value="${i}">${esc(h)}</option>`:'').join('');
+ let html=`<input type="search" id="search" placeholder="Search all fields…" value="${esc(SEARCH)}">
+ <div class="rrow"><label>Sort</label><select id="sortsel"><option value="">— none —</option>${opts}</select>
+ <button id="sortdir" title="asc/desc">${SORT&&SORT.dir<0?'▼':'▲'}</button></div>
+ <div class="rrow"><label>Group</label><select id="groupsel"><option value="">— none —</option>${opts}</select></div>
+ <div class="rrow"><label>Density</label><input type="range" id="dens" min="10" max="18" value="${getComputedStyle(document.documentElement).getPropertyValue('--fs').trim().replace('px','')||13}"></div>
+ <details class="sec" open><summary>Fields (${H.filter(Boolean).length}) · <span class="mini" id="allf">all</span> / <span class="mini" id="nonef">none</span></summary>
+ <div id="ftogs"></div></details>
+ <details class="sec" open><summary>Filters — click a value</summary><div id="facets"></div></details>`;
+ rail.innerHTML=html;
+ $('#sortsel').value=SORT?SORT.col:''; $('#groupsel').value=GROUP;
+ // field toggles
+ $('#ftogs').innerHTML=H.map((h,i)=>h?`<label class="ftog"><input type="checkbox" data-c="${i}" ${HIDDEN.has(String(i))?'':'checked'}> ${esc(h)}</label>`:'').join('');
+ buildFacets();
+ wireRail();
+}
+function buildFacets(){
+ const H=DATA.headers, host=$('#facets'); if(!host)return;
+ // rows passing OTHER filters (so counts reflect current narrowing)
+ const base=DATA.rows.filter(passes);
+ let html='';
+ H.forEach((h,ci)=>{
+ if(!h) return;
+ const tally={};
+ for(const r of base){ let v=norm(r[ci])||'(blank)'; tally[v]=(tally[v]||0)+1; }
+ const vals=Object.entries(tally).sort((a,b)=>b[1]-a[1]);
+ if(vals.length<=1 && vals[0] && vals[0][0]==='(blank)') return; // skip all-blank fields
+ const active=FILTERS[ci]&&FILTERS[ci].size;
+ const shown=vals.slice(0,40);
+ html+=`<details class="field ${active?'active':''}" data-c="${ci}"><summary>${esc(h)}<span class="cnt">${vals.length}</span></summary>
+ <div class="vals">${shown.map(([v,n])=>`<div class="val ${active&&FILTERS[ci].has(v)?'on':''}" data-v="${esc(v)}"><span class="bx"></span>${esc(v.length>34?v.slice(0,33)+'…':v)}<span class="n">${n}</span></div>`).join('')}
+ ${vals.length>40?`<div class="mini">+${vals.length-40} more…</div>`:''}</div></details>`;
});
+ host.innerHTML=html||'<div class="hint" style="padding:6px">No filterable fields</div>';
+ host.querySelectorAll('.val').forEach(el=>el.onclick=()=>toggleFilter(+el.closest('.field').dataset.c, el.dataset.v));
+}
+function toggleFilter(ci,v){ FILTERS[ci]=FILTERS[ci]||new Set(); FILTERS[ci].has(v)?FILTERS[ci].delete(v):FILTERS[ci].add(v); if(!FILTERS[ci].size)delete FILTERS[ci]; buildFacets(); renderChips(); render(); }
+function renderChips(){ const c=$('#chips'); let h='';
+ for(const ci in FILTERS){ for(const v of FILTERS[ci]){ h+=`<span class="fchip" data-c="${ci}" data-v="${esc(v)}">${esc(DATA.headers[ci])}: ${esc(v.length>18?v.slice(0,17)+'…':v)}<b>✕</b></span>`; } }
+ c.innerHTML=h; c.querySelectorAll('.fchip').forEach(el=>el.onclick=()=>toggleFilter(+el.dataset.c, el.dataset.v));
}
-function setSync(state,msg){
- const dot=$('#dot');
- dot.className='dot'+(state==='err'?' err':state==='stale'?' stale':'');
- $('#syncmsg').textContent = state==='err' ? ('sync error: '+(msg||'')) :
- ('live · synced '+Math.max(0,Math.round((Date.now()-lastPull)/1000))+'s ago · '+DATA.rows.length+' rows');
+function wireRail(){
+ $('#search').oninput=e=>{SEARCH=e.target.value;clearTimeout(window._st);window._st=setTimeout(()=>{buildFacets();render()},180)};
+ $('#sortsel').onchange=e=>{SORT=e.target.value===''?null:{col:+e.target.value,dir:SORT?SORT.dir:1};LS.set('sort',SORT);render()};
+ $('#sortdir').onclick=()=>{if(SORT){SORT.dir*=-1;LS.set('sort',SORT);$('#sortdir').textContent=SORT.dir<0?'▼':'▲';render()}};
+ $('#groupsel').onchange=e=>{GROUP=e.target.value;LS.set('group',GROUP);render()};
+ $('#dens').oninput=e=>{document.documentElement.style.setProperty('--fs',e.target.value+'px')};
+ $('#ftogs').querySelectorAll('input').forEach(cb=>cb.onchange=()=>{const i=cb.dataset.c;cb.checked?HIDDEN.delete(i):HIDDEN.add(i);LS.set('hidden.'+GID,[...HIDDEN]);render()});
+ $('#allf').onclick=()=>{HIDDEN.clear();LS.set('hidden.'+GID,[]);buildRail();render()};
+ $('#nonef').onclick=()=>{HIDDEN=new Set(DATA.headers.map((h,i)=>h?String(i):null).filter(x=>x));LS.set('hidden.'+GID,[...HIDDEN]);buildRail();render()};
}
-setInterval(()=>{ if(Date.now()-lastPull>25000) $('#dot').classList.add('stale'); if(!editing) setSync('ok'); },1000);
// ---- render: table | list | grid --------------------------------------------
-const MAXROWS=1200;
+function visCols(){return DATA.headers.map((h,i)=>({h,i})).filter(o=>o.h&&!HIDDEN.has(String(o.i)))}
function render(){
- const wrap=$('#wrap');
- if(!DATA.rows.length){ wrap.innerHTML='<div class="empty">No rows in this tab.</div>'; return; }
- document.querySelectorAll('#viewseg button').forEach(b=>b.classList.toggle('on',b.dataset.v===VIEW));
- 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(); 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){} }
+ const wrap=$('#wrap'); $$('#viewseg button').forEach(b=>b.classList.toggle('on',b.dataset.v===VIEW));
+ const rows=filtered();
+ setSync('ok');
+ if(!rows.length){wrap.innerHTML='<div class="empty">No rows match the current filters.</div>';return}
+ const capped=rows.length>MAXROWS, show=rows.slice(0,MAXROWS);
+ const banner=capped?`<div class="empty" style="padding:8px;color:#d29922">Showing ${MAXROWS} of ${rows.length} matches — narrow with filters (left).</div>`:'';
+ wrap.innerHTML=banner+(VIEW==='table'?tableHTML(show):cardsHTML(show,VIEW));
+ wireGrid();
}
-function tableHTML(){
- let h='<table id="grid" data-nav-grid><thead><tr><th></th>';
- DATA.headers.forEach(hd=> h+='<th>'+esc(hd)+'</th>'); h+='</tr></thead><tbody>';
- 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, 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"'+
- (cls?' class="'+cls+'"':'')+'>'+cellRender(c)+'</td>';
- });
+function tableHTML(rows){
+ const cols=visCols();
+ let h='<table id="grid"><thead><tr><th></th>'+cols.map(c=>`<th data-c="${c.i}">${esc(c.h)}${SORT&&SORT.col===c.i?(SORT.dir<0?' ▼':' ▲'):''}</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>`; } }
+ h+=`<tr><td><button class="rowbtn" data-r="${ri}">✍</button></td>`;
+ for(const c of cols){ const gr=GREENSET.has(ri+','+c.i), isN=c.i===ROLE.name;
+ h+=`<td data-r="${ri}" data-c="${c.i}" contenteditable="true" class="${[isN?'namecell':'',gr?'enriched':''].filter(Boolean).join(' ')}">${cellRender(g(r,c.i))}</td>`; }
h+='</tr>';
- });
+ }
return h+'</tbody></table>';
}
-function cardsHTML(mode){
- let h='<div class="cards '+mode+'" id="cards" data-nav-grid>';
- DATA.rows.slice(0,MAXROWS).forEach((r,ri)=>{
- const c=contact(r);
- h+='<div class="card" data-r="'+ri+'">'+
- '<button class="rowbtn act" data-r="'+ri+'">✍ Draft</button>'+
- '<div class="co" data-field="Company/Venue">'+esc(c.company||'—')+'</div>'+
- (c.name?'<div class="nm" data-field="Contact Name">'+esc(c.name)+'</div>':'')+
- (c.position?'<div class="ro" data-field="Position">'+esc(c.position)+'</div>':'')+
- '<div class="meta">'+
- badge('✉', c.email, 'Email')+ badge('☎', c.phone, 'Phone')+
- badge('in', c.liC||c.liCo, 'LinkedIn')+ badge('★', c.mvpN, 'Mktg/VP')+
- '</div></div>';
- });
+function cardsHTML(rows,mode){
+ let h=`<div class="cards ${mode}" id="grid">`;
+ for(const [r,ri] of rows){ const c=contact(r), any=[...GREENSET].some(k=>k.split(',')[0]==ri);
+ h+=`<div class="card ${any?'enriched-card':''}" data-r="${ri}"><button class="rowbtn act" data-r="${ri}">✍ Draft</button>
+ <div class="co">${esc(c.company||'—')}</div>${c.name?`<div class="nm">${esc(c.name)}</div>`:''}${c.position?`<div class="ro">${esc(c.position)}</div>`:''}
+ <div class="meta">${badge('✉',c.email)}${badge('☎',c.phone)}${badge('in',c.liC||c.liCo)}${badge('★',c.mvpN)}</div></div>`; }
return h+'</div>';
}
-function badge(ic,val,label){ return '<span class="badge'+(val?' ok':'')+'" data-field="'+label+'">'+ic+' '+esc(label)+(val?' ✓':'')+'</span>'; }
-function cellRender(v){ const u=hrefOf(v); if(u&&/^https?:/i.test(v)) return '<a href="'+esc(u)+'" target="_blank" rel="noopener">'+esc(v.length>40?u.replace(/^https?:\/\/(www\.)?/,''):v)+'</a>'; return esc(v); }
-function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,m=>({'&':'&','<':'<','>':'>','"':'"'}[m])); }
-
+function badge(ic,v){return `<span class="badge${v?' ok':''}">${ic}${v?' ✓':''}</span>`}
+function cellRender(v){const u=hrefOf(v);if(u&&/^https?:/i.test(v))return `<a href="${esc(u)}" target="_blank" rel="noopener">${esc(v.length>40?u.replace(/^https?:\/\/(www\.)?/,''):v)}</a>`;return esc(v)}
+function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,m=>({'&':'&','<':'<','>':'>','"':'"'}[m]))}
+function contact(r){const nm=g(r,ROLE.name),co=g(r,ROLE.company);return{name:nm,first:(nm.split(/[\s,]+/)[0]||'').trim(),company:co,position:g(r,ROLE.position),email:g(r,ROLE.email)||g(r,ROLE.email2),phone:g(r,ROLE.phone)||g(r,ROLE.cphone),liC:hrefOf(g(r,ROLE.liC)),liCo:hrefOf(g(r,ROLE.liCo)),mvpN:g(r,ROLE.mvpN),mvpP:g(r,ROLE.mvpP),mvpL:hrefOf(g(r,ROLE.mvpL))}}
function wireGrid(){
- document.querySelectorAll('.rowbtn').forEach(b=> b.onclick=e=>{e.stopPropagation();openDraft(+b.dataset.r);});
- document.querySelectorAll('.card').forEach(c=> c.onclick=()=>openDraft(+c.dataset.r));
- document.querySelectorAll('td[contenteditable]').forEach(td=>{
- td.addEventListener('focus',()=>{editing=true; td._orig=td.textContent;});
- td.addEventListener('blur',()=>saveCell(td));
- td.addEventListener('keydown',e=>{ if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();td.blur();} });
- });
+ $$('.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=()=>{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)});
+ $$('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()}})});
}
-async function saveCell(td){
- editing=false;
- const val=td.textContent.trim();
- if(val===String(td._orig||'').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:val})})).json();
- 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); }
+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')}
}
// ---- draft composer ----------------------------------------------------------
-function openDraft(ri){
- curRow=ri; const c=contact(DATA.rows[ri]);
- $('#d-company').textContent=c.company||'(no company)';
- $('#d-who').textContent=[c.name,c.position].filter(Boolean).join(' · ')||'—';
- const F=$('#d-fields'); F.innerHTML='';
- const add=(k,v,link)=>{ if(!v)return; F.insertAdjacentHTML('beforeend','<div class="k">'+k+'</div><div>'+(link?'<a href="'+esc(link)+'" target="_blank" rel="noopener">'+esc(v)+'</a>':esc(v))+'</div>'); };
- add('Email',c.email,c.email?'mailto:'+c.email:''); add('Phone',c.phone,c.phone?'tel:'+c.phone.replace(/[^0-9+]/g,''):'');
- add('LinkedIn',c.liC?'Contact profile':(c.liCo?'Company page':''),c.liC||c.liCo);
- add('Marketing / VP',[c.mvpN,c.mvpP].filter(Boolean).join(' — '),c.mvpL);
- // enable channels that have data; pick a sensible default
- const avail={email:!!c.email, linkedin:!!(c.liC||c.mvpL||c.liCo), phone:!!c.phone};
- document.querySelectorAll('#chanseg button').forEach(b=>{ b.classList.toggle('chdis',!avail[b.dataset.c]); });
- curChan = avail[curChan]?curChan : (avail.email?'email':avail.linkedin?'linkedin':avail.phone?'phone':'email');
- syncChanUI(); regen();
- $('#scrim').style.display='block'; $('#drawer').classList.add('open');
-}
-function closeDraft(){ $('#scrim').style.display='none'; $('#drawer').classList.remove('open'); }
-function syncChanUI(){ document.querySelectorAll('#chanseg button').forEach(b=>b.classList.toggle('on',b.dataset.c===curChan));
- $('#d-subject').style.display = curChan==='email'?'block':'none';
- $('#act-go').textContent = curChan==='email'?'Open in Email':curChan==='linkedin'?'Open LinkedIn':'Call';
+function openDraft(ri){curRow=ri;const c=contact(DATA.rows[ri]);
+ $('#d-company').textContent=c.company||'(no company)';$('#d-who').textContent=[c.name,c.position].filter(Boolean).join(' · ')||'—';
+ const F=$('#d-fields');F.innerHTML='';const add=(k,v,l)=>{if(!v)return;F.insertAdjacentHTML('beforeend',`<div class="k">${k}</div><div>${l?`<a href="${esc(l)}" target="_blank" rel="noopener">${esc(v)}</a>`:esc(v)}</div>`)};
+ add('Email',c.email,c.email?'mailto:'+c.email:'');add('Phone',c.phone,c.phone?'tel:'+c.phone.replace(/[^0-9+]/g,''):'');add('LinkedIn',c.liC?'Contact':(c.liCo?'Company':''),c.liC||c.liCo);add('Marketing/VP',[c.mvpN,c.mvpP].filter(Boolean).join(' — '),c.mvpL);
+ const av={email:!!c.email,linkedin:!!(c.liC||c.mvpL||c.liCo),phone:!!c.phone};
+ $$('#chanseg button').forEach(b=>b.classList.toggle('chdis',!av[b.dataset.c]));
+ curChan=av[curChan]?curChan:(av.email?'email':av.linkedin?'linkedin':av.phone?'phone':'email');
+ syncChan();regen();$('#scrim').style.display='block';$('#drawer').classList.add('open');
}
-function regen(){
- const c=contact(DATA.rows[curRow]); const p=$('#purpose').value, tone=$('#tone').value;
- const d=buildDraft(c,curChan,p,tone);
- $('#d-subject').value=d.subject||''; $('#d-body').value=d.body;
- $('#d-target').textContent = d.target?('→ '+d.target):'';
+function closeDraft(){$('#scrim').style.display='none';$('#drawer').classList.remove('open')}
+function syncChan(){$$('#chanseg button').forEach(b=>b.classList.toggle('on',b.dataset.c===curChan));$('#d-subject').style.display=curChan==='email'?'block':'none';$('#act-go').textContent=curChan==='email'?'Open in Email':curChan==='linkedin'?'Open LinkedIn':'Call'}
+function regen(){const c=contact(DATA.rows[curRow]),d=buildDraft(c,curChan,$('#purpose').value,$('#tone').value);$('#d-subject').value=d.subject||'';$('#d-body').value=d.body;$('#d-target').textContent=d.target?('→ '+d.target):''}
+function buildDraft(c,chan,purpose,tone){const first=(c.first&&/^[A-Za-z]/.test(c.first))?c.first:'there',co=c.company||'your team';
+ const hi=tone==='formal'?('Dear '+(c.name||first)+','):(tone==='direct'?('Hi '+first+' —'):('Hi '+first+','));
+ const val={partnership:`I run partnerships for RENTV (Real Estate News Television), the CRE industry's video news network. I'd love to explore ways ${co} and RENTV could work together — featuring your deals and leadership on our broadcasts and co-marketing to our commercial-real-estate audience.`,sponsorship:`RENTV is lining up sponsors for our upcoming commercial real estate conference, and ${co} stood out as a strong fit — on-camera visibility, stage presence, and direct access to CRE principals, brokers, and capital sources.`,speaker:`We're building the panel lineup for RENTV's upcoming CRE conference and I'd like to invite ${co} to join as a speaker.`,followup:`Following up on RENTV's outreach to ${co} — wanted to make sure this reached the right person and see if a short call this week makes sense.`}[purpose];
+ const close=tone==='formal'?'Kind regards,\nSteve Bloom\nRENTV — Real Estate News Television':'Best,\nSteve Bloom\nRENTV — Real Estate News Television';
+ if(chan==='email'){const subj={partnership:'RENTV × '+co+' — partnership',sponsorship:co+' as a RENTV CRE conference sponsor',speaker:'Speaker invite — RENTV CRE conference',followup:'Following up — RENTV × '+co}[purpose];
+ return{subject:subj,body:`${hi}\n\n${val}\n\nWould you be open to a 15-minute call this week?\n\n${close}`,target:c.email||'(no email)'}}
+ if(chan==='linkedin'){const note=`${hi.replace(/ —$/,'')} — I run partnerships at RENTV (CRE video news). ${purpose==='sponsorship'?"We're selecting sponsors for our CRE conference and "+co+' is a great fit.':purpose==='speaker'?"I'd love to invite "+co+' onto a panel at our CRE conference.':"I'd love to connect about ways RENTV and "+co+' could work together.'} Open to a quick chat?`;
+ return{subject:'',body:note.slice(0,600),target:(c.liC||c.mvpL||c.liCo)||'(no LinkedIn)'}}
+ return{subject:'',target:c.phone||'(no phone)',body:`CALL SCRIPT — ${co}${c.name?' ('+c.name+')':''}\n\n• Opener: "Hi${c.name?' '+first:''}, this is Steve Bloom with RENTV, the commercial real estate news network."\n• Reason: ${val}\n• Ask: "Any 15 minutes this week to walk through it?"\n• Gatekeeper: ask for ${c.mvpN||'the marketing or partnerships lead'}.`}
}
-function buildDraft(c,chan,purpose,tone){
- const first=(c.first && /^[A-Za-z]/.test(c.first))?c.first:'there', co=c.company||'your team';
- const hi = tone==='formal'?('Dear '+(c.name||first)+','): (tone==='direct'?('Hi '+first+' —'):('Hi '+first+','));
- const val={
- partnership:`I run partnerships for RENTV (Real Estate News Television), the CRE industry's video news network. I'd love to explore ways ${co} and RENTV could work together — from featuring your deals and leadership on our broadcasts to co-marketing across our commercial-real-estate audience.`,
- sponsorship:`RENTV is lining up sponsors for our upcoming commercial real estate conference, and ${co} stood out as a strong fit. Sponsors get on-camera visibility, stage presence, and direct access to an audience of CRE principals, brokers, and capital sources.`,
- speaker:`We're building the panel lineup for RENTV's upcoming CRE conference and I'd like to invite ${(c.mvpN||c.name)?('someone from '+co):(co)} to join as a speaker. Your perspective would resonate with our audience of commercial real estate leaders.`,
- followup:`Following up on RENTV's outreach to ${co} — I wanted to make sure this reached the right person and see whether it's worth a short call this week.`
- }[purpose];
- const close = tone==='formal'?'Kind regards,\nSteve Bloom\nRENTV — Real Estate News Television':'Best,\nSteve Bloom\nRENTV — Real Estate News Television';
- if(chan==='email'){
- const subj={partnership:'RENTV × '+co+' — partnership',sponsorship:co+' as a RENTV CRE conference sponsor',
- speaker:'Speaker invite — RENTV CRE conference',followup:'Following up — RENTV × '+co}[purpose];
- return {subject:subj, body:`${hi}\n\n${val}\n\nWould you be open to a 15-minute call this week? Happy to work around your schedule.\n\n${close}`,
- target:c.email||'(no email on file)'};
- }
- if(chan==='linkedin'){
- const who = c.mvpN?`${c.mvpN}${c.mvpP?' ('+c.mvpP+')':''} at ${co}`:co;
- const note=`${hi.replace(/ —$/,'')} — I run partnerships at RENTV (CRE video news). ${purpose==='sponsorship'?'We\'re selecting sponsors for our CRE conference and '+co+' is a great fit.':purpose==='speaker'?'I\'d love to invite '+co+' onto a panel at our CRE conference.':'I\'d love to connect about ways RENTV and '+co+' could work together.'} Open to a quick chat?`;
- return {subject:'', body:note.slice(0,600), target:(c.liC||c.mvpL||c.liCo)||'(no LinkedIn on file)'};
- }
- // phone script
- return {subject:'', target:c.phone||'(no phone on file)',
- body:`CALL SCRIPT — ${co}${c.name?' ('+c.name+')':''}\n\n• Opener: "Hi${c.name?' '+first:''}, this is Steve Bloom with RENTV, the commercial real estate news network."\n• Reason: ${val}\n• Ask: "Is there a good 15 minutes this week to walk through it?"\n• If gatekeeper: ask for ${c.mvpN||'the marketing or partnerships lead'}.\n• Leave-behind: offer to email details${c.email?' to '+c.email:''}.`};
-}
-async function doEnhance(){
- const btn=$('#enhance'); const old=btn.textContent; btn.textContent='✨ thinking…'; btn.disabled=true;
- const c=contact(DATA.rows[curRow]);
- const prompt=`Rewrite this ${curChan} outreach so it sounds natural, concise and professional. Keep it truthful, keep the sender "Steve Bloom, RENTV". Return ONLY the rewritten message, no preamble.\n\n---\n${$('#d-body').value}`;
- try{ const r=await (await fetch('/api/enhance',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt})})).json();
- if(r.ok && r.text) $('#d-body').value=r.text; else alert(r.error||'local model unavailable'); }
- catch(e){ alert('enhance failed: '+e.message); }
- finally{ btn.textContent=old; btn.disabled=false; }
-}
-function doGo(){
- const c=contact(DATA.rows[curRow]);
- if(curChan==='email'){ const to=c.email; if(!to)return alert('No email on file.');
- location.href='mailto:'+to+'?subject='+encodeURIComponent($('#d-subject').value)+'&body='+encodeURIComponent($('#d-body').value); }
- else if(curChan==='linkedin'){ const u=c.liC||c.mvpL||c.liCo; if(!u)return alert('No LinkedIn on file.'); navigator.clipboard?.writeText($('#d-body').value); window.open(u,'_blank','noopener'); }
- else { const p=c.phone; if(!p)return alert('No phone on file.'); location.href='tel:'+p.replace(/[^0-9+]/g,''); }
-}
-
-// ---- events ------------------------------------------------------------------
-$('#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).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;
-$('#enhance').onclick=doEnhance; $('#act-go').onclick=doGo;
-$('#act-copy').onclick=()=>{ navigator.clipboard?.writeText(($('#d-subject').value?('Subject: '+$('#d-subject').value+'\n\n'):'')+$('#d-body').value); $('#act-copy').textContent='✓ Copied'; setTimeout(()=>$('#act-copy').textContent='⧉ Copy draft',1200); };
-document.addEventListener('keydown',e=>{ if(e.key==='Escape')closeDraft(); });
+async function doEnhance(){const b=$('#enhance'),o=b.textContent;b.textContent='✨ thinking…';b.disabled=true;
+ try{const r=await (await fetch('/api/enhance',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt:`Rewrite this ${curChan} outreach so it sounds natural, concise, professional. Keep it truthful, sender "Steve Bloom, RENTV". Return ONLY the message.\n\n---\n${$('#d-body').value}`})})).json();
+ if(r.ok&&r.text)$('#d-body').value=r.text;else alert(r.error||'local model unavailable')}catch(e){alert('enhance failed')}finally{b.textContent=o;b.disabled=false}}
+function doGo(){const c=contact(DATA.rows[curRow]);if(curChan==='email'){if(!c.email)return alert('No email');location.href='mailto:'+c.email+'?subject='+encodeURIComponent($('#d-subject').value)+'&body='+encodeURIComponent($('#d-body').value)}else if(curChan==='linkedin'){const u=c.liC||c.mvpL||c.liCo;if(!u)return alert('No LinkedIn');navigator.clipboard?.writeText($('#d-body').value);window.open(u,'_blank','noopener')}else{if(!c.phone)return alert('No phone');location.href='tel:'+c.phone.replace(/[^0-9+]/g,'')}}
-// ---- boot --------------------------------------------------------------------
-(async()=>{ await loadMeta(); await pull(true); pullFormat(); setInterval(()=>pull(false),12000); })();
+// ---- events + boot -----------------------------------------------------------
+$('#tab').onchange=e=>{GID=+e.target.value;LS.set('gid',GID);loadTab()};
+$$('#viewseg button').forEach(b=>b.onclick=()=>{VIEW=b.dataset.v;LS.set('view',VIEW);render()});
+$('#railtog').onclick=()=>{$('#rail').classList.toggle('hide');if(!$('#rail').classList.contains('hide'))buildRail()};
+$('#refresh').onclick=async()=>{setSync('load');$('#refresh').textContent='↻ refreshing…';try{await fetch('/api/refresh',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});await loadMeta();$('#tab').value=GID;await loadTab()}catch(e){setSync('err')}$('#refresh').textContent='↻ Refresh'};
+$('#dclose').onclick=closeDraft;$('#scrim').onclick=closeDraft;
+$$('#chanseg button').forEach(b=>b.onclick=()=>{if(b.classList.contains('chdis'))return;curChan=b.dataset.c;syncChan();regen()});
+$('#purpose').onchange=regen;$('#tone').onchange=regen;$('#regen').onclick=regen;$('#enhance').onclick=doEnhance;$('#act-go').onclick=doGo;
+$('#act-copy').onclick=()=>{navigator.clipboard?.writeText(($('#d-subject').value?('Subject: '+$('#d-subject').value+'\n\n'):'')+$('#d-body').value);$('#act-copy').textContent='✓ Copied';setTimeout(()=>$('#act-copy').textContent='⧉ Copy draft',1200)};
+document.addEventListener('keydown',e=>{if(e.key==='Escape')closeDraft()});
+(async()=>{await loadMeta();await loadTab()})();
</script>
</body>
</html>
diff --git a/snapshot.py b/snapshot.py
new file mode 100644
index 0000000..9ca09ad
--- /dev/null
+++ b/snapshot.py
@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+"""
+snapshot.py — pull the ENTIRE workbook into OUR OWN local copy (data/snapshot.json) in
+just a few batch reads, so the viewer never live-reads the Google Sheet per tab-switch
+(that was the 60-reads/min quota that blanked tabs). One values.batchGet for all tab
+values + one includeGridData get for all green (enriched) cell maps. Re-run to refresh.
+"""
+import lib, json, urllib.request, urllib.parse, time, os
+
+def api(url, tok):
+ r = urllib.request.Request(url, headers={"Authorization": f"Bearer {tok}"})
+ return json.load(urllib.request.urlopen(r))
+
+def rng(title):
+ return "ranges=" + urllib.parse.quote("'" + title.replace("'", "''") + "'", safe="")
+
+def build(tok=None):
+ tok = tok or lib.access_token()
+ sheets = [(s["properties"]["sheetId"], s["properties"]["title"])
+ for s in lib.get_meta(tok)["sheets"]]
+ # 1) all values in ONE call
+ qs = "&".join(rng(t) for _, t in sheets)
+ vals = api(f"{lib.API}/{lib.SID}/values:batchGet?{qs}&majorDimension=ROWS", tok)
+ value_ranges = vals.get("valueRanges", [])
+ # 2) all green (enriched) cell backgrounds in ONE call
+ G = lib.GREEN
+ 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
+ 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", [])
+ for sh in fmt.get("sheets", [])}
+ def header_row(rows):
+ """The real header is the row (of the first 4) with the most non-empty cells —
+ so a merged TITLE row above the header (panels, ARIZONA BROKERAGE) is skipped."""
+ 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
+ tabs = []
+ for (gid, title), vr in zip(sheets, value_ranges):
+ rows = vr.get("values", [])
+ if not rows:
+ tabs.append({"gid": gid, "title": title, "headers": [], "rows": [], "green": []})
+ continue
+ hr = header_row(rows)
+ width = max((len(r) for r in rows), default=0)
+ headers = [(rows[hr][i].strip() if i < len(rows[hr]) else "") for i in range(width)]
+ body = [[(r[i] if i < len(r) else "") for i in range(width)] for r in rows[hr + 1:]]
+ 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")):
+ green.append(f"{ri-hr-1},{ci}")
+ tabs.append({"gid": gid, "title": title, "headers": headers, "rows": body, "green": green})
+ return {"generated": int(time.time()), "tabs": tabs}
+
+def main():
+ snap = build()
+ os.makedirs("data", exist_ok=True)
+ json.dump(snap, open("data/snapshot.json", "w"))
+ print(f"snapshot: {len(snap['tabs'])} tabs · "
+ f"{sum(len(t['rows']) for t in snap['tabs'])} rows · "
+ f"{sum(len(t['green']) for t in snap['tabs'])} green cells")
+
+if __name__ == "__main__":
+ main()
← 48eb172 Split Contact Name -> First Name + Last Name across ALL 19 n
·
back to Rentv Sheet Enrich
·
5x sweep: default landing tab = Unique Contacts (was tab[0] 4ad282d →