← back to Reid Witlin Onboarding

preview.py

130 lines

#!/usr/bin/env python3
"""
QA preview grid for the Reid Witlin batch — SAFE (local, read-only).

Emits preview.html: a self-contained grid of the ready + held products
(primary image, title, SKU, town/colorway, key specs, tags) so Steve can
eyeball batch quality — bad images, wrong titles, odd specs — BEFORE running
the gated create. Sort <select> + density slider with localStorage persistence
per the standing product-grid rule. No server needed; open the file directly.
"""
import csv, json, os, datetime

HERE = os.path.dirname(os.path.abspath(__file__))

def load(path):
    p = os.path.join(HERE, path)
    return list(csv.DictReader(open(p))) if os.path.exists(p) else []

def main():
    ready = load("targets_ready.csv")
    held = load("targets_image_held.csv")
    for r in ready: r["_pile"] = "ready"
    for r in held: r["_pile"] = "held"
    rows = ready + held
    # keep the payload lean — only the fields the grid renders
    keep = ["sku", "title", "handle", "town", "colorway", "pattern", "image_url",
            "image_confidence", "width", "content", "finish", "abrasion", "care",
            "repeat", "fire_rating", "style", "tags", "_pile"]
    data = [{k: r.get(k, "") for k in keep} for r in rows]
    gen = datetime.datetime.now().strftime("%b %d, %Y, %-I:%M %p")

    html = """<!doctype html><html><head><meta charset="utf-8">
<title>Reid Witlin QA — __READY__ ready / __HELD__ held</title>
<style>
:root{--cols:5}
*{box-sizing:border-box}
body{margin:0;font:14px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;background:#f4f4f5;color:#18181b}
header{position:sticky;top:0;background:#fff;border-bottom:1px solid #e4e4e7;padding:12px 20px;z-index:5;
  display:flex;gap:16px;align-items:center;flex-wrap:wrap;box-shadow:0 1px 4px rgba(0,0,0,.04)}
header h1{font-size:16px;margin:0;font-weight:700}
header .meta{color:#71717a;font-size:12px}
header label{font-size:12px;color:#52525b;display:flex;align-items:center;gap:6px}
select,input[type=search]{font:13px inherit;padding:5px 8px;border:1px solid #d4d4d8;border-radius:6px}
#grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:14px;padding:20px}
.card{background:#fff;border:1px solid #e4e4e7;border-radius:10px;overflow:hidden;display:flex;flex-direction:column}
.card.held{border-color:#f59e0b;border-width:2px}
.thumb{aspect-ratio:1;width:100%;background:#fafafa center/cover no-repeat;border-bottom:1px solid #f1f1f4}
img.thumb{object-fit:cover;display:block}
.thumb.none{display:flex;align-items:center;justify-content:center;color:#a1a1aa;font-size:12px;text-align:center}
.card.img-failed{border-color:#ef4444;border-width:2px}
.body{padding:10px 12px;display:flex;flex-direction:column;gap:5px}
.title{font-weight:600;font-size:14px}
.sku{font:11px ui-monospace,monospace;color:#71717a}
.badge{display:inline-block;font-size:10px;padding:1px 6px;border-radius:99px;font-weight:600}
.b-exact{background:#dcfce7;color:#166534}.b-vendor-js{background:#dbeafe;color:#1e40af}
.b-none,.b-unconfirmed-held,.b-collision-held{background:#fef3c7;color:#92400e}
.specs{font-size:11px;color:#52525b;line-height:1.5}
.tags{font-size:10px;color:#a1a1aa;margin-top:2px;max-height:34px;overflow:hidden}
</style></head><body>
<header>
  <h1>Reid Witlin QA</h1>
  <span class="meta">__READY__ ready · <b style="color:#b45309">__HELD__ held</b> · generated __GEN__</span>
  <label>Sort <select id="sort">
    <option value="natural">Newest (natural)</option>
    <option value="title">Title A→Z</option>
    <option value="sku">SKU A→Z</option>
    <option value="town">Town / Pattern</option>
    <option value="conf">Image confidence</option>
    <option value="pile">Held first</option>
  </select></label>
  <label>Density <input type="range" id="dens" min="2" max="8" value="5"></label>
  <input type="search" id="q" placeholder="filter title / sku / tag…">
  <label><input type="checkbox" id="heldonly"> held only</label>
</header>
<div id="grid"></div>
<script>
const DATA = __DATA__;
const grid = document.getElementById('grid');
const $ = id => document.getElementById(id);
function save(){localStorage.setItem('rw_qa',JSON.stringify({s:$('sort').value,d:$('dens').value}))}
function restore(){try{const o=JSON.parse(localStorage.getItem('rw_qa'));if(o){$('sort').value=o.s||'natural';$('dens').value=o.d||5}}catch(e){}}
function specLine(r){
  const bits=[['W',r.width],['Content',r.content],['Finish',r.finish],['Abr',r.abrasion],
    ['Care',r.care],['Rpt',r.repeat],['Fire',r.fire_rating],['Style',r.style]]
    .filter(([k,v])=>v&&v.trim()).map(([k,v])=>k+': '+v).join(' · ');
  return bits;
}
function render(){
  const q=$('q').value.toLowerCase().trim(), heldonly=$('heldonly').checked;
  let rows=DATA.filter(r=>!heldonly||r._pile==='held');
  if(q) rows=rows.filter(r=>(r.title+' '+r.sku+' '+r.tags+' '+r.town+' '+r.colorway).toLowerCase().includes(q));
  const s=$('sort').value;
  // least-trusted first, so "sort by confidence" bubbles the dodgy images up
  const confRank={'collision-held':0,'unconfirmed-held':1,none:2,prefix:3,'vendor-js':4,exact:5};
  const cmp={title:(a,b)=>a.title.localeCompare(b.title),sku:(a,b)=>a.sku.localeCompare(b.sku),
    town:(a,b)=>(a.town+a.colorway).localeCompare(b.town+b.colorway),
    conf:(a,b)=>(confRank[a.image_confidence]??9)-(confRank[b.image_confidence]??9),
    pile:(a,b)=>(a._pile==='held'?0:1)-(b._pile==='held'?0:1)};
  if(cmp[s]) rows=rows.slice().sort(cmp[s]);
  document.documentElement.style.setProperty('--cols',$('dens').value);
  grid.innerHTML=rows.map(r=>{
    const img=r.image_url&&r.image_url.startsWith('http');
    return `<div class="card ${r._pile==='held'?'held':''}">
      ${img?`<img class="thumb" loading="lazy" src="${r.image_url}" onerror="this.closest('.card').classList.add('img-failed');this.replaceWith(Object.assign(document.createElement('div'),{className:'thumb none',textContent:'image failed'}))">`
           :`<div class="thumb none">no image</div>`}
      <div class="body">
        <span class="title">${r.title||'(untitled)'}</span>
        <span class="sku">${r.sku} · ${r.town||''} ${r.colorway||''}</span>
        <span><span class="badge b-${r.image_confidence}">${r.image_confidence}</span></span>
        <div class="specs">${specLine(r)}</div>
        <div class="tags">${(r.tags||'').split('|').map(t=>t.trim()).filter(Boolean).join(' · ')}</div>
      </div></div>`;}).join('');
  save();
}
['sort','dens','q','heldonly'].forEach(id=>$(id).addEventListener('input',render));
restore();render();
</script></body></html>"""
    # token replace (not % / .format — the CSS uses '%' and the JS uses '{}')
    html = (html.replace("__READY__", str(len(ready)))
                .replace("__HELD__", str(len(held)))
                .replace("__GEN__", gen)
                .replace("__DATA__", json.dumps(data)))

    open(os.path.join(HERE, "preview.html"), "w").write(html)
    print(json.dumps({"ready": len(ready), "held": len(held),
                      "wrote": "preview.html", "generated": gen}, indent=2))

if __name__ == "__main__":
    main()