[object Object]

← back to Sample Followup Sweep

Console: exact-letter preview + populate Sent snapshot (18 letters sent 8/14); 25 memo stamps in FileMaker

2397a7f9621aa307889db881392ee895b54ecd19 · 2026-08-15 07:54:38 -0700 · Steve Abrams

Files touched

Diff

commit 2397a7f9621aa307889db881392ee895b54ecd19
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 15 07:54:38 2026 -0700

    Console: exact-letter preview + populate Sent snapshot (18 letters sent 8/14); 25 memo stamps in FileMaker
---
 data/sent.json | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js      | 74 +++++++++++++++++++++++++++++++++++++++++++-------
 2 files changed, 150 insertions(+), 10 deletions(-)

diff --git a/data/sent.json b/data/sent.json
new file mode 100644
index 0000000..f0272cc
--- /dev/null
+++ b/data/sent.json
@@ -0,0 +1,86 @@
+{
+  "harvested": "2026-08-15T00:00:00.000Z",
+  "source": "info@ Sent harvest 2026-08-15",
+  "byEmail": {
+    "polancol@oalusa.com": {
+      "lastSent": "2026-08-14T23:37:51.000Z",
+      "count": 2
+    },
+    "matt.schoffman@kravet.com": {
+      "lastSent": "2026-08-14T23:36:45.000Z",
+      "count": 1
+    },
+    "rhaiza@quadrilleinc.com": {
+      "lastSent": "2026-08-14T23:36:59.000Z",
+      "count": 1
+    },
+    "flovos@scalamandre.com": {
+      "lastSent": "2026-08-14T23:37:15.000Z",
+      "count": 1
+    },
+    "orders@wallquest.com": {
+      "lastSent": "2026-08-14T23:37:07.000Z",
+      "count": 1
+    },
+    "riverah@yorkwall.com": {
+      "lastSent": "2026-08-14T23:37:11.000Z",
+      "count": 1
+    },
+    "orders@yorkwall.com": {
+      "lastSent": "2026-08-14T23:37:11.000Z",
+      "count": 1
+    },
+    "nicole.monbarren@sandersondesigngroup.com": {
+      "lastSent": "2026-08-14T23:38:13.000Z",
+      "count": 1
+    },
+    "cathy.dy@thibautdesign.com": {
+      "lastSent": "2026-08-14T23:37:54.000Z",
+      "count": 1
+    },
+    "amy.d@astek.com": {
+      "lastSent": "2026-08-14T23:37:58.000Z",
+      "count": 1
+    },
+    "losangelesshowroom@pindlercorp.com": {
+      "lastSent": "2026-08-14T23:38:08.000Z",
+      "count": 1
+    },
+    "brewsterorders@brewp.com": {
+      "lastSent": "2026-08-14T23:38:16.000Z",
+      "count": 1
+    },
+    "therese@egg-and-dart.com": {
+      "lastSent": "2026-08-14T23:38:26.000Z",
+      "count": 1
+    },
+    "sales@sancarwallcovering.com": {
+      "lastSent": "2026-08-14T23:38:30.000Z",
+      "count": 1
+    },
+    "jballard@phillipjeffries.com": {
+      "lastSent": "2026-08-14T23:38:33.000Z",
+      "count": 1
+    },
+    "ari@quintushome.com": {
+      "lastSent": "2026-08-14T23:38:37.000Z",
+      "count": 1
+    },
+    "samples@koroseal.com": {
+      "lastSent": "2026-08-14T23:38:40.000Z",
+      "count": 1
+    },
+    "sshortino@koroseal.com": {
+      "lastSent": "2026-08-14T23:38:40.000Z",
+      "count": 1
+    },
+    "support@gimmersta.com": {
+      "lastSent": "2026-08-14T23:38:45.000Z",
+      "count": 1
+    },
+    "info@concertex.com": {
+      "lastSent": "2026-08-14T23:38:52.000Z",
+      "count": 1
+    }
+  }
+}
\ No newline at end of file
diff --git a/server.js b/server.js
index 650c5d2..e597128 100644
--- a/server.js
+++ b/server.js
@@ -17,6 +17,9 @@ const p = (...a) => path.join(ROOT, ...a);
 const readJSON = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
 const fleet = () => readJSON(p('data', 'fleet.json'), { vendors: [] });
 const contacts = () => readJSON(p('data', 'contacts.json'), {});
