[object Object]

← back to Pitch Guard

Add recent-followup signal, draft delete (trash), and sort by name/date/subject/risk

fba50d0e2ddec33a10fa48dc6d3bda630c425f69 · 2026-05-19 09:19:13 -0700 · Steve

Files touched

Diff

commit fba50d0e2ddec33a10fa48dc6d3bda630c425f69
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue May 19 09:19:13 2026 -0700

    Add recent-followup signal, draft delete (trash), and sort by name/date/subject/risk
---
 public/index.html | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++++---
 server.js         | 27 ++++++++++++++++++-
 2 files changed, 102 insertions(+), 4 deletions(-)

diff --git a/public/index.html b/public/index.html
index fe31b47..ddb34c6 100644
--- a/public/index.html
+++ b/public/index.html
@@ -80,6 +80,17 @@
   .vgroup-hd .vct{margin-left:auto;background:var(--slatebg);color:var(--slate);
     border-radius:9px;padding:1px 8px;font-size:11px}
   .vgroup-note{padding:6px 14px 8px;font-size:11px;color:var(--muted);background:var(--cream)}
+  .subbar{display:flex;align-items:center;gap:8px;padding:7px 12px;border-bottom:1px solid var(--line);
+    background:var(--paper);flex-shrink:0}
+  .subbar label{font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);font-weight:700}
+  .subbar select{font:inherit;font-size:12px;border:1px solid var(--line);border-radius:5px;
+    padding:4px 7px;background:var(--cream);color:var(--ink);flex:1}
+  .panel.collapsed .subbar{display:none}
+  .draft-del{position:absolute;bottom:8px;right:10px;width:22px;height:22px;border-radius:5px;
+    border:1px solid var(--line);background:var(--paper);color:var(--muted);font-size:11px;
+    line-height:1;cursor:pointer;padding:0;display:none}
+  .draft:hover .draft-del{display:block}
+  .draft-del:hover{border-color:var(--red);color:var(--red);background:var(--redbg)}
   .badge{font-size:10px;font-weight:700;letter-spacing:.5px;border-radius:4px;padding:2px 6px;text-transform:uppercase}
   .r-high{background:var(--redbg);color:var(--red)}
   .r-elevated{background:var(--amberbg);color:var(--amber)}
@@ -173,6 +184,16 @@
       <button class="hd-btn" data-act="popout" title="Pop out into its own window">↗</button>
       <button class="hd-btn" data-act="collapse" title="Collapse panel">–</button>
     </div>
+    <div class="subbar">
+      <label>Sort</label>
+      <select id="sortSel">
+        <option value="newest">Newest first</option>
+        <option value="oldest">Oldest first</option>
+        <option value="name">Recipient name A→Z</option>
+        <option value="subject">Subject A→Z</option>
+        <option value="risk">Risk (high → clear)</option>
+      </select>
+    </div>
     <div class="pane-body" id="draftList">
       <div class="empty"><div class="spin"></div> loading drafts…</div>
     </div>
@@ -216,6 +237,7 @@
 const $ = s => document.querySelector(s);
 let DRAFTS = [], CURRENT = null, MORE = false;
 let vendorsOpen = localStorage.getItem('pg.vendorsOpen') === '1';
+let sortMode = localStorage.getItem('pg.sort') || 'newest';
 const riskCache = {}; // email -> full /api/contact result
 const liteCache = {}; // email -> light /api/risk result
 const verdictOf = email => riskCache[email] || liteCache[email] || null;
@@ -382,6 +404,20 @@ async function loadDrafts(){
     renderDrafts();
   }catch(e){ const el = $('#draftList'); if(el) el.innerHTML = '<div class="empty">⚠ '+esc(e.message)+'</div>'; }
 }
