[object Object]

← back to Dw Pitch Followup

pitch: card/UX batch — dismiss-completed, park vendor memos, quote/order label fix, FM deep-links, live sent-index, expandable columns

c6d5609cbed975a4a6db0a8f2c10a205a1f7f082 · 2026-07-14 11:12:10 -0700 · Steve

- Dismiss any card as ✓ Done: instant DOM removal + persisted, never re-shown (localStorage done: flag; filterSort/flatRows exclude)
- Park Vendor Memos Overdue (list3): dropped from board + bucket via PARKED set (un-park = 1 line); grid tracks now match visible column count
- Fix quote-vs-order contradiction: postFlag shows BOOKED orders only; drawer relabels "Quoted since we sent samples" when none booked
- Per-invoice details: drawer splits Orders vs Quotes, each row shows item detail; goodLbl drops stripMfr punctuation-junk items
- FileMaker deep-links: invoice# and client acct now Find the exact record (fmpInvoice/fmpClient script+param, USE_FM_SCRIPT on) — needs GotoInvoice/GotoClient scripts in FM
- Real "emailed on <date>" chip from server /api/sent-index (cross-browser, 311 recipients from the send log)
- /api/sent now merges the local log with a live Gmail Sent view (George), 60s cache, invalidated on send
- Expand/shrink any board column (⤢ toggle, persisted)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit c6d5609cbed975a4a6db0a8f2c10a205a1f7f082
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jul 14 11:12:10 2026 -0700

    pitch: card/UX batch — dismiss-completed, park vendor memos, quote/order label fix, FM deep-links, live sent-index, expandable columns
    
    - Dismiss any card as ✓ Done: instant DOM removal + persisted, never re-shown (localStorage done: flag; filterSort/flatRows exclude)
    - Park Vendor Memos Overdue (list3): dropped from board + bucket via PARKED set (un-park = 1 line); grid tracks now match visible column count
    - Fix quote-vs-order contradiction: postFlag shows BOOKED orders only; drawer relabels "Quoted since we sent samples" when none booked
    - Per-invoice details: drawer splits Orders vs Quotes, each row shows item detail; goodLbl drops stripMfr punctuation-junk items
    - FileMaker deep-links: invoice# and client acct now Find the exact record (fmpInvoice/fmpClient script+param, USE_FM_SCRIPT on) — needs GotoInvoice/GotoClient scripts in FM
    - Real "emailed on <date>" chip from server /api/sent-index (cross-browser, 311 recipients from the send log)
    - /api/sent now merges the local log with a live Gmail Sent view (George), 60s cache, invalidated on send
    - Expand/shrink any board column (⤢ toggle, persisted)
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 public/index.html | 70 +++++++++++++++++++++++++++++++++++++++++++------------
 server.js         | 66 ++++++++++++++++++++++++++++++++++++++++++++++-----
 2 files changed, 115 insertions(+), 21 deletions(-)

diff --git a/public/index.html b/public/index.html
index 988571a..f95391d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -29,6 +29,10 @@
   .pletter .acts{align-items:center;gap:8px;padding-bottom:8px;border-bottom:1px solid var(--line);margin-bottom:8px}
   .pletter .acts .send{margin-left:auto}
   .btn.reorder{background:#3a1512;color:var(--red);border-color:var(--red);font-weight:700}
+  .btn.done{background:transparent;color:var(--mut);border-color:var(--line);font-size:12px;padding:5px 9px;margin-left:auto}
+  .btn.done:hover{color:var(--green);border-color:var(--green)}
+  a.fmlink{color:var(--gold);text-decoration:none;border-bottom:1px dotted color-mix(in srgb,var(--gold) 55%,transparent)}
+  a.fmlink:hover{filter:brightness(1.12);border-bottom-style:solid}
   .reorder-note{color:var(--red);font-weight:600;margin:6px 0}
   .spacer{flex:1}
   .selcount{color:var(--gold);font-size:13px}
@@ -44,6 +48,9 @@
   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 - 150px);overflow:hidden}
+  .col.wide{grid-column:span 2}
+  .col-exp{background:transparent;border:1px solid var(--line);color:var(--mut);border-radius:6px;cursor:pointer;font-size:12px;line-height:1;padding:2px 6px;margin-left:6px}
+  .col-exp:hover{color:var(--gold);border-color:var(--gold)}
   .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}
@@ -159,8 +166,17 @@ const PARKED = new Set(['list3']);
 const LISTS = ALL_LISTS.filter(l=>!PARKED.has(l));
 const API = p => `${location.protocol}//${location.host}${p}`;
 const FM_HOST = 'designerwallcoverings.account.filemaker-cloud.com';