+const sent = () => readJSON(p('data', 'sent.json'), { byEmail: {} });
+const staged = () => readJSON(p('out', 'all-drafts.json'), []); // the EXACT letters staged/sent
+function stagedFor(slug) { return (staged() || []).find(d => (d.slugs || []).includes(slug)); }
 
 function vendorObj(v, c) {
   const cc = c[v.slug] || {};
@@ -40,7 +43,35 @@ const server = http.createServer(async (req, res) => {
   const u = new URL(req.url, 'http://x');
 
   if (u.pathname === '/' ) return send(res, 200, PAGE, 'text/html; charset=utf-8');
-  if (u.pathname === '/api/state') return send(res, 200, { fleet: fleet(), contacts: contacts() });
+  if (u.pathname === '/api/state') return send(res, 200, { fleet: fleet(), contacts: contacts(), sent: sent() });
+
+  // Re-harvest push: Claude searches info@ Sent (subject "Outstanding Memos") via MCP and
+  // POSTs { byEmail } (or a raw George message list) here to refresh the sent snapshot.
+  if (u.pathname === '/api/sent' && req.method === 'POST') {
+    const b = await body(req);
+    let byEmail = b.byEmail && typeof b.byEmail === 'object' ? b.byEmail : null;
+    if (!byEmail && Array.isArray(b.messages)) {
+      byEmail = {};
+      for (const m of b.messages) {
+        const subj = String(m.subject || '');
+        if (!/Sample Follow-Up.*Outstanding Memos/i.test(subj) || /^\s*Re:/i.test(subj)) continue;
+        const ms = m.internalDate ? Number(m.internalDate) : (m.date ? Date.parse(m.date) : NaN);
+        if (!isFinite(ms)) continue;
+        const iso = new Date(ms).toISOString();
+        for (let a of String(m.to || '').split(',')) {
+          a = a.trim().toLowerCase().replace(/^.*<|>.*$/g, '').trim();
+          if (!a.includes('@')) continue;
+          const cur = byEmail[a];
+          if (!cur) byEmail[a] = { lastSent: iso, count: 1 };
+          else { cur.count++; if (iso > cur.lastSent) cur.lastSent = iso; }
+        }
+      }
+    }
+    if (!byEmail) return send(res, 400, { error: 'provide { byEmail } or { messages }' });
+    const out = { harvested: new Date().toISOString(), source: b.source || 'POST /api/sent', byEmail };
+    fs.writeFileSync(p('data', 'sent.json'), JSON.stringify(out, null, 2));
+    return send(res, 200, { ok: true, addresses: Object.keys(byEmail).length });
+  }
 
   if (u.pathname === '/api/contact' && req.method === 'POST') {
     const b = await body(req); const c = contacts();
@@ -51,10 +82,14 @@ const server = http.createServer(async (req, res) => {
   }
 
   if (u.pathname === '/api/preview' && req.method === 'POST') {
-    const b = await body(req); const v = fleet().vendors.find(x => x.slug === b.slug);
+    const b = await body(req);
+    // Serve the EXACT letter that was staged/sent (consolidated + cleaned), so preview == what the vendor got.
+    const st = stagedFor(b.slug);
+    if (st) return send(res, 200, { to: st.to, subject: st.subject, html: st.body, count: st.items, exact: true });
+    const v = fleet().vendors.find(x => x.slug === b.slug);
     if (!v) return send(res, 404, { error: 'no vendor' });
     const d = draftFor(v, contacts());
-    return send(res, 200, { to: d.to, subject: d.subject, html: d.html, count: v.items.length });
+    return send(res, 200, { to: d.to, subject: d.subject, html: d.html, count: v.items.length, exact: false });
   }
 
   if (u.pathname === '/api/queue' && req.method === 'POST') {
@@ -94,6 +129,8 @@ const PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Sample Fol
  th{background:#fafafa;position:sticky;top:96px;color:#555;font-weight:600}
  .badge{font-size:11px;padding:2px 8px;border-radius:20px;font-weight:700}
  .ok{background:#e5f6ea;color:#137a34} .need{background:#fdf0dd;color:#9a6b12} .dead{background:#eee;color:#888}
+ .sentb{background:#e5eefc;color:#1a56c4} .sentx{font-size:11px;color:#1a56c4;font-weight:700;margin-left:4px}
+ td.sentcell{white-space:nowrap;font-size:12px;color:#555} td.sentcell .dt{color:#333}
  .vid{color:#999;font-size:11px}
  input.em{width:190px} input.ac{width:100px}
  .wrap{padding:0 20px 40px}
@@ -119,26 +156,38 @@ const PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>Sample Fol
      <option value="age">Sort: oldest first</option>
      <option value="name">Sort: vendor A–Z</option>
      <option value="ready">Sort: ready first</option>
+     <option value="unsent">Sort: not-yet-sent first</option>
+     <option value="sent">Sort: sent first</option>
    </select>
    <button class="primary" id="queueBtn">Queue selected → Drafts</button>
    <span id="status"></span>
  </div>
 </header>
 <div class="wrap"><table><thead><tr>
- <th></th><th>Vendor</th><th>Items</th><th>Re-req</th><th>Oldest</th><th>Status</th><th>Sample email</th><th>DW acct #</th><th></th>
+ <th></th><th>Vendor</th><th>Items</th><th>Re-req</th><th>Oldest</th><th>Status</th><th>Email sent</th><th>Sample email</th><th>DW acct #</th><th></th>
 </tr></thead><tbody id="rows"></tbody></table>
 <p class="muted">Queue writes <code>out/send-queue.json</code>. Ready = sample email + acct present. Then in chat: “create the queued drafts” → they land in info@ Drafts for you to send. 15 rows had no vendor id and aren’t shown.</p>
 </div>
 <div id="modal" onclick="if(event.target.id==='modal')this.style.display='none'"><div class="card" id="mbody"></div></div>
 <script>
-let S={fleet:{vendors:[]},contacts:{}};
+let S={fleet:{vendors:[]},contacts:{},sent:{byEmail:{}}};
 const sortKey=localStorage.getItem('sfsort')||'items';
-async function load(){const r=await fetch('/api/state');S=await r.json();document.getElementById('sort').value=sortKey;render();
- document.getElementById('meta').textContent=S.fleet.vendors.length+' vendors · '+S.fleet.totalItems+' items · window '+S.fleet.window;}
+async function load(){const r=await fetch('/api/state');S=await r.json();if(!S.sent)S.sent={byEmail:{}};document.getElementById('sort').value=sortKey;render();
+ const nSent=S.fleet.vendors.filter(v=>sentInfo(v).sent).length;
+ document.getElementById('meta').textContent=S.fleet.vendors.length+' vendors · '+S.fleet.totalItems+' items · window '+S.fleet.window+' · '+nSent+' emailed / '+(S.fleet.vendors.length-nSent)+' not yet';}
 function ready(v){const c=S.contacts[v.slug]||{};return c.sample_email&&c.account_number;}
+// Resolve whether this vendor's follow-up was actually emailed, from the Sent-folder snapshot.
+// Matches the vendor's recipient address(es) (contacts.sample_email) against sent.byEmail.
+function sentInfo(v){const c=S.contacts[v.slug]||{};const be=(S.sent&&S.sent.byEmail)||{};
+ const addrs=String(c.sample_email||'').split(',').map(a=>a.trim().toLowerCase()).filter(a=>a.includes('@'));
+ let last=null,count=0;for(const a of addrs){const e=be[a];if(e){count+=e.count||1;if(!last||e.lastSent>last)last=e.lastSent;}}
+ return{sent:!!last,lastSent:last,count};}
+function fmtSent(iso){try{const d=new Date(iso);return d.toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});}catch(e){return iso;}}
 function sorted(){const k=document.getElementById('sort').value;localStorage.setItem('sfsort',k);const a=[...S.fleet.vendors];
  if(k==='items')a.sort((x,y)=>y.count-x.count);if(k==='age')a.sort((x,y)=>y.items[0].age-x.items[0].age);
- if(k==='name')a.sort((x,y)=>x.name.localeCompare(y.name));if(k==='ready')a.sort((x,y)=>(ready(y)?1:0)-(ready(x)?1:0));return a;}
+ if(k==='name')a.sort((x,y)=>x.name.localeCompare(y.name));if(k==='ready')a.sort((x,y)=>(ready(y)?1:0)-(ready(x)?1:0));
+ if(k==='unsent')a.sort((x,y)=>(sentInfo(x).sent?1:0)-(sentInfo(y).sent?1:0));
+ if(k==='sent')a.sort((x,y)=>{const sx=sentInfo(x),sy=sentInfo(y);return (sy.lastSent||'').localeCompare(sx.lastSent||'');});return a;}
 function itemsTable(v){
  let h='<table class=detail><thead><tr><th>Pattern (Mfr#)</th><th>DW#</th><th>Client</th><th>Initial request</th><th>Re-req</th></tr></thead><tbody>';
  for(const i of v.items){
@@ -148,6 +197,9 @@ function itemsTable(v){
  }
  return h+'</tbody></table>';
 }
+function sentCell(v){const s=sentInfo(v);
+ if(!s.sent)return '<span class=muted>— not sent</span>';
+ return '<span class="badge sentb">SENT</span> <span class=dt title="'+esc(s.lastSent)+'">'+esc(fmtSent(s.lastSent))+'</span>'+(s.count>1?'<span class=sentx>×'+s.count+'</span>':'');}
 function render(){const t=document.getElementById('rows');t.innerHTML='';for(const v of sorted()){const c=S.contacts[v.slug]||{};const r=ready(v);
  const tr=document.createElement('tr');tr.innerHTML=
  '<td><input type=checkbox class=pick data-slug="'+v.slug+'"></td>'+
@@ -156,12 +208,13 @@ function render(){const t=document.getElementById('rows');t.innerHTML='';for(con
  '<td>'+(v.reReqCount?'<span class="badge rrb">'+v.reReqCount+'</span>':'<span class=muted>–</span>')+'</td>'+
  '<td>'+v.items[0].age+'d</td>'+
  '<td><span class="badge '+(r?'ok':'need')+'">'+(r?'READY':'NEEDS')+'</span></td>'+
+ '<td class="sentcell">'+sentCell(v)+'</td>'+
  '<td><input class="em" data-slug="'+v.slug+'" data-f="sample_email" value="'+esc(c.sample_email||'')+'" placeholder="sample email"></td>'+
  '<td><input class="ac" data-slug="'+v.slug+'" data-f="account_number" value="'+esc(c.account_number||'')+'" placeholder="acct #"></td>'+
  '<td><button data-prev="'+v.slug+'">preview</button></td>';
  t.appendChild(tr);
  const dr=document.createElement('tr');dr.className='detailrow';dr.style.display='none';
- dr.innerHTML='<td></td><td colspan=8>'+itemsTable(v)+'</td>';t.appendChild(dr);}
+ dr.innerHTML='<td></td><td colspan=9>'+itemsTable(v)+'</td>';t.appendChild(dr);}
  t.querySelectorAll('input.em,input.ac').forEach(i=>i.addEventListener('change',saveContact));
  t.querySelectorAll('button[data-prev]').forEach(b=>b.addEventListener('click',()=>preview(b.dataset.prev)));
  t.querySelectorAll('button.exp').forEach(b=>b.addEventListener('click',e=>{const dr=e.target.closest('tr').nextElementSibling;const open=dr.style.display==='none';dr.style.display=open?'table-row':'none';e.target.textContent=(open?'▼ ':'▶ ')+e.target.textContent.slice(2);}));}
@@ -170,7 +223,8 @@ async function saveContact(e){const el=e.target;const slug=el.dataset.slug;const
  await fetch('/api/contact',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({slug,sample_email:em,account_number:ac})});
  S.contacts[slug]=Object.assign({},S.contacts[slug],{sample_email:em,account_number:ac});render();}
 async function preview(slug){const r=await fetch('/api/preview',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({slug})});const d=await r.json();
- document.getElementById('mbody').innerHTML='<div class=muted>To: '+esc(d.to)+' · '+d.count+' items</div><hr>'+d.html;document.getElementById('modal').style.display='flex';}
+ const tag=d.exact?'<span style="color:#137a34;font-weight:700">✓ exact letter sent to vendor</span>':'<span style="color:#9a6b12">live preview — not the staged letter</span>';
+ document.getElementById('mbody').innerHTML='<div class=muted>To: '+esc(d.to)+' · '+d.count+' items · '+tag+'</div><hr>'+d.html;document.getElementById('modal').style.display='flex';}
 document.getElementById('all').addEventListener('change',e=>{document.querySelectorAll('.pick').forEach(c=>c.checked=e.target.checked);});
 document.getElementById('sort').addEventListener('change',render);
 document.getElementById('queueBtn').addEventListener('click',async()=>{

← 2678e39 Add send-drafts.js (Steve-triggered sender via George approv  ·  back to Sample Followup Sweep  ·  Console: per-vendor Send button (fires exact letter via Geor 29a0213 →