+const RISK_RANK = {high:0,elevated:1,caution:2,clear:3};
+function sortDrafts(arr){
+  const a = arr.slice(), ts = d => Date.parse(d.date)||0;
+  const nm = d => (d.toName||'').toLowerCase();
+  if(sortMode==='oldest') a.sort((x,y)=>ts(x)-ts(y));
+  else if(sortMode==='name') a.sort((x,y)=>nm(x).localeCompare(nm(y)));
+  else if(sortMode==='subject') a.sort((x,y)=>(x.subject||'').toLowerCase().localeCompare((y.subject||'').toLowerCase()));
+  else if(sortMode==='risk') a.sort((x,y)=>{
+    const rx=RISK_RANK[(verdictOf(x.toEmail)||{}).risk]??9, ry=RISK_RANK[(verdictOf(y.toEmail)||{}).risk]??9;
+    return rx-ry || ts(y)-ts(x);
+  });
+  else a.sort((x,y)=>ts(y)-ts(x)); // newest
+  return a;
+}
 function draftChip(d){
   const c = verdictOf(d.toEmail);
   let badge = '<span class="badge r-pending">·</span>';
@@ -391,14 +427,15 @@ function draftChip(d){
     '<div class="who">'+esc(d.toName)+'</div>'+
     '<div class="subj">'+esc(d.subject)+'</div>'+
     '<div class="meta">'+esc(d.toEmail||'no recipient')+' · '+fmtDate(d.date)+'</div>'+
+    '<button class="draft-del" data-del="'+d.id+'" title="Move draft to Trash">✕</button>'+
   '</div>';
 }
 function renderDrafts(){
   const el = $('#draftList');
   if(!el) return; // drafts panel popped out / not in this window
   if(!DRAFTS.length){ el.innerHTML='<div class="empty">No drafts waiting.</div>'; return; }
-  const clients = DRAFTS.filter(d=>d.kind!=='vendor');
-  const vendors = DRAFTS.filter(d=>d.kind==='vendor');
+  const clients = sortDrafts(DRAFTS.filter(d=>d.kind!=='vendor'));
+  const vendors = sortDrafts(DRAFTS.filter(d=>d.kind==='vendor'));
   let html = MORE?'<div class="meta" style="padding:7px 14px;color:var(--muted);border-bottom:1px solid var(--line)">Showing the '+DRAFTS.length+' most recent followup drafts — the bot backlog is larger.</div>':'';
   html += clients.length ? clients.map(draftChip).join('')
         : '<div class="empty">No client drafts in this batch.</div>';
@@ -412,6 +449,7 @@ function renderDrafts(){
   }
   el.innerHTML = html;
   el.querySelectorAll('.draft').forEach(n=>n.onclick=()=>selectDraft(n.dataset.id));
+  el.querySelectorAll('.draft-del').forEach(b=>b.onclick=e=>{ e.stopPropagation(); deleteDraft(b.dataset.del); });
   const vh = $('#vgroupHd');
   if(vh) vh.onclick=()=>{
     vendorsOpen=!vendorsOpen;
@@ -422,6 +460,20 @@ function renderDrafts(){
   const dc=$('#draftCount'); if(dc) dc.textContent = clients.length + (MORE?'+':'');
 }
 
+async function deleteDraft(id){
+  const d = DRAFTS.find(x=>x.id===id); if(!d) return;
+  if(!confirm('Move this draft to Trash?\n\nTo: '+(d.toEmail||d.toName||'(no recipient)')+'\n'+(d.subject||'')+
+    '\n\nIt stays recoverable in Gmail Trash for 30 days.')) return;
+  try{
+    const r = await fetch('/api/draft/'+encodeURIComponent(id)+'/trash',{method:'POST'});
+    const out = await r.json();
+    if(out.error) throw new Error(out.error);
+    DRAFTS = DRAFTS.filter(x=>x.id!==id);
+    if(CURRENT===id) CURRENT=null;
+    renderDrafts();
+    toast('Draft moved to Trash');
+  }catch(e){ toast('Delete failed: '+e.message,1); }
+}
 async function selectDraft(id){
   CURRENT = id; renderDrafts();
   const d = DRAFTS.find(x=>x.id===id); if(!d) return;
@@ -458,6 +510,15 @@ function renderCorr(c){
   else banner='<div class="risk-banner clear"><h3>✓ Clear to follow up</h3>'+
     '<p>No invoice / payment / shipping signals found in this contact’s history.</p></div>';
 
+  if(c.recentFollowup){
+    const fw=c.recentFollowup.daysAgo;
+    if(c.risk==='clear'||c.risk==='caution') banner=banner.replace(/risk-banner (clear|caution)/,'risk-banner elevated');
+    banner=banner.replace(/<\/div>\s*$/,'')+
+      '<p style="margin-top:8px;font-weight:600;color:var(--amber);">⚠ Followup already sent '+
+      (fw===0?'today':fw+' day'+(fw===1?'':'s')+' ago')+
+      ' — a second one this soon reads as spam. Hold off.</p></div>';
+  }
+
   const threads = c.threads.map(t=>{
     const tsig = t.signals.length? ' '+t.signals.map(s=>'<span class="sig-chip">'+esc(s)+'</span>').join('') : '';
     const msgs = t.messages.map(m=>{
@@ -526,9 +587,18 @@ function renderVerdict(c){
   else if(c.risk==='elevated') html='<h3>⚠ '+riskWord.elevated+'</h3><p>Invoice / order signals found in other threads — review the correspondence before sending.</p>';
   else if(c.risk==='caution') html='<h3>↔ '+riskWord.caution+'</h3><p>Multiple threads exist. Make sure this followup is not a duplicate.</p>';
   else html='<h3>✓ '+riskWord.clear+'</h3><p>No prior-order signals. Safe to follow up.</p>';
+  let boxClass = c.risk;
+  if(c.recentFollowup){
+    const fw=c.recentFollowup.daysAgo;
+    html+='<p style="margin-top:8px;padding:7px 9px;background:var(--amberbg);border-radius:6px;'+
+      'color:var(--amber);font-weight:600;font-size:12px;">⚠ A standard followup was already sent to '+
+      'this contact '+(fw===0?'today':fw+' day'+(fw===1?'':'s')+' ago')+
+      '. Sending another now reads as spam — hold off.</p>';
+    if(c.risk==='clear'||c.risk==='caution') boxClass='elevated';
+  }
   if(gated) html+='<label class="override"><input type="checkbox" id="ovr">'+
     '<span>I reviewed every thread and confirm this followup is still appropriate.</span></label>';
-  slot.className=''; slot.innerHTML='<div class="verdict '+c.risk+'">'+html+'</div>';
+  slot.className=''; slot.innerHTML='<div class="verdict '+boxClass+'">'+html+'</div>';
   const sb=$('#sendBtn');
   if(sb){
     if(gated){ sb.disabled=true; sb.classList.add('btn-danger'); sb.classList.remove('btn-go');
@@ -583,6 +653,9 @@ async function scanAll(){
 /* ════════ BOOT ════════ */
 $('#scanAll').onclick=scanAll;
 $('#resetLayout').onclick=resetLayout;
+const sortSel=$('#sortSel');
+if(sortSel){ sortSel.value=sortMode;
+  sortSel.onchange=()=>{ sortMode=sortSel.value; localStorage.setItem('pg.sort',sortMode); renderDrafts(); }; }
 const soloDraft = applySoloMode();
 if(!document.body.classList.contains('solo')){
   applyLayout();
diff --git a/server.js b/server.js
index 011371b..c88c1fc 100644
--- a/server.js
+++ b/server.js
@@ -185,10 +185,26 @@ app.get('/api/contact', async (req, res) => {
     if (threads.length > 1) level = Math.max(level, 1);
     const risk = level >= 3 ? 'high' : level >= 2 ? 'elevated' : level >= 1 ? 'caution' : 'clear';
 
+    // Did we already send this contact a standard followup in the last 7 days?
+    // (in:sent only — excludes the unsent draft so a queued draft doesn't count)
+    let recentFollowup = null;
+    try {
+      const FOLLOWUP_RE = /following up on the (quote|pricing)|wanted to check in and see if you/i;
+      const fu = await george('/api/search', { query: { account: ACCOUNT, q: `in:sent to:${email} newer_than:7d`, maxResults: 10 } });
+      for (const m of (fu.messages || [])) {
+        if (m.error) continue;
+        if (!FOLLOWUP_RE.test(`${m.subject || ''} ${m.snippet || ''}`)) continue;
+        const ts = Date.parse(m.date) || 0;
+        if (!recentFollowup || ts > recentFollowup.ts) {
+          recentFollowup = { ts, date: m.date, daysAgo: Math.max(0, Math.floor((Date.now() - ts) / 86400000)) };
+        }
+      }
+    } catch (e) { /* non-fatal — followup check is advisory */ }
+
     const result = {
       email, totalMessages: msgs.length, threadCount: threads.length,
       signals: [...allSignals].map(k => SIGNALS.find(s => s.key === k).label),
-      customerReplied, risk, threads,
+      customerReplied, risk, recentFollowup, threads,
     };
     cacheSet(email, result);
     res.json(result);
@@ -245,6 +261,15 @@ app.post('/api/send', async (req, res) => {
   } catch (e) { res.status(e.status || 500).json({ error: e.message }); }
 });
 
+// ── API: trash a draft (reversible — moves to Gmail Trash, recoverable 30 days) ──
+app.post('/api/draft/:id/trash', async (req, res) => {
+  try {
+    const out = await george('/api/messages/' + encodeURIComponent(req.params.id) + '/trash',
+      { method: 'POST', body: { account: ACCOUNT } });
+    res.json({ ok: true, trashed: out });
+  } catch (e) { res.status(e.status || 500).json({ error: e.message }); }
+});
+
 app.get('/api/health', (req, res) => res.json({ ok: true, george: GEORGE, account: ACCOUNT }));
 
 app.listen(PORT, () => console.log(`Pitch Guard → http://127.0.0.1:${PORT}  (info@${OURS} via George)`));

← e6f61db Split drafts into Clients + collapsible Vendors section (ven  ·  back to Pitch Guard  ·  Add Delete draft button to the Pitch panel footer e7305dd →