-const USE_FM_SCRIPT = false; // true once a "GotoInvoice" FM script exists (jumps to record)
+// Flip to true ONCE the two FileMaker scripts exist (then the links jump to the exact record):
+//   GotoInvoice — Enter Find Mode → Set Field [invoice::Invoice ; Get(ScriptParameter)] → Perform Find
+//   GotoClient  — Enter Find Mode → Set Field [Clients::Account Number ; Get(ScriptParameter)] → Perform Find
+// Until then it's false: the buttons still open the right FM file (no error), just don't auto-jump.
+// ENABLED: links pass a find param to GotoInvoice / GotoClient (add those 2 scripts in FileMaker).
+const USE_FM_SCRIPT = true;
 const fmpInvoice = inv => USE_FM_SCRIPT ? `fmp://${FM_HOST}/invoice?script=GotoInvoice&param=${encodeURIComponent(inv)}` : `fmp://${FM_HOST}/invoice`;
+const fmpClient  = acct => USE_FM_SCRIPT ? `fmp://${FM_HOST}/Clients?script=GotoClient&param=${encodeURIComponent(acct)}` : `fmp://${FM_HOST}/Clients`;
+// Rendered link helpers (used on cards + drawer).
+const invoiceLink = (inv,txt) => inv ? `<a class="fmlink" href="${fmpInvoice(inv)}" title="Open invoice #${esc(inv)} in FileMaker Pro">🗂 ${esc(txt||('#'+inv))}</a>` : (txt?esc(txt):'');
+const clientLink  = (acct,label) => acct ? `<a class="fmlink" href="${fmpClient(acct)}" title="Open client ${esc(acct)} in FileMaker Pro">${esc(label)}</a>` : esc(label);
 const META = {
   list1:{title:'Samples Sent ≥90%', tag:'Samples ≥90%', defn:'≥90% of ordered samples shipped — follow up to convert.'},
   list2:{title:'Invoiced · No Order', tag:'Inv · No Order', defn:'Got a real quote in the last ~4 months (with totals + details) but never booked an order.'},
@@ -223,15 +239,18 @@ function discoBanner(r){
 }
 // Compact card flag: any invoice placed since we sent samples (possible conversion).
 function postFlag(r){
-  const p=Array.isArray(r.post_sample_invoices)?r.post_sample_invoices:[]; if(!p.length) return '';
-  const detail=p.slice(0,3).map(i=>`#${esc(i.invoice)} ${esc(money(i.total))}`).join(' · ')+(p.length>3?` +${p.length-3} more`:'');
+  // BOOKED orders only — a quote is NOT an order (else a "quoted · no order" card contradicts itself).
+  const p=(Array.isArray(r.post_sample_invoices)?r.post_sample_invoices:[]).filter(i=>i.booked); if(!p.length) return '';
+  const detail=p.slice(0,3).map(i=>{const it=goodLbl(i.item);return `#${esc(i.invoice)} ${esc(money(i.total))}${it?` · ${esc(it)}`:''}`}).join(' · ')+(p.length>3?` +${p.length-3} more`:'');
   return `<div class="postflag">🧾 Ordered since sampling — ${detail}</div>`;
 }
 // Panel: full detail — #, total, sku/item, quote/booked, date.
 function postSampleBlock(r){
   const p=Array.isArray(r.post_sample_invoices)?r.post_sample_invoices:[]; if(!p.length) return '';
-  const rows=p.map(i=>`<div class="inv"><span class="chip ${i.booked?'g':'a'}">${i.booked?'BOOKED':'QUOTE'}</span> <a href="${fmpInvoice(i.invoice)}">#${esc(i.invoice)}</a> <b>${esc(money(i.total))}</b>${i.item?` · ${esc(i.item)}`:''}${i.date?` · ${esc(fmtDay(i.date))}`:''}</div>`).join('');
-  return `<div class="postflag" style="margin-bottom:12px">🧾 <b>Ordered since we sent samples${r.post_sample_since?` (since ${esc(fmtDay(r.post_sample_since))})`:''}:</b>${rows}</div>`;
+  const anyBooked=p.some(i=>i.booked);
+  const rows=p.map(i=>{const it=goodLbl(i.item);return `<div class="inv"><span class="chip ${i.booked?'g':'a'}">${i.booked?'BOOKED':'QUOTE'}</span> ${invoiceLink(i.invoice)} <b>${esc(money(i.total))}</b>${it?` · ${esc(it)}`:''}${i.date?` · ${esc(fmtDay(i.date))}`:''}</div>`}).join('');
+  const heading=anyBooked?'Ordered since we sent samples':'Quoted since we sent samples (no order yet)';
+  return `<div class="postflag" style="margin-bottom:12px">🧾 <b>${heading}${r.post_sample_since?` (since ${esc(fmtDay(r.post_sample_since))})`:''}:</b>${rows}</div>`;
 }
 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}
