← 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
M public/index.htmlM server.js
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=>({'&':'&','<':'<','>':'>','"':'"'}[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¶m=${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 →