[object Object]

← back to Dw Pitch Followup

Compact cards + Columns/One-list layout toggle + ▸details expander (samples/merch/last-corresponded); lazy /api/lastcorr via George

c660a285650576ec14b9b38f61ea7d10d25eb1c8 · 2026-07-07 14:24:21 -0700 · Steve Abrams

Files touched

Diff

commit c660a285650576ec14b9b38f61ea7d10d25eb1c8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 7 14:24:21 2026 -0700

    Compact cards + Columns/One-list layout toggle + ▸details expander (samples/merch/last-corresponded); lazy /api/lastcorr via George
---
 public/index.html | 335 +++++++++++++++++++++++++++++++++---------------------
 server.js         |  14 +++
 2 files changed, 221 insertions(+), 128 deletions(-)

diff --git a/public/index.html b/public/index.html
index 211b77e..36bfe04 100644
--- a/public/index.html
+++ b/public/index.html
@@ -12,89 +12,108 @@
   }
   *{box-sizing:border-box}
   body{margin:0;font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;background:var(--bg);color:var(--ink)}
-  header{padding:16px 24px 10px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#181511,#13110e)}
-  h1{margin:0;font-size:19px;letter-spacing:.04em;font-weight:600}
-  h1 small{color:var(--mut);font-weight:400;font-size:13px;letter-spacing:0}
+  header{padding:14px 24px 9px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#181511,#13110e)}
+  h1{margin:0;font-size:18px;letter-spacing:.04em;font-weight:600}
+  h1 small{color:var(--mut);font-weight:400;font-size:12.5px;letter-spacing:0}
 
-  .toolbar{position:sticky;top:0;z-index:20;display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:11px 24px;background:var(--panel2);border-bottom:1px solid var(--line)}
+  .toolbar{position:sticky;top:0;z-index:20;display:flex;gap:12px;align-items:center;flex-wrap:wrap;padding:10px 24px;background:var(--panel2);border-bottom:1px solid var(--line)}
   .toolbar label{color:var(--mut);font-size:12.5px;margin-right:5px}
-  select,input[type=search]{background:#15120f;color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:7px 9px;font-size:13px}
-  input[type=range]{vertical-align:middle;width:120px}
-  .btn{background:#2a2620;color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:7px 12px;cursor:pointer;font-size:13px}
+  select,input[type=search]{background:#15120f;color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:6px 9px;font-size:13px}
+  input[type=range]{vertical-align:middle;width:110px}
+  .btn{background:#2a2620;color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:6px 11px;cursor:pointer;font-size:13px}
   .btn:hover{border-color:var(--gold)}
   .btn.gold{background:var(--gold);color:#1a1611;border-color:var(--gold);font-weight:600}
   .spacer{flex:1}
   .selcount{color:var(--gold);font-size:13px}
-
-  /* View toggle (List ⇄ Grid) */
   .seg{display:inline-flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}
-  .seg button{background:#15120f;color:var(--mut);border:none;padding:7px 13px;font-size:12.5px;cursor:pointer}
+  .seg button{background:#15120f;color:var(--mut);border:none;padding:6px 12px;font-size:12.5px;cursor:pointer}
   .seg button.on{background:var(--gold);color:#1a1611;font-weight:600}
+  #bucketWrap{display:none}
+  body.flat #bucketWrap{display:inline}
 
-  /* 3-COLUMN BOARD */
-  main{padding:16px 24px 60px}
+  main{padding:14px 24px 60px}
+  /* BOARD = 3 columns; FLAT = single column */
   .board{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px;align-items:start}
+  body.flat .board{grid-template-columns:minmax(0,760px);justify-content:center}
   @media(max-width:1100px){.board{grid-template-columns:1fr}}
-  .col{background:var(--panel2);border:1px solid var(--line);border-radius:12px;display:flex;flex-direction:column;max-height:calc(100vh - 160px);overflow:hidden}
-  .col-head{padding:12px 14px 11px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#231f19,#211d18)}
-  .col-title{display:flex;align-items:center;gap:8px;font-weight:600;font-size:14.5px}
+  .col{background:var(--panel2);border:1px solid var(--line);border-radius:12px;display:flex;flex-direction:column;max-height:calc(100vh - 150px);overflow:hidden}
+  body.flat .col{max-height:calc(100vh - 150px)}
+  .col-head{padding:10px 13px 9px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#231f19,#211d18)}
+  .col-title{display:flex;align-items:center;gap:8px;font-weight:600;font-size:14px}
   .col-title .n{margin-left:auto;padding:1px 9px;border-radius:11px;background:#2c2822;color:var(--gold);font-size:12px}
-  .col-defn{font-size:11px;color:var(--mut);margin:5px 0 8px;line-height:1.4}
+  .col-defn{font-size:11px;color:var(--mut);margin:4px 0 7px;line-height:1.35}
+  body.flat .col-defn{display:none}
   .col-sort{width:100%}
-  .col-body{overflow-y:auto;padding:11px;display:flex;flex-direction:column;gap:10px}
-  .col-empty{color:var(--mut);font-size:12.5px;padding:22px 6px;text-align:center}
+  .col-body{overflow-y:auto;padding:9px;display:flex;flex-direction:column;gap:8px}
+  .col-empty{color:var(--mut);font-size:12.5px;padding:20px 6px;text-align:center}
 
-  .card{background:var(--panel);border:1px solid var(--line);border-radius:11px;padding:12px 13px;display:flex;flex-direction:column;gap:7px;position:relative;font-size:calc(13px*var(--scale))}
+  /* COMPACT cards by default */
+  .card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:9px 11px;display:flex;flex-direction:column;gap:5px;position:relative;font-size:calc(12.5px*var(--scale))}
   .card.sel{border-color:var(--gold);box-shadow:0 0 0 1px var(--gold) inset}
-  .card .top{display:flex;align-items:flex-start;gap:9px}
-  .card input[type=checkbox]{width:17px;height:17px;margin-top:2px;accent-color:var(--gold);cursor:pointer;flex:0 0 auto}
-  .co{font-weight:600;font-size:calc(14.5px*var(--scale))}
-  .sub{color:var(--mut);font-size:calc(12px*var(--scale));word-break:break-word}
-  .chips{display:flex;flex-wrap:wrap;gap:6px}
-  .chip{font-size:calc(11px*var(--scale));padding:2px 8px;border-radius:11px;background:#26221c;color:var(--mut);border:1px solid var(--line)}
+  .card .top{display:flex;align-items:flex-start;gap:8px}
+  .card input[type=checkbox]{width:16px;height:16px;margin-top:2px;accent-color:var(--gold);cursor:pointer;flex:0 0 auto}
+  .co{font-weight:600;font-size:calc(13.5px*var(--scale))}
+  .btag{font-size:calc(10px*var(--scale));padding:1px 6px;border-radius:9px;background:#2c2822;color:var(--gold);border:1px solid var(--line);margin-right:5px;vertical-align:middle}
+  .sub{color:var(--mut);font-size:calc(11.5px*var(--scale));word-break:break-word}
+  .chips{display:flex;flex-wrap:wrap;gap:5px}
+  .chip{font-size:calc(10.5px*var(--scale));padding:2px 7px;border-radius:10px;background:#26221c;color:var(--mut);border:1px solid var(--line)}
   .chip.k{color:var(--gold)} .chip.g{color:var(--green)} .chip.a{color:var(--amber)} .chip.r{color:var(--red)}
   .chip.sent{color:var(--green);border-color:var(--green)}
-  .btn.send{background:#33280f;color:var(--gold);border-color:var(--gold)}
-  .btn.send:hover{background:var(--gold);color:#1a1611}
-  .sendstat{font-size:11.5px;margin-left:2px}
-  .pats{font-size:calc(11.5px*var(--scale));color:var(--mut)}
-  .acts{display:flex;gap:8px;margin-top:2px}
-  .letter{margin-top:6px;border-top:1px dashed var(--line);padding-top:8px;display:none}
+  .pats{font-size:calc(11px*var(--scale));color:var(--mut)}
+
+  /* expandable detail — collapsed by default so cards stay small */
+  .more{align-self:flex-start;background:none;border:none;color:var(--gold);font-size:calc(11px*var(--scale));cursor:pointer;padding:0}
+  .details{display:none;border-top:1px dashed var(--line);padding-top:6px;margin-top:1px;font-size:calc(11.5px*var(--scale))}
+  .details.show{display:block}
+  .drow{margin:3px 0;color:var(--mut)}
+  .drow b{color:var(--ink);font-weight:600}
+  .slist{margin:2px 0 0;padding-left:0;list-style:none}
+  .slist li{padding:1px 0}
+  .dot{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:6px;vertical-align:middle}
+  .dot.ok{background:var(--green)} .dot.pend{background:var(--amber)}
+  .inv a{color:var(--gold)}
+  .lastcorr{color:var(--mut)} .lastcorr.found{color:var(--green)}
+
+  .acts{display:flex;gap:7px;margin-top:2px;flex-wrap:wrap}
+  .letter{margin-top:5px;border-top:1px dashed var(--line);padding-top:7px;display:none}
   .letter.show{display:block}
-  .letter .subj{font-size:12.5px;color:var(--gold);margin-bottom:5px}
-  textarea{width:100%;min-height:170px;background:#15120f;color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:9px;font:13px/1.5 ui-monospace,Menlo,monospace;resize:vertical}
+  .letter .subj{font-size:12px;color:var(--gold);margin-bottom:5px}
+  textarea{width:100%;min-height:160px;background:#15120f;color:var(--ink);border:1px solid var(--line);border-radius:7px;padding:9px;font:13px/1.5 ui-monospace,Menlo,monospace;resize:vertical}
   .ctxnote{font-size:11.5px;color:var(--mut);margin:5px 0}
   .ctxnote.found{color:var(--green)}
+  .btn.send{background:#33280f;color:var(--gold);border-color:var(--gold)}
+  .btn.send:hover{background:var(--gold);color:#1a1611}
+  .sendstat{font-size:11.5px;margin-left:2px}
   .meta{color:var(--mut);font-size:12px}
   a{color:var(--gold)}
-
-  /* LIST MODE — compact 3-column rows (checkbox · who · trailing facts) */
-  body.view-list .card{padding:8px 11px;gap:5px}
-  body.view-list .card .top{align-items:center}
-  body.view-list .pats{display:none}
-  body.view-list .chips{font-size:11px}
-  body.view-list .card .acts:first-of-type{margin-left:auto}
-  body.view-list .co{font-size:calc(13.5px*var(--scale))}
 </style>
 </head>
 <body>
 <header>
-  <h1>Designer Wallcoverings — Client Follow-Up <small>· every open correspondence, 3 columns · nothing sends from here</small></h1>
+  <h1>Designer Wallcoverings — Client Follow-Up <small>· compact · columns or one list · nothing sends without a confirm</small></h1>
 </header>
 
 <div class="toolbar">
-  <span><input type="search" id="q" placeholder="filter every column…" /></span>
-  <span><label>View</label>
-    <span class="seg" id="viewSeg">
-      <button data-mode="list">List</button><button data-mode="grid">Grid</button>
+  <span><input type="search" id="q" placeholder="filter…" /></span>
+  <span><label>Layout</label>
+    <span class="seg" id="layoutSeg">
+      <button data-layout="board">Columns</button><button data-layout="flat">One list</button>
     </span>
   </span>
-  <span><label>Density</label><input type="range" id="density" min="1" max="5" step="1" /></span>
-  <span><label title="OFF (default) hides manufacturer numbers from view AND from client letters"><input type="checkbox" id="showMfr" /> show mfr #</label></span>
+  <span id="bucketWrap"><label>Show</label>
+    <select id="bucket">
+      <option value="all">All types</option>
+      <option value="list1">Samples Sent ≥90%</option>
+      <option value="list2">Invoiced · No Order</option>
+      <option value="list3">Vendor Memos Overdue</option>
+    </select>
+  </span>
+  <span><label>Size</label><input type="range" id="density" min="1" max="5" step="1" /></span>
+  <span><label title="OFF (default) hides manufacturer numbers from view AND from client letters"><input type="checkbox" id="showMfr" /> mfr #</label></span>
   <span class="spacer"></span>
   <span class="selcount" id="selcount">0 selected</span>
   <button class="btn" id="copyBtn">Copy selected</button>
-  <button class="btn gold" id="exportBtn">Export selected ▾</button>
+  <button class="btn gold" id="exportBtn">Export ▾</button>
   <span class="meta" id="built"></span>
 </div>
 
@@ -104,14 +123,12 @@
 
 <script>
 const LISTS = ['list1','list2','list3'];
-// Build fetch URLs from host only — never inherit credentials from the page URL.
-// If the tab was opened as http://user:pass@host/, a relative fetch('/api/…')
-// throws "URL that includes credentials"; rebuilding from location.host strips them.
 const API = p => `${location.protocol}//${location.host}${p}`;
+const FM_HOST = 'designerwallcoverings.account.filemaker-cloud.com'; // for fmp:// invoice links
 const META = {
-  list1:{title:'Samples Sent ≥90%', defn:'≥90% of the samples the client ordered have shipped — they have the goods in hand. Follow up to convert.'},
-  list2:{title:'Invoiced · No Order', defn:'Invoiced in the last ~4 months, all sample-only (no order over $50) — never converted to a firm order.'},
-  list3:{title:'Vendor Memos Overdue', defn:'Sample memos WE requested from a vendor 8–180 days ago that still have not arrived — chase the vendor.'},
+  list1:{title:'Samples Sent ≥90%', tag:'Samples ≥90%', defn:'≥90% of the samples the client ordered have shipped — follow up to convert.'},
+  list2:{title:'Invoiced · No Order', tag:'Inv · No Order', defn:'Invoiced in the last ~4 months, all sample-only (no order over $50).'},
+  list3:{title:'Vendor Memos Overdue', tag:'Vendor Memo', defn:'Sample memos WE requested 8–180 days ago that still have not arrived.'},
 };
 const SORTS = {
   list1:[['Newest sent','created_at:desc'],['Oldest sent','created_at:asc'],['Company A→Z','company:asc'],['Most samples','ordered:desc']],
@@ -119,88 +136,146 @@ const SORTS = {
   list3:[['Most overdue','days_overdue:desc'],['Least overdue','days_overdue:asc'],['Vendor A→Z','vendor:asc'],['Client A→Z','company:asc']],
 };
 let DATA=null;
-const VIEW = {list1:[],list2:[],list3:[]};   // filtered+sorted rows per column
-const RENDERED = {list1:0,list2:0,list3:0};  // how many rendered (lazy scroll)
+const VIEW = {};      // rows per container id
+const RENDERED = {};  // count rendered per container id
 const CHUNK = 30;
-let mode = 'grid';
+let layout = 'board'; // 'board' | 'flat'
 
 const LS = (k,d)=>{try{return JSON.parse(localStorage.getItem('dwpf:'+k))??d}catch{return d}};
 const setLS=(k,v)=>localStorage.setItem('dwpf:'+k, JSON.stringify(v));
 const esc = s => String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
-const fmtWhen = iso => { if(!iso) return ''; const d=new Date(iso); return d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); };
-const dateChip = r => r.when_iso ? `<span class="chip" title="${esc(r.when_iso)}">🕓 ${esc(r.when_label||'when')}: ${esc(fmtWhen(r.when_iso))}</span>` : '';
-// mfr-number scrub for display: drop SKU-ish tokens (any alnum run containing a digit), keep names.
+const fmtWhen = iso => { if(!iso) return ''; const d=new Date(iso); return isNaN(d)?'':d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); };
+const fmtDay = iso => { if(!iso) return ''; const d=new Date(iso); return isNaN(d)?'':d.toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}); };
+const dateChip = r => r.when_iso ? `<span class="chip" title="${esc(r.when_iso)}">🕓 ${esc(r.when_label||'when')}: ${esc(fmtDay(r.when_iso))}</span>` : '';
 function stripMfr(s){ return String(s||'').replace(/[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*/g,t=>/\d/.test(t)?'':t).replace(/\s*[|·•—–-]+\s*/g,' · ').replace(/(?:^[\s·]+|[\s·]+$)/g,'').replace(/(?:·\s*){2,}/g,'· ').replace(/\bSample\b\s*$/i,'').replace(/\s{2,}/g,' ').trim().replace(/[·\s]+$/,''); }
 function showMfr(){ return !!document.getElementById('showMfr')?.checked; }
 const lbl = s => showMfr() ? String(s||'').trim() : stripMfr(s);
+const money = n => '$'+(Number(n)||0).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2});
+// fmp:// deep-link that opens the invoice file to that invoice (needs a 1-line "find invoice" script named GotoInvoice; falls back to opening the file).
+const fmpInvoice = inv => `fmp://${FM_HOST}/invoice?script=GotoInvoice&param=${encodeURIComponent(inv)}`;
 
-// --- selection (per-column) ---
 function rowKey(list,r){ return list==='list3' ? `${r.account}|${r.sku}` : r.account; }
 function selSet(list){ return new Set(LS('sel:'+list, [])); }
 function saveSel(list,set){ setLS('sel:'+list, [...set]); paintSelCount(); }
 function totalSel(){ return LISTS.reduce((n,l)=>n+selSet(l).size,0); }
 function paintSelCount(){ document.getElementById('selcount').textContent = `${totalSel()} selected`; }
 
-// --- filter + sort for one column ---
 function filterSort(list){
   const rows = (DATA[list]||[]).slice();
   const q = document.getElementById('q').value.trim().toLowerCase();
   let v = q ? rows.filter(r=>JSON.stringify(r).toLowerCase().includes(q)) : rows;
   const [f,dir] = (LS('sort:'+list, SORTS[list][0][1])).split(':');
-  v.sort((a,b)=>{
-    let x=a[f], y=b[f];
+  v.sort((a,b)=>{ let x=a[f],y=b[f];
     if(typeof x==='number'||typeof y==='number'){ x=+x||0;y=+y||0; return dir==='asc'?x-y:y-x; }
-    x=String(x||'');y=String(y||''); return dir==='asc'?x.localeCompare(y):y.localeCompare(x);
-  });
-  return v;
+    x=String(x||'');y=String(y||''); return dir==='asc'?x.localeCompare(y):y.localeCompare(x); });
+  return v.map(r=>({r,list}));
+}
+// flat mode: merge buckets (respecting the bucket filter), sort by recency
+function flatRows(){
+  const bucket = LS('bucket','all');
+  const buckets = bucket==='all' ? LISTS : [bucket];
+  const q = document.getElementById('q').value.trim().toLowerCase();
+  let all=[];
+  for(const list of buckets){ for(const r of (DATA[list]||[])) all.push({r,list}); }
+  if(q) all = all.filter(x=>JSON.stringify(x.r).toLowerCase().includes(q));
+  all.sort((a,b)=>String(b.r.created_at||'').localeCompare(String(a.r.created_at||'')));
+  return all;
+}
+
+function samplesBlock(r){
+  const sent = Array.isArray(r.samples_sent)?r.samples_sent:[];
+  const pend = Array.isArray(r.samples_pending)?r.samples_pending:[];
+  if(!sent.length && !pend.length) return '';
+  const li = (arr,cls,suffix)=>arr.slice(0,12).map(s=>{
+    const name = typeof s==='string'?s:(s.label||s.sku||'sample');
+    const when = (s&&s.date)?` · ${esc(fmtDay(s.date))}`:'';
+    return `<li><span class="dot ${cls}"></span>${esc(lbl(name))}${suffix?when:''}</li>`;
+  }).join('');
+  let out='';
+  if(sent.length) out+=`<div class="drow"><b>Samples sent (${sent.length})</b><ul class="slist">${li(sent,'ok',true)}</ul></div>`;
+  if(pend.length) out+=`<div class="drow"><b>Still pending (${pend.length})</b><ul class="slist">${li(pend,'pend',false)}</ul></div>`;
+  return out;
+}
+function merchBlock(r){
+  const inv = Array.isArray(r.merch_invoices)?r.merch_invoices:[];
+  if(!inv.length) return '';
+  const total = r.merch_total!=null ? r.merch_total : inv.reduce((s,x)=>s+(Number(x.total)||0),0);
+  const rows = inv.slice(0,8).map(x=>`<span class="inv"><a href="${fmpInvoice(x.invoice)}" title="Open invoice ${esc(x.invoice)} in FileMaker">#${esc(x.invoice)}</a> ${esc(money(x.total))}${x.date?` · ${esc(fmtDay(x.date))}`:''}</span>`).join('<br>');
+  return `<div class="drow"><b>Merchandise orders — ${esc(money(total))}</b><div>${rows}</div></div>`;
 }
 
 function cardHTML(list,r){
   const k = rowKey(list,r), sel = selSet(list).has(k);
   const sentRec = LS('sent:'+list+':'+k, null);
-  const sentChip = sentRec ? ` <span class="chip sent" title="${esc(new Date(sentRec.at).toISOString())}">✓ sent ${esc(fmtWhen(new Date(sentRec.at).toISOString()))}</span>` : '';
+  const sentChip = sentRec ? ` <span class="chip sent" title="${esc(new Date(sentRec.at).toISOString())}">✓ sent ${esc(fmtDay(new Date(sentRec.at).toISOString()))}</span>` : '';
+  const btag = layout==='flat' ? `<span class="btag">${esc(META[list].tag)}</span>` : '';
   let head, facts='';
   if(list==='list3'){
-    head = `<div><div class="co">${esc(r.vendor)} <span class="chip r">${r.days_overdue}d overdue</span>${sentChip}</div>
+    head = `<div><div class="co">${btag}${esc(r.vendor)} <span class="chip r">${r.days_overdue}d</span>${sentChip}</div>
       <div class="sub">${esc(r.company||('account '+r.account))} · acct ${esc(r.account)}</div></div>`;
     facts = `<div class="chips">${showMfr()?`<span class="chip k">${esc(r.sku||'—')}</span>`:''}<span class="chip">${esc(lbl(r.pattern)||'pattern')}</span>${dateChip(r)}</div>`;
   } else if(list==='list2'){
-    head = `<div><div class="co">${esc(r.company)}${sentChip}</div><div class="sub">${esc(r.email||'no email')} · acct ${esc(r.account)}</div></div>`;
-    facts = `<div class="chips"><span class="chip a">${r.invoice_count} sample inv</span><span class="chip">max $${r.max_total}</span>${dateChip(r)}</div>
-      ${(()=>{const d=r.details&&r.details[0]?lbl(r.details[0]):'';return d?`<div class="pats">${esc(d)}</div>`:''})()}`;
+    head = `<div><div class="co">${btag}${esc(r.company)}${sentChip}</div><div class="sub">${esc(r.email||'no email')} · acct ${esc(r.account)}</div></div>`;
+    facts = `<div class="chips"><span class="chip a">${r.invoice_count} sample inv</span><span class="chip">max $${r.max_total}</span>${dateChip(r)}</div>`;
   } else {
-    head = `<div><div class="co">${esc(r.company)} <span class="chip g">${r.ratio}% sent</span>${sentChip}</div>
+    head = `<div><div class="co">${btag}${esc(r.company)} <span class="chip g">${r.ratio}%</span>${sentChip}</div>
       <div class="sub">${esc(r.email||'no email')} · acct ${esc(r.account)}</div></div>`;
-    facts = `<div class="chips"><span class="chip">${r.sent}/${r.ordered} sent</span>${r.missing?`<span class="chip a">${r.missing} missing</span>`:''}${dateChip(r)}</div>
-      ${(()=>{const p=(r.patterns||[]).map(lbl).filter(Boolean).slice(0,3).join(' · ');return p?`<div class="pats">${esc(p)}</div>`:''})()}`;
+    facts = `<div class="chips"><span class="chip">${r.sent}/${r.ordered} sent</span>${r.missing?`<span class="chip a">${r.missing} missing</span>`:''}${dateChip(r)}</div>`;
   }
   return `<div class="card ${sel?'sel':''}" data-k="${esc(k)}" data-list="${list}">
     <div class="top"><input type="checkbox" ${sel?'checked':''} data-sel/>${head}</div>
     ${facts}
+    <button class="more" data-more>▸ details</button>
+    <div class="details">
+      ${samplesBlock(r)}${merchBlock(r)}
+      <div class="drow lastcorr" data-lastcorr>Last corresponded: <span data-lc>…</span></div>
+    </div>
     <div class="acts"><button class="btn gold" data-gen>Generate letter</button></div>
     <div class="letter"><div class="subj"></div><div class="ctxnote"></div><textarea spellcheck="false"></textarea>
-      <div class="acts"><button class="btn" data-regen>↻ Regenerate</button><button class="btn" data-copy>Copy letter</button><button class="btn send" data-send>✉ Send now (info@)</button><span class="sendstat"></span><span class="meta" data-variant></span></div></div>
+      <div class="acts"><button class="btn" data-regen>↻ Regenerate</button><button class="btn" data-copy>Copy</button><button class="btn send" data-send>✉ Send now (info@)</button><span class="sendstat"></span><span class="meta" data-variant></span></div></div>
   </div>`;
 }
 
-function renderChunk(list){
-  const body = document.getElementById('body-'+list);
-  const slice = VIEW[list].slice(RENDERED[list], RENDERED[list]+CHUNK);
+function renderChunk(cid, list){
+  const body = document.getElementById('body-'+cid);
+  const slice = VIEW[cid].slice(RENDERED[cid], RENDERED[cid]+CHUNK);
   if(!slice.length) return;
-  const frag=document.createElement('div'); frag.innerHTML = slice.map(r=>cardHTML(list,r)).join('');
-  [...frag.children].forEach((el,i)=>{ el._row = slice[i]; el._list = list; body.appendChild(el); });
-  RENDERED[list] += slice.length;
+  const frag=document.createElement('div'); frag.innerHTML = slice.map(x=>cardHTML(x.list,x.r)).join('');
+  [...frag.children].forEach((el,i)=>{ el._row = slice[i].r; el._list = slice[i].list; body.appendChild(el); });
+  RENDERED[cid] += slice.length;
+}
+function rerenderContainer(cid, rows, list){
+  VIEW[cid]=rows; RENDERED[cid]=0;
+  const body=document.getElementById('body-'+cid);
+  body.innerHTML = rows.length ? '' : '<div class="col-empty">No matching rows.</div>';
+  const nEl=document.getElementById('n-'+cid); if(nEl) nEl.textContent = rows.length;
+  renderChunk(cid, list);
+}
+function rerenderAll(){
+  if(layout==='board'){ LISTS.forEach(l=>rerenderContainer(l, filterSort(l), l)); }
+  else { rerenderContainer('flat', flatRows(), null); }
 }
 
-function rerenderCol(list){
-  VIEW[list] = filterSort(list);
-  RENDERED[list] = 0;
-  const body = document.getElementById('body-'+list);
-  body.innerHTML = VIEW[list].length ? '' : '<div class="col-empty">No matching rows.</div>';
-  document.getElementById('n-'+list).textContent = VIEW[list].length;
-  renderChunk(list);
+// --- last-corresponded: lazy, only when a card's details is opened ---
+async function loadLastCorr(card){
+  const r=card._row; const email=(r.email||'').trim(); const span=card.querySelector('[data-lc]'); const wrap=card.querySelector('[data-lastcorr]');
+  if(!span) return;
+  if(!email){ span.textContent='—'; return; }
+  const ck='lastcorr:'+email; const c=LS(ck,undefined);
+  if(c!==undefined){ paintLastCorr(wrap,span,c); return; }
+  span.textContent='checking…';
+  try{
+    const res=await fetch(API('/api/lastcorr?email='+encodeURIComponent(email)));
+    const j=await res.json();
+    const val = j.date ? {date:j.date, subject:j.subject||'', account:j.account||''} : null;
+    setLS(ck,val); paintLastCorr(wrap,span,val);
+  }catch(e){ span.textContent='(lookup failed)'; }
+}
+function paintLastCorr(wrap,span,val){
+  if(!val){ span.textContent='no prior email found'; return; }
+  wrap.classList.add('found');
+  span.textContent = `${fmtWhen(val.date)}${val.subject?` · "${val.subject.slice(0,50)}"`:''}${val.account?` (${val.account})`:''}`;
 }
-function rerenderAll(){ LISTS.forEach(rerenderCol); }
 
 async function genLetter(card, opts={}){
   const r = card._row, list = card._list;
@@ -224,24 +299,20 @@ async function genLetter(card, opts={}){
     subj.textContent='Subject: '+j.subject; ta.value=j.body;
     const ctx = j.context?.found ? `✓ grounded in last Gmail thread (${j.context.account}${j.context.subject?': "'+j.context.subject+'"':''})` : 'composed from project data (no prior thread)';
     note.textContent=ctx; note.className='ctxnote '+(j.context?.found?'found':'');
-    const vmeta = `variant ${j.variant||seed} · ${j.engine||'local'}${j.engineNote?' · '+j.engineNote:''}`;
-    vlabel.textContent=vmeta;
-    setLS(cacheK,{subject:j.subject,body:j.body,ctx,found:!!j.context?.found,vmeta});
+    vlabel.textContent=`variant ${j.variant||seed} · ${j.engine||'local'}${j.engineNote?' · '+j.engineNote:''}`;
+    setLS(cacheK,{subject:j.subject,body:j.body,ctx,found:!!j.context?.found,vmeta:vlabel.textContent});
     setLS('lastletter:'+list+':'+rowKey(list,r),{subject:j.subject,body:j.body});
   }catch(e){ subj.textContent='error'; note.textContent=e.message; }
   finally{ if(rbtn){ rbtn.disabled=false; rbtn.textContent='↻ Regenerate'; } }
 }
 
-// --- selected → text (across ALL columns) ---
-// Actually EMAIL the letter, FROM info@designerwallcoverings.com, via the server → George.
-// Deliberate per-recipient action: confirm() guards every send; nothing goes out on its own.
 async function sendLetter(card, btn){
   const r = card._row, list = card._list, k = rowKey(list,r);
   const to = (r.email||'').trim();
   const subj = card.querySelector('.subj').textContent.replace(/^Subject:\s*/,'').trim();
   const body = card.querySelector('textarea').value.trim();
   const stat = card.querySelector('.sendstat');
-  if(!to){ stat.style.color='var(--red)'; stat.textContent = list==='list3' ? 'no vendor email on file — can’t send' : 'no client email on file — can’t send'; return; }
+  if(!to){ stat.style.color='var(--red)'; stat.textContent = list==='list3' ? 'no vendor email on file' : 'no client email on file'; return; }
   if(!subj || !body){ stat.style.color='var(--amber)'; stat.textContent='generate a letter first'; return; }
   if(!confirm(`Send this letter NOW?\n\nFROM:  info@designerwallcoverings.com\nTO:    ${to}\nSUBJ:  ${subj}\n\nThis emails the recipient immediately.`)) return;
   const old = btn.textContent; btn.disabled=true; btn.textContent='sending…'; stat.style.color='var(--mut)'; stat.textContent='';
@@ -268,36 +339,46 @@ function selectedAsText(){
   return blocks.join('\n----------------------------------------\n\n');
 }
 
-// --- build the 3 columns ---
+// --- build containers for the current layout ---
 function buildBoard(){
-  document.getElementById('board').innerHTML = LISTS.map(list=>{
-    const saved = LS('sort:'+list, SORTS[list][0][1]);
-    const opts = SORTS[list].map(([t,v])=>`<option value="${v}" ${v===saved?'selected':''}>${t}</option>`).join('');
-    return `<section class="col">
-      <div class="col-head">
-        <div class="col-title">${META[list].title}<span class="n" id="n-${list}">0</span></div>
-        <div class="col-defn">${META[list].defn}</div>
-        <select class="col-sort" data-list="${list}">${opts}</select>
-      </div>
-      <div class="col-body" id="body-${list}"></div>
+  const board=document.getElementById('board');
+  if(layout==='board'){
+    board.innerHTML = LISTS.map(list=>{
+      const saved = LS('sort:'+list, SORTS[list][0][1]);
+      const opts = SORTS[list].map(([t,v])=>`<option value="${v}" ${v===saved?'selected':''}>${t}</option>`).join('');
+      return `<section class="col">
+        <div class="col-head">
+          <div class="col-title">${META[list].title}<span class="n" id="n-${list}">0</span></div>
+          <div class="col-defn">${META[list].defn}</div>
+          <select class="col-sort" data-list="${list}">${opts}</select>
+        </div>
+        <div class="col-body" id="body-${list}"></div>
+      </section>`;
+    }).join('');
+    LISTS.forEach(list=>{ const body=document.getElementById('body-'+list);
+      body.addEventListener('scroll',()=>{ if(body.scrollTop+body.clientHeight >= body.scrollHeight-300) renderChunk(list,list); }); });
+  } else {
+    board.innerHTML = `<section class="col">
+      <div class="col-head"><div class="col-title">All correspondence<span class="n" id="n-flat">0</span></div></div>
+      <div class="col-body" id="body-flat"></div>
     </section>`;
-  }).join('');
+    const body=document.getElementById('body-flat');
+    body.addEventListener('scroll',()=>{ if(body.scrollTop+body.clientHeight >= body.scrollHeight-300) renderChunk('flat',null); });
+  }
+}
 
-  // per-column: sort change + lazy scroll
-  LISTS.forEach(list=>{
-    const body = document.getElementById('body-'+list);
-    body.addEventListener('scroll',()=>{ if(body.scrollTop+body.clientHeight >= body.scrollHeight-300) renderChunk(list); });
-  });
+function applyLayout(){
+  document.body.classList.toggle('flat', layout==='flat');
+  [...document.querySelectorAll('#layoutSeg button')].forEach(b=>b.classList.toggle('on', b.dataset.layout===layout));
+  buildBoard(); rerenderAll();
 }
 
 // --- wiring ---
-function applyMode(){ document.body.classList.toggle('view-list', mode==='list'); document.body.classList.toggle('view-grid', mode==='grid');
-  [...document.querySelectorAll('#viewSeg button')].forEach(b=>b.classList.toggle('on', b.dataset.mode===mode)); }
-
-document.getElementById('viewSeg').addEventListener('click',e=>{ const b=e.target.closest('button'); if(!b)return; mode=b.dataset.mode; setLS('viewMode',mode); applyMode(); });
+document.getElementById('layoutSeg').addEventListener('click',e=>{ const b=e.target.closest('button'); if(!b)return; layout=b.dataset.layout; setLS('layout',layout); applyLayout(); });
+document.getElementById('bucket').addEventListener('change',e=>{ setLS('bucket',e.target.value); rerenderAll(); });
 document.getElementById('q').addEventListener('input',rerenderAll);
 document.getElementById('board').addEventListener('change',e=>{
-  if(e.target.matches('.col-sort')){ setLS('sort:'+e.target.dataset.list, e.target.value); rerenderCol(e.target.dataset.list); return; }
+  if(e.target.matches('.col-sort')){ setLS('sort:'+e.target.dataset.list, e.target.value); rerenderContainer(e.target.dataset.list, filterSort(e.target.dataset.list), e.target.dataset.list); return; }
   if(e.target.matches('[data-sel]')){
     const card=e.target.closest('.card'); const list=card.dataset.list; const k=card.dataset.k; const set=selSet(list);
     if(e.target.checked){ set.add(k); card.classList.add('sel'); } else { set.delete(k); card.classList.remove('sel'); }
@@ -306,22 +387,22 @@ document.getElementById('board').addEventListener('change',e=>{
 });
 document.getElementById('board').addEventListener('click',e=>{
   const card=e.target.closest('.card'); if(!card) return;
+  if(e.target.matches('[data-more]')){ const d=card.querySelector('.details'); const open=d.classList.toggle('show'); e.target.textContent=(open?'▾':'▸')+' details'; if(open) loadLastCorr(card); return; }
   if(e.target.matches('[data-gen]')) return genLetter(card);
   if(e.target.matches('[data-regen]')) return genLetter(card,{regenerate:true});
   if(e.target.matches('[data-send]')) return sendLetter(card, e.target);
-  if(e.target.matches('[data-copy]')){ const ta=card.querySelector('textarea'); navigator.clipboard.writeText(`Subject: ${card.querySelector('.subj').textContent.replace(/^Subject: /,'')}\n\n${ta.value}`); e.target.textContent='Copied ✓'; setTimeout(()=>e.target.textContent='Copy letter',1200); }
+  if(e.target.matches('[data-copy]')){ const ta=card.querySelector('textarea'); navigator.clipboard.writeText(`Subject: ${card.querySelector('.subj').textContent.replace(/^Subject: /,'')}\n\n${ta.value}`); e.target.textContent='Copied ✓'; setTimeout(()=>e.target.textContent='Copy',1200); }
 });
 
 const dens=document.getElementById('density');
 dens.value = LS('density',3);
-const applyScale = v => document.documentElement.style.setProperty('--scale', (0.72 + (+v)*0.11).toFixed(2)); // 1→0.83 … 5→1.27
+const applyScale = v => document.documentElement.style.setProperty('--scale', (0.78 + (+v)*0.09).toFixed(2));
 applyScale(dens.value);
 dens.addEventListener('input',e=>{ setLS('density',+e.target.value); applyScale(e.target.value); });
-
 const mfrToggle=document.getElementById('showMfr');
 mfrToggle.checked = LS('showMfr',false);
 mfrToggle.addEventListener('change',e=>{ setLS('showMfr',e.target.checked); rerenderAll(); });
-
+document.getElementById('bucket').value = LS('bucket','all');
 document.getElementById('copyBtn').addEventListener('click',()=>{
   const t=selectedAsText(); if(!t){alert('Nothing selected.');return;}
   navigator.clipboard.writeText(t); const b=document.getElementById('copyBtn'); b.textContent='Copied ✓'; setTimeout(()=>b.textContent='Copy selected',1400);
@@ -333,14 +414,12 @@ document.getElementById('exportBtn').addEventListener('click',()=>{
   a.href=URL.createObjectURL(blob); a.download=`dw-followup-${Date.now()}.md`; a.click();
 });
 
-mode = LS('viewMode','grid');
+layout = LS('layout','board');
 fetch(API('/api/lists')).then(r=>r.json()).then(j=>{
   DATA=j;
   document.getElementById('built').textContent = 'built '+new Date(j.generatedAt).toLocaleString();
-  buildBoard();
-  applyMode();
   paintSelCount();
-  rerenderAll();
+  applyLayout();
 }).catch(e=>{ document.getElementById('board').innerHTML='<div class="col-empty">Could not load lists: '+e.message+'<br>Run <code>npm run build</code>.</div>'; });
 </script>
 </body>
diff --git a/server.js b/server.js
index 0f80ce7..f6afda6 100644
--- a/server.js
+++ b/server.js
@@ -242,6 +242,20 @@ app.post('/api/letter', async (req, res) => {
   }
 });
 
+// GET /api/lastcorr?email= — newest email we exchanged with this address (George read-only).
+// Lazy per-card lookup (fired only when a card's details is expanded) so we never fan 600+
+// George searches at build time. Reuses georgeContext (info@ then steve-office).
+app.get('/api/lastcorr', async (req, res) => {
+  try {
+    const email = String(req.query.email || '').trim();
+    if (!email) return res.json({ found: false });
+    const ctx = await georgeContext(email);
+    res.json({ found: !!ctx.found, date: ctx.date || '', subject: ctx.subject || '', account: ctx.account || '' });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 // POST /api/send — actually EMAIL a generated letter, FROM info@designerwallcoverings.com,
 // via George's /api/send. Best-effort: reply into the client's last info@ thread so it lands
 // as a follow-up, not a cold email. George applies its own external-send guard; if it blocks,

← 306b791 Add 'Send now (info@)' — emails generated letter via George  ·  back to Dw Pitch Followup  ·  auto-save: 2026-07-07T14:31:09 (2 files) — public/index.html 28a382b →