[object Object]

← back to Dw Pitch Followup

pitch: Follow up? YES/NO chip on every card + per-column ⚡ Triage (batch, cached)

5e218be25e8d2adb44e38677871205c9e395c254 · 2026-07-15 10:15:31 -0700 · Steve

Server keeps a persistent follow-up cache (data/followup-cache.json): analyzeFollowup writes each verdict; drawer opens + a new POST /api/followup-batch (concurrency 3, skips <24h-fresh) fill it. GET /api/followup-cache feeds card badges at boot. Cards show a green "Follow up" / tan "Hold" chip from cache; ⚡ Triage per column batches all un-cached clients with live progress; opening a drawer lights that card immediately. All local Ollama, $0.

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

Files touched

Diff

commit 5e218be25e8d2adb44e38677871205c9e395c254
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jul 15 10:15:31 2026 -0700

    pitch: Follow up? YES/NO chip on every card + per-column ⚡ Triage (batch, cached)
    
    Server keeps a persistent follow-up cache (data/followup-cache.json): analyzeFollowup writes each verdict; drawer opens + a new POST /api/followup-batch (concurrency 3, skips <24h-fresh) fill it. GET /api/followup-cache feeds card badges at boot. Cards show a green "Follow up" / tan "Hold" chip from cache; ⚡ Triage per column batches all un-cached clients with live progress; opening a drawer lights that card immediately. All local Ollama, $0.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 .gitignore        |  1 +
 public/index.html | 61 +++++++++++++++++++++++++++++++++++++++++++++++--------
 server.js         | 56 ++++++++++++++++++++++++++++++++++++++++++++------
 3 files changed, 104 insertions(+), 14 deletions(-)

