← 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 →