@@ -267,22 +286,27 @@ function flatRows(){
 
 function cardHTML(list,r){
   const k=rowKey(list,r),sel=selSet(list).has(k);
+  // Emailed-date chip: local send record (this browser) OR the server-side sent index
+  // (every email ever sent from this tool, by recipient) — so the chip shows cross-browser.
   const sentRec=LS('sent:'+list+':'+k,null);
-  const sentChip=sentRec?` <span class="chip sent" title="${esc(new Date(sentRec.at).toISOString())}">✓ emailed ${esc(fmtDay(new Date(sentRec.at).toISOString()))}</span>`:'';
+  const idxTs=(!sentRec&&r.email&&window.SENT_IDX)?SENT_IDX[String(r.email).toLowerCase()]:null;
+  const sentAt=sentRec?sentRec.at:(idxTs||null);
+  const sentChip=sentAt?` <span class="chip sent" title="${esc(new Date(sentAt).toISOString())}">✓ emailed ${esc(fmtDay(new Date(sentAt).toISOString()))}</span>`:'';
   const lifeChip=r.lifetime_sales?` <span class="chip life">💰 ${esc(money(r.lifetime_sales))} lifetime</span>`:'';
   const projLine=r.project?`<div class="proj">🏠 ${esc(lbl(r.project))}</div>`:'';
-  const quoteItems=(list==='list2'&&Array.isArray(r.quote_items)&&r.quote_items.length)?`<div class="proj">🧾 ${esc(r.quote_items.map(lbl).filter(Boolean).join(' · '))}</div>`:'';
+  const qi=(list==='list2'&&Array.isArray(r.quote_items))?r.quote_items.map(goodLbl).filter(Boolean):[];
+  const quoteItems=qi.length?`<div class="proj">🧾 ${esc(qi.join(' · '))}</div>`:'';
   const btag=layout==='flat'?`<span class="btag">${esc(META[list].tag)}</span>`:'';
   let head,facts='';
   if(list==='list3'){
     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">${btag}${esc(r.company)}${sentChip}</div><div class="sub">${esc(r.email||'no email')} · acct ${esc(r.account)}</div></div>`;
+    head=`<div><div class="co">${btag}${esc(r.company)}${sentChip}</div><div class="sub">${esc(r.email||'no email')} · ${clientLink(r.account,'acct '+r.account)}</div></div>`;
     const qCountChip=(r.quote_count>1)?` <span class="chip a">${r.quote_count} quotes</span>`:'';
     facts=`<div class="chips"><span class="chip a">💬 ${esc(money(r.quote_total))} quoted · no order</span>${qCountChip}${dateChip(r)}</div>`;
   } else {
-    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>`;
+    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')} · ${clientLink(r.account,'acct '+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>`:''}${lifeChip}${dateChip(r)}</div>`;
   }
   return `<div class="card ${sel?'sel':''}" data-k="${esc(k)}" data-list="${list}">
@@ -337,7 +361,11 @@ function merchBlock(r){
     ? `<div class="drow"><span class="lifebig">No orders booked</span> <span class="chip a">${esc(money(quoted))} in open quotes</span></div>`
     : `<div class="drow"><span class="lifebig">Lifetime sales: ${esc(money(life))}</span>${quoted?` <span class="chip a">+ ${esc(money(quoted))} open quotes</span>`:''}</div>`;
   if(inv.length){
-    out+='<div class="drow">'+inv.slice(0,15).map(x=>`<div class="inv"><span class="chip ${x.booked?'g':'a'}">${x.booked?'BOOKED':'QUOTE'}</span> <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))}`:''}</div>`).join('')+'</div>';
+    // Each invoice: BOOKED/QUOTE badge · invoice# (opens in FileMaker) · total · item detail · date.
+    const invRow=x=>{const it=goodLbl(x.item);return `<div class="inv"><span class="chip ${x.booked?'g':'a'}">${x.booked?'BOOKED':'QUOTE'}</span> ${invoiceLink(x.invoice)} <b>${esc(money(x.total))}</b>${it?` · ${esc(it)}`:''}${x.date?` · ${esc(fmtDay(x.date))}`:''}</div>`};
+    const booked=inv.filter(x=>x.booked), quotes=inv.filter(x=>!x.booked);
+    if(booked.length) out+=`<div class="drow"><b>Orders (${booked.length})</b>${booked.slice(0,15).map(invRow).join('')}</div>`;
+    if(quotes.length) out+=`<div class="drow"><b>Quotes · no order (${quotes.length})</b>${quotes.slice(0,15).map(invRow).join('')}</div>`;
   }
   return out;
 }
@@ -462,8 +490,11 @@ function buildBoard(){
     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>`;
+      const wide=LS('wide:'+list,false);
+      return `<section class="col${wide?' wide':''}" data-col="${list}"><div class="col-head"><div class="col-title">${META[list].title}<span class="n" id="n-${list}">0</span><button class="col-exp" data-exp="${list}" title="Expand / shrink this column">${wide?'⤡':'⤢'}</button></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('');
+    // grid tracks match the number of visible (non-parked) columns so there's no empty gap
+    board.style.gridTemplateColumns='repeat('+LISTS.length+',minmax(0,1fr))';
     LISTS.forEach(list=>{const body=document.getElementById('body-'+list);body.addEventListener('scroll',()=>{if(body.scrollTop+body.clientHeight>=body.scrollHeight-300)renderChunk(list)})});
   } else {
     const fsaved=LS('sort:flat','lifetime_sales:desc');
@@ -483,6 +514,9 @@ document.getElementById('board').addEventListener('change',e=>{
   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')}saveSel(list,set)}
 });
 document.getElementById('board').addEventListener('click',e=>{
+  // column expand/shrink toggle (not inside a card) — handle before the card early-return
+  const exp=e.target.closest('[data-exp]');
+  if(exp){const list=exp.dataset.exp;const col=exp.closest('.col');const now=!col.classList.contains('wide');col.classList.toggle('wide',now);setLS('wide:'+list,now);exp.textContent=now?'⤡':'⤢';return}
   const card=e.target.closest('.card'); if(!card)return;
   if(e.target.matches('[data-done]')) return markDone(card);
   if(e.target.matches('[data-gen]')) return openPanelFor(card,{generate:true});
@@ -525,10 +559,16 @@ document.getElementById('copyBtn').addEventListener('click',()=>{const t=selecte
 document.getElementById('exportBtn').addEventListener('click',()=>{if(!totalSel()){alert('Nothing selected.');return}const md=`# DW Follow-Up — selected correspondence\n_${totalSel()} selected · ${new Date().toLocaleString()}_\n\n`+selectedAsText();const blob=new Blob([md],{type:'text/markdown'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=`dw-followup-${Date.now()}.md`;a.click()});
 
 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();
-  paintSelCount(); applyLayout();
-}).catch(e=>{document.getElementById('board').innerHTML='<div class="col-empty">Could not load lists: '+esc(e.message)+'<br>Run <code>npm run build</code>.</div>'});
+// Load the real "who have we emailed" index first (best-effort) so every card can show a
+// live emailed-date chip, then render the lists. A slow/failed index never blocks the board.
+window.SENT_IDX={};
+fetch(API('/api/sent-index')).then(r=>r.json()).then(j=>{window.SENT_IDX=j.idx||{}}).catch(()=>{})
+  .finally(()=>{
+    fetch(API('/api/lists')).then(r=>r.json()).then(j=>{
+      DATA=j; document.getElementById('built').textContent='built '+new Date(j.generatedAt).toLocaleString();
+      paintSelCount(); applyLayout();
+    }).catch(e=>{document.getElementById('board').innerHTML='<div class="col-empty">Could not load lists: '+esc(e.message)+'<br>Run <code>npm run build</code>.</div>'});
+  });
 </script>
 </body>
 </html>
diff --git a/server.js b/server.js
index 675dc32..2a4420e 100644
--- a/server.js
+++ b/server.js
@@ -496,6 +496,7 @@ app.post('/api/send', async (req, res) => {
     const sentAt = new Date().toISOString();
     try {
       fs.appendFileSync(SENT_LOG, JSON.stringify({ ts: sentAt, to: String(to).trim(), subject, list: list || '', account: acctNo || '', company: company || '', messageId: j.messageId || '', threadId: j.threadId || '', inThread: !!(j.inThread || replyToMessageId), body }) + '\n');
+      _sentCache.at = 0; // invalidate so the Sent log + chips show this send immediately
     } catch (e) { /* logging must never block a successful send */ }
     res.json({ success: true, account: 'info', messageId: j.messageId, threadId: j.threadId, inThread: !!(j.inThread || replyToMessageId), sentAt, cost: '$0 (Gmail relay via George)' });
   } catch (e) {
@@ -503,13 +504,66 @@ app.post('/api/send', async (req, res) => {
   }
 });
 
-// GET /api/sent — the permanent record of every email sent from this tool (newest first).
-app.get('/api/sent', (_req, res) => {
+// The local append-only record of every letter sent THROUGH this tool.
+function readSentLog() {
+  if (!fs.existsSync(SENT_LOG)) return [];
+  return fs.readFileSync(SENT_LOG, 'utf8').split('\n').filter((l) => l.trim())
+    .map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
+}
+// LIVE view of follow-up letters actually sent from info@ (Gmail Sent), scoped to our
+// follow-up subject — so the Sent log + per-card chips reflect reality even for sends made
+// outside this tool instance (the local jsonl only sees sends fired from THIS process).
+async function georgeSentLetters(maxResults = 200) {
+  try {
+    const q = encodeURIComponent('in:sent subject:(Designer Wallcoverings samples)');
+    const r = await fetch(`${GEORGE}/api/search?q=${q}&account=info&maxResults=${maxResults}`, {
+      headers: { Authorization: GEORGE_AUTH }, signal: AbortSignal.timeout(10000),
+    });
+    if (!r.ok) return [];
+    const j = await r.json();
+    return (j.messages || []).map((m) => {
+      const toM = String(m.to || m.recipient || m.headers?.to || '').match(/[\w.+-]+@[\w.-]+\.\w+/);
+      return { ts: m.date || '', to: toM ? toM[0].toLowerCase() : '', subject: m.subject || '', messageId: m.id || '', inThread: false, live: true };
+    }).filter((x) => x.ts && x.to);
+  } catch { return []; }
+}
+// Merge local tool log + live Gmail Sent; dedup by recipient+day; newest first. 60s cache so
+// a page load (sent-index) + a log open don't each pay the George round-trip.
+let _sentCache = { at: 0, data: null };
+async function collectSent() {
+  if (_sentCache.data && Date.now() - _sentCache.at < 60000) return _sentCache.data;
+  const local = readSentLog().map((o) => ({ ...o, to: String(o.to || '').toLowerCase() }));
+  const live = await georgeSentLetters(200);
+  const key = (x) => `${x.to}|${String(x.ts).slice(0, 10)}`;
+  const map = new Map();
+  for (const x of live) map.set(key(x), x);
+  for (const x of local) {
+    const k = key(x), cur = map.get(k);
+    if (cur) map.set(k, { ...cur, company: x.company || cur.company, list: x.list || cur.list, subject: x.subject || cur.subject, inThread: x.inThread || cur.inThread });
+    else map.set(k, x);
+  }
+  const data = [...map.values()].sort((a, b) => String(b.ts).localeCompare(String(a.ts)));
+  _sentCache = { at: Date.now(), data };
+  return data;
+}
+
+// GET /api/sent — every follow-up email sent (LIVE: local log ∪ Gmail Sent), newest first.
+app.get('/api/sent', async (_req, res) => {
+  try {
+    const sent = await collectSent();
+    res.json({ count: sent.length, sent: sent.slice(0, 800), live: true });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
+// GET /api/sent-index — { emailLowercased → newest sent ISO } for the per-card "emailed" chip.
+app.get('/api/sent-index', async (_req, res) => {
   try {
-    if (!fs.existsSync(SENT_LOG)) return res.json({ count: 0, sent: [] });
-    const lines = fs.readFileSync(SENT_LOG, 'utf8').split('\n').filter((l) => l.trim());
-    const sent = lines.slice(-800).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean).reverse();
-    res.json({ count: lines.length, sent });
+    const sent = await collectSent();
+    const idx = {};
+    for (const s of sent) { const t = s.to; if (t && (!idx[t] || String(s.ts) > String(idx[t]))) idx[t] = s.ts; }
+    res.json({ count: Object.keys(idx).length, idx });
   } catch (e) {
     res.status(500).json({ error: e.message });
   }

← d4a8d30 auto-save: 2026-07-14T11:00:40 (1 files) — public/index.html  ·  back to Dw Pitch Followup  ·  pitch: FIX critical George 401 — resolve GEORGE_AUTH from se 6c560f3 →