diff --git a/.gitignore b/.gitignore
index 913ae95..894c5cb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,4 @@ data/lists.json
 data/*.jsonl
 
 5x/*.log
+data/followup-cache.json
diff --git a/public/index.html b/public/index.html
index ced0f75..bf8f085 100644
--- a/public/index.html
+++ b/public/index.html
@@ -84,6 +84,11 @@
   @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}
+  .chip.fu-yes{background:#16351c;color:#7ee093;border-color:#2f6b3d}
+  .chip.fu-no{background:#332017;color:#e0b07e;border-color:#6b4a2f}
+  .col-tri{background:transparent;border:1px solid var(--line);color:var(--mut);border-radius:6px;cursor:pointer;font-size:11px;line-height:1;padding:2px 7px;margin-left:6px}
+  .col-tri:hover{color:var(--gold);border-color:var(--gold)}
+  .col-tri:disabled{opacity:.6;cursor:default}
   .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)}
@@ -253,6 +258,14 @@ const fmtWhen=iso=>{if(!iso)return'';const d=new Date(iso);return isNaN(d)?'':d.
 // is UTC midnight and renders as Jun 30 in PT. Append midnight-local for the date-only case.
 const fmtDay=iso=>{if(!iso)return'';const d=new Date(/^\d{4}-\d{2}-\d{2}$/.test(iso)?iso+'T00:00:00':iso);return isNaN(d)?'':d.toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'})};
 const dateChip=r=>r.when_iso?`<span class="chip" title="${esc(r.when_iso)}">🕓 ${esc(r.when_label||'when')}: ${esc(fmtDay(r.when_iso))}</span>`:'';
+// Follow-up verdict chip on the card, read from the server-cached triage (blank until analyzed).
+function fuChip(r){
+  if(!r||!r.email) return '';
+  const v=(window.FU_CACHE||{})[String(r.email).toLowerCase()]; if(!v) return '';
+  if(v.followUp==='yes') return `<span class="chip fu-yes" title="${esc(v.reason||'follow up')}">🧭 Follow up</span>`;
+  if(v.followUp==='no') return `<span class="chip fu-no" title="${esc(v.reason||'hold')}">🧭 Hold</span>`;
+  return '';
+}
 const money=n=>'$'+(Number(n)||0).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2});
 // Nothing shipped yet: samples are pending but ZERO have been sent → block the letter, prompt a reorder.
 const needsReorder=r=>Array.isArray(r.samples_pending)&&r.samples_pending.length>0&&Array.isArray(r.samples_sent)&&r.samples_sent.length===0;
@@ -340,10 +353,10 @@ function cardHTML(list,r){
   } else if(list==='list2'){
     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>`;
+    facts=`<div class="chips"><span class="chip a">💬 ${esc(money(r.quote_total))} quoted · no order</span>${qCountChip}${fuChip(r)}${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')} · ${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>`;
+    facts=`<div class="chips"><span class="chip">${r.sent}/${r.ordered} sent</span>${r.missing?`<span class="chip a">${r.missing} missing</span>`:''}${lifeChip}${fuChip(r)}${dateChip(r)}</div>`;
   }
   return `<div class="card ${sel?'sel':''}" data-k="${esc(k)}" data-list="${list}">
     <div class="top"><input type="checkbox" ${sel?'checked':''} data-sel/>${head}</div>
@@ -486,10 +499,38 @@ async function loadFollowup(r,force){
   const q=(Array.isArray(r.merch_invoices)?r.merch_invoices:[]).find(i=>!i.booked);
   const quote=q&&q.invoice?('#'+q.invoice):'';
   try{
-    const v=await (await fetch(API('/api/followup-analysis?email='+encodeURIComponent(email)+'&company='+encodeURIComponent(r.company||'')+'&quote='+encodeURIComponent(quote)))).json();
+    const v=await (await fetch(API('/api/followup-analysis?email='+encodeURIComponent(email)+'&company='+encodeURIComponent(r.company||'')+'&quote='+encodeURIComponent(quote)+(force?'&force=1':'')))).json();
     setLS(ck,{at:Date.now(),v}); paintFollowup(badge,notes,reb,v);
+    // sync the board cache + light up this client's card badge immediately
+    if(v&&v.followUp){ (window.FU_CACHE=window.FU_CACHE||{})[email.toLowerCase()]={followUp:v.followUp,reason:v.reason,deadline:v.deadline||'',at:Date.now()}; refreshCardFor(email); }
   }catch(e){ badge.textContent='analysis failed'; badge.className='fu-badge'; if(reb)reb.hidden=false; }
 }
+// Re-render the on-board card(s) for a client so a new follow-up verdict shows without a full reload.
+function refreshCardFor(email){
+  const lc=String(email).toLowerCase();
+  document.querySelectorAll('.card').forEach(card=>{
+    const r=card._row; if(!r||String(r.email||'').toLowerCase()!==lc)return;
+    const tmp=document.createElement('div'); tmp.innerHTML=cardHTML(card._list,r); const fresh=tmp.firstElementChild;
+    fresh._row=r; fresh._list=card._list; if(card.classList.contains('active'))fresh.classList.add('active');
+    card.replaceWith(fresh); if(active&&active.card===card)active.card=fresh;
+  });
+}
+// Triage a whole column: analyze each not-yet-cached client's thread (batched, background), badges fill in.
+async function triageColumn(list,btn){
+  const rows=(filterSort(list)||[]).map(x=>x.r).filter(r=>r.email && !(window.FU_CACHE||{})[String(r.email).toLowerCase()]);
+  if(!rows.length){ btn.textContent='✓ all triaged'; setTimeout(()=>btn.textContent='⚡ Triage',1800); return; }
+  const total=rows.length; let done=0; btn.disabled=true;
+  for(let i=0;i<rows.length;i+=25){
+    const chunk=rows.slice(i,i+25).map(r=>{const q=(r.merch_invoices||[]).find(x=>!x.booked);return {email:r.email,company:r.company||'',quote:q&&q.invoice?('#'+q.invoice):''}});
+    btn.textContent=`triaging ${done}/${total}…`;
+    try{
+      const j=await (await fetch(API('/api/followup-batch'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({clients:chunk})})).json();
+      Object.assign((window.FU_CACHE=window.FU_CACHE||{}), j.map||{});
+    }catch(e){ /* keep going */ }
+    done+=chunk.length; rerenderContainer(list,filterSort(list));
+  }
+  btn.disabled=false; btn.textContent='✓ done'; setTimeout(()=>btn.textContent='⚡ Triage',2500);
+}
 function paintFollowup(badge,notes,reb,v){
   const d=v.followUp||'unknown';
   badge.textContent = d==='yes'?'YES — follow up':(d==='no'?'NO — hold':'—');
@@ -594,7 +635,7 @@ function buildBoard(){
       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('');
       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>`;
+      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-tri" data-tri="${list}" title="Triage this column — decide Follow up? for each client from their email thread">⚡ Triage</button><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))';
@@ -617,7 +658,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
+  // column-level buttons (not inside a card) — handle before the card early-return
+  const tri=e.target.closest('[data-tri]');
+  if(tri){ triageColumn(tri.dataset.tri,tri); 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;
@@ -691,9 +734,11 @@ document.getElementById('exportBtn').addEventListener('click',()=>{if(!totalSel(
 layout=LS('layout','board');
 // 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(()=>{
+window.SENT_IDX={}; window.FU_CACHE={};
+Promise.allSettled([
+  fetch(API('/api/sent-index')).then(r=>r.json()).then(j=>{window.SENT_IDX=j.idx||{}}),
+  fetch(API('/api/followup-cache')).then(r=>r.json()).then(j=>{window.FU_CACHE=j.map||{}}),
+]).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();
diff --git a/server.js b/server.js
index 639383f..0bd4359 100644
--- a/server.js
+++ b/server.js
@@ -499,11 +499,21 @@ app.get('/api/emails', async (req, res) => {
   }
 });
 
+// Persistent follow-up cache so the CARDS can show a Yes/No at a glance without re-running the
+// 8s local-LLM analysis every load. Filled as clients get analyzed (drawer opens + batch triage).
+const FU_CACHE_FILE = path.join(DATA_DIR, 'followup-cache.json');
+let FU_CACHE = {};
+try { FU_CACHE = JSON.parse(fs.readFileSync(FU_CACHE_FILE, 'utf8')) || {}; } catch { FU_CACHE = {}; }
+let _fuSaveTimer = null;
+function fuSave() { if (_fuSaveTimer) return; _fuSaveTimer = setTimeout(() => { _fuSaveTimer = null; try { fs.writeFileSync(FU_CACHE_FILE, JSON.stringify(FU_CACHE)); } catch { /* best effort */ } }, 1500); }
+
 // Read the client's recent thread + decide whether to follow up, with operational notes.
-// Local LLM (Ollama) → $0. Returns { followUp:'yes'|'no'|'unknown', reason, notes[], deadline }.
-async function analyzeFollowup(email, context = {}) {
+// Local LLM (Ollama) → $0. maxAgeMs>0 returns a cached verdict if fresh enough (skips the LLM).
+async function analyzeFollowup(email, context = {}, maxAgeMs = 0) {
+  const key = String(email).toLowerCase();
+  if (maxAgeMs > 0) { const c = FU_CACHE[key]; if (c && Date.now() - (c.at || 0) < maxAgeMs) return c; }
   const emails = await georgeEmails(email, 12);
-  if (!emails.length) return { followUp: 'unknown', reason: 'no email history with this client', notes: [], deadline: '', count: 0 };
+  if (!emails.length) { const r = { followUp: 'unknown', reason: 'no email history with this client', notes: [], deadline: '', count: 0, at: Date.now() }; FU_CACHE[key] = r; fuSave(); return r; }
   const today = new Date().toISOString().slice(0, 10);
   const thread = emails.map((m) => `[${m.dir === 'in' ? 'CLIENT→us' : 'us→CLIENT'} ${String(m.date).slice(0, 16)}] ${m.subject} :: ${m.snippet}`).join('\n');
   const sys = `You are a sales follow-up triage assistant for Designer Wallcoverings. Given recent email correspondence with a client, decide whether the sales team should PROACTIVELY FOLLOW UP with the client now, and extract operational notes. Today is ${today}. Return STRICT JSON: {"followUp":"yes"|"no","reason":"<=12 words","notes":["short bullet"],"deadline":"YYYY-MM-DD or empty"}.
@@ -520,24 +530,58 @@ notes: 2-4 terse, concrete bullets on delays, deadlines, promises made, or curre
     if (!r.ok) throw new Error(`ollama ${r.status}`);
     const out = JSON.parse((await r.json()).message?.content || '{}');
     const decision = /^y/i.test(String(out.followUp || '')) ? 'yes' : (/^n/i.test(String(out.followUp || '')) ? 'no' : 'unknown');
-    return {
+    const result = {
       followUp: decision,
       reason: String(out.reason || '').replace(/\s+/g, ' ').slice(0, 160),
       notes: Array.isArray(out.notes) ? out.notes.map((n) => String(n).replace(/\s+/g, ' ').slice(0, 160)).filter(Boolean).slice(0, 5) : [],
       deadline: (String(out.deadline || '').match(/\d{4}-\d{2}-\d{2}/) || [''])[0], // real date or nothing (LLM sometimes echoes "or empty")
       count: emails.length,
+      at: Date.now(),
     };
+    FU_CACHE[key] = result; fuSave();
+    return result;
   } catch (e) {
+    // don't cache transient failures — leave any prior cached verdict intact
     return { followUp: 'unknown', reason: 'analysis unavailable', notes: [], deadline: '', count: emails.length, error: e.message };
   }
 }
 
-// GET /api/followup-analysis?email=&company=&quote= — Yes/No + notes from the email thread (local $0).
+// GET /api/followup-analysis?email=&company=&quote=&force= — Yes/No + notes (local $0).
+// Returns a cached verdict if <6h old unless force=1 (the drawer's ↻ button).
 app.get('/api/followup-analysis', async (req, res) => {
   try {
     const email = String(req.query.email || '').trim();
     if (!email) return res.json({ followUp: 'unknown', reason: 'no email on file', notes: [] });
-    res.json(await analyzeFollowup(email, { company: String(req.query.company || ''), quote: String(req.query.quote || '') }));
+    const maxAge = req.query.force === '1' ? 0 : 6 * 3600e3;
+    res.json(await analyzeFollowup(email, { company: String(req.query.company || ''), quote: String(req.query.quote || '') }, maxAge));
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
+// GET /api/followup-cache — the lightweight decision map for the CARD badges (no LLM run).
+app.get('/api/followup-cache', (_req, res) => {
+  const map = {};
+  for (const [k, v] of Object.entries(FU_CACHE)) map[k] = { followUp: v.followUp, reason: v.reason, deadline: v.deadline || '', at: v.at || 0 };
+  res.json({ count: Object.keys(map).length, map });
+});
+
+// POST /api/followup-batch {clients:[{email,company,quote}]} — triage a batch (e.g. one column),
+// skipping any verdict fresh <24h. Bounded fan-out with small concurrency so Ollama isn't swamped.
+app.post('/api/followup-batch', async (req, res) => {
+  try {
+    const clients = (Array.isArray(req.body?.clients) ? req.body.clients : []).filter((c) => c && c.email).slice(0, 80);
+    const DAY = 24 * 3600e3, CONC = 3, out = {};
+    let i = 0;
+    async function worker() {
+      while (i < clients.length) {
+        const c = clients[i++];
+        try { const v = await analyzeFollowup(String(c.email).trim(), { company: c.company || '', quote: c.quote || '' }, DAY); out[String(c.email).toLowerCase()] = { followUp: v.followUp, reason: v.reason, deadline: v.deadline || '', at: v.at || Date.now() }; }
+        catch { /* skip one bad client */ }
+      }
+    }
+    await Promise.all(Array.from({ length: Math.min(CONC, clients.length) }, worker));
+    res.json({ analyzed: Object.keys(out).length, map: out });
   } catch (e) {
     res.status(500).json({ error: e.message });
   }

← 8e921dc pitch: auto follow-up triage — reads the email thread → Foll  ·  back to Dw Pitch Followup  ·  pitch: scheduled refresh now also pre-triages the whole boar e90255c →