← back to Rentv 2026
public/deals-log.html
176 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RENTV — Deals Log · every deal ever found + change history</title>
<style>
:root{ --a:#c8102e; --ink:#14171a; --sub:#5b636b; --ln:#e4e7ea; --bg:#fff; --wa:#f5f6f8; --ok:#1a7f37; --chg:#b8860b; }
*{box-sizing:border-box}
body{margin:0;background:var(--wa);color:var(--ink);font:14px/1.5 'Inter',system-ui,-apple-system,sans-serif}
header{background:var(--bg);border-bottom:2px solid var(--a);padding:14px 22px;position:sticky;top:0;z-index:10;display:flex;align-items:baseline;gap:14px;flex-wrap:wrap}
header h1{font:700 19px/1 'Playfair Display',Georgia,serif;margin:0}
header h1 span{color:var(--a)}
header .meta{font-size:12px;color:var(--sub)} header .meta b{color:var(--ink)}
.bar{background:var(--bg);border-bottom:1px solid var(--ln);padding:11px 22px;display:flex;gap:10px;flex-wrap:wrap;align-items:center;position:sticky;top:49px;z-index:9}
.bar input[type=text]{flex:1;min-width:220px;padding:8px 11px;border:1px solid var(--ln);border-radius:8px;font-size:13px}
.bar select,.bar button{padding:7px 10px;border:1px solid var(--ln);border-radius:8px;background:var(--bg);font-size:12px;cursor:pointer}
.bar .chip{padding:6px 11px;border:1px solid var(--ln);border-radius:16px;background:var(--bg);font-size:12px;cursor:pointer;user-select:none}
.bar .chip.on{border-color:var(--a);color:var(--a);background:rgba(200,16,46,.06)}
.cols{padding:8px 22px;background:var(--bg);border-bottom:1px solid var(--ln);font-size:11px;color:var(--sub);display:flex;gap:12px;flex-wrap:wrap;align-items:center}
.cols label{display:inline-flex;align-items:center;gap:4px;cursor:pointer}
.wrap{padding:16px 22px 60px}
table{width:100%;border-collapse:collapse;background:var(--bg);font-size:13px}
th,td{text-align:left;padding:9px 11px;border-bottom:1px solid var(--ln);vertical-align:top}
th{position:sticky;top:96px;background:var(--wa);font-size:11px;text-transform:uppercase;letter-spacing:.4px;color:var(--sub);cursor:pointer;user-select:none;white-space:nowrap}
th.asc::after{content:' ▲';color:var(--a)} th.desc::after{content:' ▼';color:var(--a)}
tbody tr{cursor:pointer} tbody tr:hover td{background:var(--wa)}
td.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
.tag{display:inline-block;padding:1px 7px;border-radius:10px;font-size:11px;font-weight:600;background:var(--wa);border:1px solid var(--ln)}
.chgbadge{display:inline-block;min-width:20px;text-align:center;padding:1px 7px;border-radius:10px;font-size:11px;font-weight:700;background:rgba(184,134,11,.12);color:var(--chg)}
.chgbadge.zero{background:var(--wa);color:var(--sub);font-weight:400}
.when{font-size:11px;color:var(--sub);white-space:nowrap}
.drill{color:var(--a);text-decoration:underline dotted;text-underline-offset:2px}
.empty{padding:60px;text-align:center;color:var(--sub)}
/* LEFT detail drawer (no centered modal) — row click slides the change-history in from the left */
#ov{position:fixed;top:0;left:0;width:420px;height:100vh;z-index:60;transform:translateX(-100%);transition:transform .2s;overflow-y:auto;pointer-events:none}
#ov.on{transform:translateX(0);pointer-events:auto}
body.panel-open .wrap{margin-left:440px}
@media(max-width:900px){ body.panel-open .wrap{margin-left:0} #ov{width:90vw} }
#modal{width:100%;min-height:100vh;background:var(--bg);border-right:2px solid var(--a);box-shadow:4px 0 24px rgba(0,0,0,.12);padding:20px 22px;position:relative}
#modal h3{margin:0 0 3px;font:700 18px/1.25 'Playfair Display',Georgia,serif}
#modal .maddr{color:var(--sub);font-size:12px;margin-bottom:12px}
#modal .x{position:absolute;top:12px;right:14px;background:none;border:0;font-size:22px;cursor:pointer;color:var(--sub)}
.tl{border-left:2px solid var(--ln);margin-left:6px;padding-left:16px}
.ev{position:relative;padding:8px 0}
.ev::before{content:'';position:absolute;left:-23px;top:13px;width:9px;height:9px;border-radius:50%;background:var(--a)}
.ev.created::before{background:var(--ok)}
.ev .ts{font-size:11px;color:var(--sub)}
.ev .lbl{font-weight:700;font-size:12px;text-transform:uppercase;letter-spacing:.4px}
.ev.created .lbl{color:var(--ok)} .ev.changed .lbl{color:var(--chg)}
.diff{font-size:12.5px;margin:3px 0 0;padding-left:2px}
.diff .f{color:var(--sub)} .diff .from{color:#b00;text-decoration:line-through} .diff .to{color:var(--ok);font-weight:600}
</style>
</head>
<body>
<header>
<h1>REN<span>TV</span> · Deals Log</h1>
<span class="meta" id="hmeta">loading…</span>
<span class="meta" style="margin-left:auto"><a href="/backend.html" style="color:var(--a)">← backend</a></span>
</header>
<div class="bar">
<input type="text" id="q" placeholder="search every field — title, city, state, address, summary… (space = AND)">
<select id="fType"><option value="">All property types</option></select>
<select id="fTxn"><option value="">All deal types</option></select>
<span class="chip" id="cChanged" title="Only deals whose fields have changed since first found">✎ Changed only</span>
<button id="reset" title="Clear filters + sort">↺ Reset</button>
</div>
<div class="cols" id="cols"></div>
<div class="wrap"><table id="tbl"><thead><tr id="head"></tr></thead><tbody id="body"><tr><td class="empty">loading…</td></tr></tbody></table></div>
<div id="ov"><div id="modal"><button class="x" onclick="closeOv()">✕</button><div id="mbody"></div></div></div>
<script>
const $=s=>document.querySelector(s), $$=s=>Array.from(document.querySelectorAll(s));
const esc=s=>String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
// created date AND time in the admin's local tz (Steve HARD rule for admin cards), full ISO in title=
function fmtDT(v){ if(!v) return ''; const d=new Date(v); if(isNaN(d)) return ''; return d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }
function dtCell(v){ const s=fmtDT(v); return s?`<span class="when" title="${esc(v)}">${esc(s)}</span>`:'—'; }
const money=v=>v==null?'—':'$'+(+v).toLocaleString();
// COLUMNS — start tight (Steve rule), togglable, persisted.
const COLS=[
{k:'title', l:'Deal', def:1, cell:d=>`<b>${esc(d.title||'—')}</b>${d.address?`<div class="when">${esc(d.address)}</div>`:''}`},
{k:'txn_type',l:'Txn', def:1, cell:d=>d.txn_type?`<span class="tag">${esc(d.txn_type)}</span>`:'—'},
{k:'property_type',l:'Type', def:1, cell:d=>d.property_type?`<span class="tag">${esc(d.property_type)}</span>`:'—'},
{k:'loc', l:'Location', def:1, cell:d=>esc([d.city,d.state].filter(Boolean).join(', ')||'—'), sort:d=>`${d.state||''} ${d.city||''}`},
{k:'amount', l:'Amount', def:1, num:1, cell:d=>esc(d.amount_label||money(d.amount)), sort:d=>d.amount||0},
{k:'size_label',l:'Size', def:0, cell:d=>esc(d.size_label||'—')},
{k:'changes', l:'Changes', def:1, num:1, cell:d=>`<span class="chgbadge ${d.changes?'':'zero'}">${d.changes||0}</span>`, sort:d=>d.changes||0},
{k:'revisions',l:'Revisions', def:0, num:1, cell:d=>d.revisions||1, sort:d=>d.revisions||1},
{k:'seen_count',l:'Seen×', def:0, num:1, cell:d=>d.seen_count||1, sort:d=>d.seen_count||1},
{k:'first_seen',l:'First found',def:1, cell:d=>dtCell(d.first_seen), sort:d=>d.first_seen||''},
{k:'last_seen',l:'Last found', def:1, cell:d=>dtCell(d.last_seen), sort:d=>d.last_seen||''},
];
const VIS=(function(){let s={};try{s=JSON.parse(localStorage.getItem('rentvDealCols')||'{}')}catch(e){}const o={};COLS.forEach(c=>o[c.k]=(c.k in s)?!!s[c.k]:!!c.def);return o})();
function saveVis(){try{localStorage.setItem('rentvDealCols',JSON.stringify(VIS))}catch(e){}}
let ALL=[], SORT={k:'last_seen',dir:-1};
const F={q:'',type:'',txn:'',changed:false};
function buildCols(){
$('#cols').innerHTML='<b>Columns:</b> '+COLS.map(c=>`<label><input type="checkbox" data-c="${c.k}" ${VIS[c.k]?'checked':''}>${esc(c.l)}</label>`).join('');
}
function visCols(){ return COLS.filter(c=>VIS[c.k]); }
function buildHead(){
$('#head').innerHTML=visCols().map(c=>{const s=SORT.k===c.k?(SORT.dir>0?' asc':' desc'):'';return `<th data-k="${c.k}" class="${c.num?'num':''}${s}">${esc(c.l)}</th>`;}).join('');
}
function passF(d){
if(F.type&&(d.property_type||'').toLowerCase()!==F.type.toLowerCase()) return false;
if(F.txn&&(d.txn_type||'').toLowerCase()!==F.txn.toLowerCase()) return false;
if(F.changed&&!(d.changes>0)) return false;
if(F.q){ const hay=Object.values(d).map(v=>v==null?'':String(v)).join(' ').toLowerCase();
if(!F.q.split(/\s+/).filter(Boolean).every(t=>hay.includes(t))) return false; }
return true;
}
function sortRows(rows){
const c=COLS.find(x=>x.k===SORT.k)||COLS[0]; const g=c.sort||(d=>d[c.k]);
return rows.slice().sort((a,b)=>{let x=g(a),y=g(b);
const xn=(typeof x==='number'),yn=(typeof y==='number');
if(!xn) x=String(x==null?'':x).toLowerCase(); if(!yn) y=String(y==null?'':y).toLowerCase();
if(x<y) return -SORT.dir; if(x>y) return SORT.dir; return 0;});
}
function render(){
buildHead();
const rows=sortRows(ALL.filter(passF));
const cols=visCols();
$('#body').innerHTML = rows.length? rows.map(d=>`<tr data-id="${esc(d.id)}">`+cols.map(c=>`<td class="${c.num?'num':''}">${c.cell(d)}</td>`).join('')+`</tr>`).join('')
: '<tr><td class="empty" colspan="'+cols.length+'">no deals match these filters.</td></tr>';
$('#hmeta').innerHTML=`<b>${rows.length}</b> of <b>${ALL.length}</b> deals ever found · <b>${ALL.filter(d=>d.changes>0).length}</b> with changes`;
}
// ── change-history drill-down (Steve: every datum drills to deeper data) ──
async function openHistory(id){
const ov=$('#ov'), mb=$('#mbody'); ov.classList.add('on'); document.body.classList.add('panel-open'); mb.innerHTML='<p class="empty">loading history…</p>';
let h; try{ h=await (await fetch('/api/deals/'+encodeURIComponent(id)+'/history')).json(); }catch(e){ mb.innerHTML='<p class="empty">could not load history</p>'; return; }
if(h.error){ mb.innerHTML='<p class="empty">'+esc(h.error)+'</p>'; return; }
const c=h.current||{};
const evs=(h.history||[]).slice().reverse().map(ev=>{
const diffs=(ev.changes||[]).map(ch=>`<div class="diff"><span class="f">${esc(ch.field)}:</span> <span class="from">${esc(ch.from==null?'∅':ch.from)}</span> → <span class="to">${esc(ch.to==null?'∅':ch.to)}</span></div>`).join('');
return `<div class="ev ${ev.event}"><div class="ts">${esc(fmtDT(ev.ts))}</div><div class="lbl">${ev.event==='created'?'✚ First found':'✎ Changed'}</div>${diffs||(ev.event==='created'?'<div class="diff f">initial capture</div>':'')}</div>`;
}).join('');
mb.innerHTML=`<h3>${esc(c.title||'Deal '+h.id)}</h3>
<div class="maddr">${esc([c.city,c.state].filter(Boolean).join(', '))}${c.address?' · '+esc(c.address):''} · ${esc(c.amount_label||money(c.amount))} · ${esc(c.txn_type||'')} ${esc(c.property_type||'')}
${c.url?`· <a class="drill" href="${esc(c.url)}" target="_blank" rel="noopener noreferrer">source ↗</a>`:''}</div>
<div class="when" style="margin-bottom:10px">First found ${esc(fmtDT(h.first_seen))} · last found ${esc(fmtDT(h.last_seen))} · seen ${h.seen_count}× · ${h.revisions} revision(s)</div>
<div class="tl">${evs||'<p class="empty">no history</p>'}</div>`;
}
function closeOv(){ $('#ov').classList.remove('on'); document.body.classList.remove('panel-open'); }
$('#ov').addEventListener('click',e=>{ if(e.target.id==='ov') closeOv(); });
document.addEventListener('keydown',e=>{ if(e.key==='Escape') closeOv(); });
// ── wiring ──
$('#body').addEventListener('click',e=>{ const tr=e.target.closest('tr[data-id]'); if(tr) openHistory(tr.dataset.id); });
$('#head').addEventListener('click',e=>{ const th=e.target.closest('th[data-k]'); if(!th) return; const k=th.dataset.k;
if(SORT.k===k) SORT.dir*=-1; else { SORT.k=k; SORT.dir=(k==='first_seen'||k==='last_seen'||k==='amount'||k==='changes'||k==='revisions'||k==='seen_count')?-1:1; } render(); });
$('#cols').addEventListener('change',e=>{ const cb=e.target.closest('input[data-c]'); if(!cb) return; VIS[cb.dataset.c]=cb.checked; saveVis(); render(); });
$('#q').addEventListener('input',()=>{ clearTimeout(window._t); window._t=setTimeout(()=>{F.q=$('#q').value.trim().toLowerCase();render();},150); });
$('#fType').addEventListener('change',()=>{F.type=$('#fType').value;render();});
$('#fTxn').addEventListener('change',()=>{F.txn=$('#fTxn').value;render();});
$('#cChanged').addEventListener('click',()=>{F.changed=!F.changed;$('#cChanged').classList.toggle('on',F.changed);render();});
$('#reset').addEventListener('click',()=>{F.q='';F.type='';F.txn='';F.changed=false;$('#q').value='';$('#fType').value='';$('#fTxn').value='';$('#cChanged').classList.remove('on');SORT={k:'last_seen',dir:-1};render();});
async function boot(){
buildCols();
let d; try{ d=await (await fetch('/api/deals/registry')).json(); }catch(e){ $('#body').innerHTML='<tr><td class="empty">failed to load registry — admin login required.</td></tr>'; return; }
ALL=d.deals||[];
const types=[...new Set(ALL.map(x=>x.property_type).filter(Boolean))].sort();
const txns=[...new Set(ALL.map(x=>x.txn_type).filter(Boolean))].sort();
$('#fType').innerHTML='<option value="">All property types</option>'+types.map(t=>`<option>${esc(t)}</option>`).join('');
$('#fTxn').innerHTML='<option value="">All deal types</option>'+txns.map(t=>`<option>${esc(t)}</option>`).join('');
render();
}
boot();
</script>
</body>
</html>