← 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
M public/index.htmlM server.js
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 →