← 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
// 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>