[object Object]

← back to Pitch Guard

Followup scanner now auto-runs: warms on startup + daily, candidates auto-load on page open

a2e4c2f0dac18ebf01e48422f045f173f1dd4bce · 2026-05-19 11:16:23 -0700 · Steve

Files touched

Diff

commit a2e4c2f0dac18ebf01e48422f045f173f1dd4bce
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue May 19 11:16:23 2026 -0700

    Followup scanner now auto-runs: warms on startup + daily, candidates auto-load on page open
---
 public/index.html |  19 +++++++---
 server.js         | 104 ++++++++++++++++++++++++++++++------------------------
 2 files changed, 72 insertions(+), 51 deletions(-)

diff --git a/public/index.html b/public/index.html
index 385682c..93069bc 100644
--- a/public/index.html
+++ b/public/index.html
@@ -261,7 +261,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';
-let CANDIDATES = [], candScanned = false, candScanning = false;
+let CANDIDATES = [], candScanned = false, candScanning = false, candScannedAt = null;
 let candOpen = localStorage.getItem('pg.candOpen') !== '0';
 const riskCache = {}; // email -> full /api/contact result
 const liteCache = {}; // email -> light /api/risk result
@@ -459,8 +459,8 @@ function candidateSectionHTML(){
   let body;
   if(candScanning) body='<div class="empty" style="padding:24px"><div class="spin"></div> scanning sent mail for ghosted quotes…</div>';
   else if(!candScanned) body='<div class="vgroup-note">Not scanned yet — click <b>Scan</b> to find quote/pricing threads we replied to that got no client response in 5+ days and have no followup drafted.</div>';
-  else if(!CANDIDATES.length) body='<div class="vgroup-note">No followup candidates — every ghosted quote in range already has a draft.</div>';
-  else body=CANDIDATES.map(c=>
+  else if(!CANDIDATES.length) body='<div class="vgroup-note">No followup candidates — every ghosted quote in range already has a draft.'+(candScannedAt?' <span style="opacity:.7">· auto-scanned '+fmtDate(candScannedAt)+'</span>':'')+'</div>';
+  else body=(candScannedAt?'<div class="vgroup-note">Auto-scans daily · last scan '+fmtDate(candScannedAt)+' — review, then ✎ Draft it.</div>':'')+CANDIDATES.map(c=>
     '<div class="cand" data-email="'+esc(c.contact)+'">'+
       '<button class="cand-draft" data-draft-cand="'+esc(c.contact)+'" title="Create the followup draft">✎ Draft it</button>'+
       '<div class="who">'+esc(c.contactName)+'</div>'+
@@ -522,11 +522,21 @@ async function scanCandidates(){
     const r=await fetch('/api/followup-candidates'+(candScanned?'?fresh=1':''));
     const d=await r.json();
     if(d.error) throw new Error(d.error);
-    CANDIDATES=d.candidates||[]; candScanned=true;
+    CANDIDATES=d.candidates||[]; candScanned=true; candScannedAt=d.scannedAt||new Date().toISOString();
     toast('Scan done — '+CANDIDATES.length+' followup candidate'+(CANDIDATES.length===1?'':'s'));
   }catch(e){ toast('Scan failed: '+e.message,1); }
   candScanning=false; renderDrafts();
 }
+// quiet auto-load on page open — the server keeps the candidate list warm
+async function autoLoadCandidates(){
+  try{
+    const r=await fetch('/api/followup-candidates');
+    const d=await r.json();
+    if(d.error) return;
+    CANDIDATES=d.candidates||[]; candScanned=true; candScannedAt=d.scannedAt||null;
+    renderDrafts();
+  }catch(e){}
+}
 async function createFollowup(email){
   const c=CANDIDATES.find(x=>x.contact===email); if(!c) return;
   if(!confirm('Create a followup draft for:\n\n'+c.contactName+' <'+c.contact+'>\n"'+c.subject+'"  ('+c.daysSince+'d no reply)\n\nIt lands as a draft in info@ — nothing is sent until you send it.')) return;
@@ -777,6 +787,7 @@ if(!document.body.classList.contains('solo')){
 }
 wirePanelControls();
 loadDrafts().then(()=>{ if(soloDraft) selectDraft(soloDraft); });
+if(!document.body.classList.contains('solo')) autoLoadCandidates();
 </script>
 </body>
 </html>
diff --git a/server.js b/server.js
index edd3db6..1e05563 100644
--- a/server.js
+++ b/server.js
@@ -138,64 +138,74 @@ app.get('/api/drafts', async (req, res) => {
   } catch (e) { res.status(e.status || 500).json({ error: e.message }); }
 });
 
-// ── API: 5-day followup candidates (DRY-RUN — creates nothing) ─────────────
+// ── 5-day followup candidates (DRY-RUN — creates nothing) ──────────────────
 // Threads where info@ sent the last message and the client went quiet for 5+
 // days, with no followup draft already pending. Steve reviews, then approves.
+// Auto-scanned on startup + every 24h so the candidate list stays warm.
 let candCache = null;
 const CAND_TTL = 20 * 60 * 1000;
+
+async function runCandidateScan() {
+  // our sent mail, 5–35 days old — the window where a followup comes due
+  let sent = [], pageToken;
+  for (let pg = 0; pg < 2; pg++) {
+    const out = await george('/api/messages', { query: { account: ACCOUNT, q: 'in:sent older_than:5d newer_than:35d', maxResults: 100, pageToken } });
+    sent = sent.concat(out.messages || []);
+    pageToken = out.nextPageToken;
+    if (!pageToken) break;
+    await sleep(600);
+  }
+  // keep the newest sent message per recipient
+  const byContact = new Map();
+  for (const m of sent) {
+    const c = extractEmail(m.to);
+    if (!c) continue;
+    const ts = Date.parse(m.date) || 0;
+    const cur = byContact.get(c);
+    if (!cur || ts > cur.ts) byContact.set(c, { ts, msg: m });
+  }
+  const contacts = [...byContact.entries()].sort((a, b) => b[1].ts - a[1].ts).slice(0, 55);
+  const candidates = [];
+  for (const [contact, info] of contacts) {
+    const daysSince = Math.floor((Date.now() - info.ts) / 86400000);
+    if (daysSince < 5) continue;
+    if (classifyRecipient(contact) === 'vendor') continue;
+    try {
+      // did the client reply after our last message? (from: + after:YYYY/MM/DD)
+      const sd = new Date(info.ts);
+      const after = `${sd.getFullYear()}/${sd.getMonth() + 1}/${sd.getDate()}`;
+      const reply = await george('/api/search', { query: { account: ACCOUNT, q: `from:${contact} after:${after}`, maxResults: 2 } });
+      if ((reply.messages || []).some(x => !x.error)) continue;   // they replied → not ghosted
+      // is a followup draft to this contact already pending?
+      const dr = await george('/api/search', { query: { account: ACCOUNT, q: `in:draft to:${contact}`, maxResults: 2 } });
+      if ((dr.messages || []).some(x => !x.error)) continue;      // already drafted
+      candidates.push({
+        threadId: info.msg.threadId,
+        subject: info.msg.subject || '(no subject)',
+        contact, contactName: displayName(info.msg.to) || contact,
+        lastDate: info.msg.date, daysSince,
+      });
+    } catch (e) {}
+    await sleep(150);
+  }
+  candidates.sort((a, b) => b.daysSince - a.daysSince);
+  const payload = { count: candidates.length, scanned: contacts.length, candidates, scannedAt: new Date().toISOString() };
+  candCache = { t: Date.now(), v: payload };
+  return payload;
+}
+
 app.get('/api/followup-candidates', async (req, res) => {
   try {
     if (candCache && !req.query.fresh && Date.now() - candCache.t < CAND_TTL)
       return res.json({ ...candCache.v, cached: true });
-    // our sent mail, 5–35 days old — the window where a followup comes due
-    let sent = [], pageToken;
-    for (let pg = 0; pg < 2; pg++) {
-      const out = await george('/api/messages', { query: { account: ACCOUNT, q: 'in:sent older_than:5d newer_than:35d', maxResults: 100, pageToken } });
-      sent = sent.concat(out.messages || []);
-      pageToken = out.nextPageToken;
-      if (!pageToken) break;
-      await sleep(600);
-    }
-    // keep the newest sent message per recipient
-    const byContact = new Map();
-    for (const m of sent) {
-      const c = extractEmail(m.to);
-      if (!c) continue;
-      const ts = Date.parse(m.date) || 0;
-      const cur = byContact.get(c);
-      if (!cur || ts > cur.ts) byContact.set(c, { ts, msg: m });
-    }
-    const contacts = [...byContact.entries()].sort((a, b) => b[1].ts - a[1].ts).slice(0, 55);
-    const candidates = [];
-    for (const [contact, info] of contacts) {
-      const daysSince = Math.floor((Date.now() - info.ts) / 86400000);
-      if (daysSince < 5) continue;
-      if (classifyRecipient(contact) === 'vendor') continue;
-      try {
-        // did the client reply after our last message? (from: + after:YYYY/MM/DD)
-        const sd = new Date(info.ts);
-        const after = `${sd.getFullYear()}/${sd.getMonth() + 1}/${sd.getDate()}`;
-        const reply = await george('/api/search', { query: { account: ACCOUNT, q: `from:${contact} after:${after}`, maxResults: 2 } });
-        if ((reply.messages || []).some(x => !x.error)) continue;   // they replied → not ghosted
-        // is a followup draft to this contact already pending?
-        const dr = await george('/api/search', { query: { account: ACCOUNT, q: `in:draft to:${contact}`, maxResults: 2 } });
-        if ((dr.messages || []).some(x => !x.error)) continue;      // already drafted
-        candidates.push({
-          threadId: info.msg.threadId,
-          subject: info.msg.subject || '(no subject)',
-          contact, contactName: displayName(info.msg.to) || contact,
-          lastDate: info.msg.date, daysSince,
-        });
-      } catch (e) {}
-      await sleep(150);
-    }
-    candidates.sort((a, b) => b.daysSince - a.daysSince);
-    const payload = { count: candidates.length, scanned: contacts.length, candidates };
-    candCache = { t: Date.now(), v: payload };
-    res.json(payload);
+    res.json(await runCandidateScan());
   } catch (e) { res.status(e.status || 500).json({ error: e.message }); }
 });
 
+// Auto-scan: warm the candidate list ~30s after startup, then once a day.
+setTimeout(() => { runCandidateScan().catch(e => console.warn('[followup auto-scan] startup:', e.message)); }, 30_000);
+setInterval(() => { runCandidateScan().catch(e => console.warn('[followup auto-scan] daily:', e.message)); }, 24 * 60 * 60 * 1000);
+
 // ── API: full message (works for drafts too) ──────────────────────────────
 app.get('/api/message/:id', async (req, res) => {
   try {

← b5dbc8d Send no_source_tag to George so customer emails skip the Fro  ·  back to Pitch Guard  ·  Move George URL + auth to gitignored .env (was hardcoded in 30852ed →