[object Object]

← back to Nationalrealestate

TK-10155: listings.html — color-coded lifecycle state chip + state filter/facet + withdrawn (preserved) snapshot view

6193a68041619310f0d63557cf13b6395de7343c · 2026-08-02 23:34:07 -0700 · Steve

Files touched

Diff

commit 6193a68041619310f0d63557cf13b6395de7343c
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun Aug 2 23:34:07 2026 -0700

    TK-10155: listings.html — color-coded lifecycle state chip + state filter/facet + withdrawn (preserved) snapshot view
---
 public/listings.html | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 114 insertions(+), 2 deletions(-)

diff --git a/public/listings.html b/public/listings.html
index a2bcf96..01437f6 100644
--- a/public/listings.html
+++ b/public/listings.html
@@ -38,6 +38,35 @@
   .viewon:hover{text-decoration:underline;}
   .stat{color:var(--fg);} .stat b{color:var(--gold);}
   .src-tag{font-family:ui-monospace,Menlo,monospace;font-size:11px;color:var(--muted);}
+  /* TK-10155: lifecycle state chips — color-coded */
+  .lc-chip{display:inline-block;border-radius:999px;padding:1px 9px;font-size:10.5px;font-weight:700;
+    text-transform:uppercase;letter-spacing:.03em;white-space:nowrap;border:1px solid transparent;}
+  .lc-new{background:rgba(46,160,67,.16);color:#4ac26b;border-color:#2a5c38;}
+  .lc-active{background:rgba(56,139,253,.16);color:#6aa8ff;border-color:#264a7a;}
+  .lc-stale{background:rgba(210,153,34,.16);color:#e0b04b;border-color:#6b5320;}
+  .lc-withdrawn{background:rgba(200,60,60,.18);color:#ff6b6b;border-color:#7a2a2a;}
+  .lc-closed{background:rgba(120,130,145,.16);color:#9aa6b5;border-color:#3a424e;}
+  .lc-reappeared{background:rgba(160,110,220,.16);color:#b78af0;border-color:#4a3a6b;}
+  /* withdrawn/preserved section */
+  #withdrawnWrap{display:none;padding:14px 18px;border-top:1px solid var(--line);}
+  #withdrawnWrap.on{display:block;}
+  #withdrawnWrap h3{margin:0 0 4px;font-size:15px;color:var(--gold);}
+  #withdrawnWrap .sub{color:var(--muted);font-size:12px;margin-bottom:12px;}
+  .wd-card{border:1px solid var(--line);border-radius:10px;background:var(--panel);margin-bottom:10px;overflow:hidden;}
+  .wd-head{display:flex;gap:12px;align-items:center;flex-wrap:wrap;padding:10px 14px;cursor:pointer;}
+  .wd-head:hover{background:rgba(255,255,255,.02);}
+  .wd-addr{font-weight:600;color:var(--fg);}
+  .wd-meta{color:var(--muted);font-size:12px;}
+  .wd-meta b{color:var(--fg);}
+  .wd-when{margin-left:auto;color:var(--muted);font-size:11px;white-space:nowrap;}
+  .wd-body{display:none;padding:10px 14px 14px;border-top:1px solid var(--line);background:var(--bg);}
+  .wd-body.on{display:block;}
+  .wd-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:6px 18px;font-size:12px;}
+  .wd-grid .k{color:var(--muted);} .wd-grid .v{color:var(--fg);word-break:break-word;}
+  .wd-empty{padding:30px;text-align:center;color:var(--muted);border:1px dashed var(--line);border-radius:10px;}
+  .toggle-btn{background:var(--bg);border:1px solid var(--line);color:var(--muted);border-radius:8px;
+    padding:7px 12px;font-size:13px;cursor:pointer;}
+  .toggle-btn.on{color:var(--gold);border-color:var(--gold);}
 </style>
 <link rel="stylesheet" href="/nav-drawer.css">
 <link rel="stylesheet" href="/geo-search.css">
@@ -63,7 +92,24 @@
   <select id="countySel"><option value="">All counties</option></select>
   <span class="lbl">Source</span>
   <select id="sourceSel"><option value="">All sources</option></select>
+  <span class="lbl">Lifecycle</span>
+  <select id="stateSel">
+    <option value="">All states</option>
+    <option value="new">New</option>
+    <option value="active">Active</option>
+    <option value="stale">Stale</option>
+    <option value="withdrawn">Withdrawn</option>
+    <option value="closed">Closed</option>
+    <option value="reappeared">Reappeared</option>
+  </select>
   <input type="text" id="q" placeholder="search address / firm">
+  <button id="wdToggle" class="toggle-btn" title="Show withdrawn listings with their preserved snapshot">🗄️ Withdrawn (preserved)</button>
+</div>
+<!-- TK-10155: withdrawn (preserved) view — pulled listings whose full property info was saved -->
+<div id="withdrawnWrap">
+  <h3>🗄️ Withdrawn — Preserved Snapshots</h3>
+  <div class="sub">Listings that disappeared from the source feed (were pulled/withdrawn). Their full property info was saved at withdrawal time, so the data is still browsable after the listing vanished from the vendor. Expand a row to see the preserved snapshot.</div>
+  <div id="withdrawnList"><div id="loading">loading withdrawn…</div></div>
 </div>
 <div class="cg-shell">
   <div class="cg-rail" id="cgRail"></div>
@@ -79,6 +125,11 @@ let grid = null;
 let ALL = [];
 
 function firmChip(r){ return r.firm_name ? `<span class="firm-chip">🏢 ${esc(r.firm_name)}</span>` : `<span class="src-tag">firm —</span>`; }
+// TK-10155: color-coded lifecycle state chip
+function lcChip(state){
+  if (!state) return '';
+  return `<span class="lc-chip lc-${esc(state)}">${esc(state)}</span>`;
+}
 
 const FIELDS = [
   { k:'address', l:'Address', def:true },
@@ -87,6 +138,7 @@ const FIELDS = [
   { k:'baths',   l:'Baths',   def:true, num:true },
   { k:'sqft',    l:'Sq Ft',   def:true, num:true },
   { k:'firm_name', l:'Brokerage', def:true, badge:true },
+  { k:'lifecycle_state', l:'Lifecycle', def:true },
   { k:'last_firm_of_record', l:'Last Firm of Record', def:false },
   { k:'last_broker_of_record', l:'Last Broker of Record', def:false },
   { k:'county_name', l:'County', def:true },
@@ -98,7 +150,7 @@ function badgeHtml(r){
   // brokerage firm chip + the outbound "View on <brokerage>" link — never present as ours
   const host = (() => { try { return new URL(r.url).host.replace(/^www\./,''); } catch(e){ return r.source; } })();
   const viewOn = r.url ? `<a class="viewon" href="${esc(r.url)}" target="_blank" rel="noopener noreferrer">View on ${esc(host)} ↗</a>` : '';
-  return `${firmChip(r)} ${viewOn}`;
+  return `${lcChip(r.lifecycle_state)} ${firmChip(r)} ${viewOn}`;
 }
 
 async function loadCoverage(){
@@ -138,10 +190,12 @@ async function load(){
   const firm = document.getElementById('firmSel').value;
   const county = document.getElementById('countySel').value;
   const source = document.getElementById('sourceSel').value;
+  const state = document.getElementById('stateSel').value;
   const p = new URLSearchParams({ limit: '2000' });
   if (firm) p.set('firm', firm);
   if (county) p.set('county', county);
   if (source) p.set('source', source);
+  if (state) p.set('state', state);
   const d = await fetch(location.origin + '/api/listings?' + p).then(r=>r.json());
   ALL = d.rows;
   render();
@@ -169,6 +223,7 @@ function render(){
     sort: { k: 'price', dir: -1 },
     facets: [
       { k: 'firm_name', l: 'Brokerage Firm', type: 'chip', max: 20 },
+      { k: 'lifecycle_state', l: 'Lifecycle State', type: 'chip', max: 8 },
       { k: 'last_firm_of_record', l: 'Last Firm of Record', type: 'chip', max: 20 },
       { k: 'source', l: 'Source Site', type: 'chip', max: 10 },
       { k: 'status', l: 'Status', type: 'chip', max: 8 },
@@ -185,9 +240,66 @@ function render(){
   rail.appendChild(note);
 }
 
-['firmSel','countySel','sourceSel'].forEach(id => document.getElementById(id).addEventListener('change', load));
+['firmSel','countySel','sourceSel','stateSel'].forEach(id => document.getElementById(id).addEventListener('change', load));
 document.getElementById('q').addEventListener('input', render);
 
+// ── TK-10155: Withdrawn (preserved) view ────────────────────────────────────
+const fmtWhen = ts => { try { return new Date(ts).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); } catch(e){ return ts||'—'; } };
+// fields worth surfacing first in a snapshot (rest follow)
+const SNAP_ORDER = ['address','price','beds','baths','sqft','firm_name','broker_name','county_name','region_state_code','status','source','source_id','url','first_seen','last_seen','id'];
+function snapGrid(snap){
+  if (!snap) return '<div class="wd-empty">No snapshot saved for this listing.</div>';
+  const keys = [...SNAP_ORDER.filter(k => k in snap), ...Object.keys(snap).filter(k => !SNAP_ORDER.includes(k))];
+  const cells = keys.map(k => {
+    let v = snap[k];
+    if (v == null || v === '') v = '—';
+    if (k === 'price' && snap[k] != null) v = money(snap[k]);
+    if (k === 'url' && snap[k]) v = `<a class="viewon" href="${esc(snap[k])}" target="_blank" rel="noopener noreferrer">link ↗</a>`;
+    else v = esc(v);
+    return `<div class="k">${esc(k)}</div><div class="v">${v}</div>`;
+  }).join('');
+  return `<div class="wd-grid">${cells}</div>`;
+}
+function withdrawnCard(r, i){
+  const s = r.snapshot || {};
+  const addr = s.address || '(no address)';
+  const firm = s.firm_name ? `<span class="wd-meta">🏢 <b>${esc(s.firm_name)}</b></span>` : '';
+  const price = s.price != null ? `<span class="wd-meta">${money(s.price)}</span>` : '';
+  const county = s.county_name ? `<span class="wd-meta">${esc(s.county_name)}${s.region_state_code?', '+esc(s.region_state_code):''}</span>` : '';
+  return `<div class="wd-card">
+    <div class="wd-head" data-i="${i}">
+      <span class="wd-addr">${esc(addr)}</span>${firm}${price}${county}
+      <span class="wd-when" title="withdrawn ${esc(r.withdrawn_at||'')}">🗄️ withdrawn ${fmtWhen(r.withdrawn_at)}${r.captured_reason?` · snapshot: ${esc(r.captured_reason)}`:''}</span>
+    </div>
+    <div class="wd-body" id="wdbody-${i}">${snapGrid(r.snapshot)}</div>
+  </div>`;
+}
+let WITHDRAWN_LOADED = false;
+async function loadWithdrawn(){
+  const el = document.getElementById('withdrawnList');
+  try {
+    const d = await fetch(location.origin + '/api/withdrawn?limit=500').then(r=>r.json());
+    if (!d.rows || !d.rows.length){
+      el.innerHTML = '<div class="wd-empty">No withdrawn listings yet. When a listing disappears from a source feed for '+
+        '30+ days (or is marked withdrawn/cancelled/expired), it is preserved here with its full property info.</div>';
+      return;
+    }
+    el.innerHTML = d.rows.map((r,i)=>withdrawnCard(r,i)).join('');
+    el.querySelectorAll('.wd-head').forEach(h => h.addEventListener('click', () => {
+      document.getElementById('wdbody-'+h.dataset.i).classList.toggle('on');
+    }));
+  } catch(e){
+    el.innerHTML = '<div class="wd-empty">Failed to load withdrawn listings: '+esc(e.message||e)+'</div>';
+  }
+}
+document.getElementById('wdToggle').addEventListener('click', () => {
+  const wrap = document.getElementById('withdrawnWrap');
+  const btn = document.getElementById('wdToggle');
+  const on = wrap.classList.toggle('on');
+  btn.classList.toggle('on', on);
+  if (on && !WITHDRAWN_LOADED){ WITHDRAWN_LOADED = true; loadWithdrawn(); }
+});
+
 loadCoverage();
 loadFilters();
 load();

← a7cc0b6 TK-10155: listing lifecycle detector + sweep + backfill + in  ·  back to Nationalrealestate  ·  TK-10155 fix1: guard gone-from-feed withdrawal behind a per- fdb639e →