← 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
M .gitignoreM public/index.htmlM server.js
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||'')+'"e='+encodeURIComponent(quote)))).json();
+ const v=await (await fetch(API('/api/followup-analysis?email='+encodeURIComponent(email)+'&company='+encodeURIComponent(r.company||'')+'"e='+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="e= — Yes/No + notes from the email thread (local $0).
+// GET /api/followup-analysis?email=&company="e=&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 →