← back to Dw Pitch Followup
Show invoice # + current internal note on every pitch card — batched FM read (50/query, 10-min cache), chip updates live on save
177117e212b797039425c5baf53b626ddcaf223e · 2026-07-15 10:44:52 -0700 · Steve
Files touched
M lib/fm-notes.jsM public/index.htmlM server.js
Diff
commit 177117e212b797039425c5baf53b626ddcaf223e
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Jul 15 10:44:52 2026 -0700
Show invoice # + current internal note on every pitch card — batched FM read (50/query, 10-min cache), chip updates live on save
---
lib/fm-notes.js | 30 +++++++++++++++++++++++++++++
public/index.html | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++--
server.js | 30 +++++++++++++++++++++++++++++
3 files changed, 115 insertions(+), 2 deletions(-)
diff --git a/lib/fm-notes.js b/lib/fm-notes.js
index 4248fc3..2402700 100644
--- a/lib/fm-notes.js
+++ b/lib/fm-notes.js
@@ -58,6 +58,36 @@ export async function getNotes(account) {
};
}
+// Batch read for the card chips: one OR-query per ~50 accounts against the
+// Clients Projects layout, which exposes BOTH the invoice # (Invoice portal
+// row 1) and the note itself (related Invoice::Internal notes rep 1) — so the
+// whole board paints without hammering FileMaker per-card.
+export async function getNotesBatch(accounts) {
+ const out = {};
+ const uniq = [...new Set(accounts.map((a) => String(a).trim()).filter((a) => /^\d+$/.test(a)))];
+ for (let i = 0; i < uniq.length; i += 50) {
+ const slice = uniq.slice(i, i + 50);
+ let recs = [];
+ try {
+ const r = await client.findRecords('Clients', CLIENT_LAYOUT,
+ slice.map((a) => ({ 'Account Number': `==${a}` })), { limit: slice.length * 2 });
+ recs = r.records || [];
+ } catch (e) {
+ if (String(e.fmCode) !== '401') throw e; // 401 = no matches → skip chunk
+ }
+ for (const rec of recs) {
+ const acct = String(rec.fieldData['Account Number'] || '').trim();
+ if (!acct || out[acct]) continue;
+ const inv = (rec.portalData?.Invoice || [])[0] || null;
+ out[acct] = {
+ invoice: inv ? (String(inv['Invoice::Invoice'] || '').trim() || null) : null,
+ notes: fromFm(rec.fieldData['Invoice::Internal notes']),
+ };
+ }
+ }
+ return out;
+}
+
// Prepend "MM/DD/YYYY - <note>" to Internal notes rep 1 (newest on top).
export async function addNote(account, note) {
const text = String(note || '').trim();
diff --git a/public/index.html b/public/index.html
index 23687b0..4c1ee92 100644
--- a/public/index.html
+++ b/public/index.html
@@ -34,6 +34,8 @@
.btn.note{background:transparent;color:var(--mut);border-color:var(--line);font-size:12px;padding:5px 9px;margin-left:auto}
.btn.note:hover{color:var(--gold);border-color:var(--gold)}
.card .acts .btn.note~.btn.done{margin-left:0}
+ .noteline{color:var(--mut);font-size:calc(11px*var(--scale));margin:3px 0;line-height:1.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .noteline i{opacity:.6}
.notebox{margin-top:6px;border:1px solid var(--line);border-radius:7px;padding:8px;background:#221e19}
.notebox .nb-cur{white-space:pre-wrap;color:var(--mut);font-size:calc(11px*var(--scale));max-height:130px;overflow:auto;margin-bottom:6px;line-height:1.45}
.notebox .nb-cur b{color:var(--ink)}
@@ -372,6 +374,7 @@ function cardHTML(list,r){
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>
${facts}${projLine}${quoteItems}${postFlag(r)}${discoBanner(r)}
+ <div class="noteline" data-noteline hidden></div>
<div class="acts">${needsReorder(r)
? `<button class="btn reorder" data-view>Reorder Samples??</button>`
: `<button class="btn gold" data-gen>Generate letter</button><button class="btn" data-view>Details ▸</button>`}<button class="btn note" data-note title="Internal notes — reads/writes the FileMaker Internal notes field, new entries dated today">🗒 Notes</button><button class="btn done" data-done title="Mark completed — removes it from this pitch system">✓ Done</button></div>
@@ -382,8 +385,10 @@ function renderChunk(cid,list){
const body=document.getElementById('body-'+cid); const slice=VIEW[cid].slice(RENDERED[cid],RENDERED[cid]+CHUNK);
if(!slice.length)return;
const frag=document.createElement('div'); frag.innerHTML=slice.map(x=>cardHTML(x.list,x.r)).join('');
- [...frag.children].forEach((el,i)=>{el._row=slice[i].r;el._list=slice[i].list;body.appendChild(el)});
+ const added=[...frag.children];
+ added.forEach((el,i)=>{el._row=slice[i].r;el._list=slice[i].list;body.appendChild(el)});
RENDERED[cid]+=slice.length;
+ loadNoteChips(added); // paint invoice# + internal-notes line (batched, cached)
}
function rerenderContainer(cid,rows){
VIEW[cid]=rows; RENDERED[cid]=0;
@@ -599,6 +604,23 @@ async function composeLetter(opts={}){
finally{if(rbtn){rbtn.disabled=false;rbtn.textContent='↻ Regenerate'}}
}
+// After a send, repaint every LIVE card for this recipient — found by data-k in the DOM,
+// not via active.card, which is a DETACHED node whenever the board re-rendered (sort/filter/
+// scroll) while the panel was open; replacing a detached node made the ✓ emailed chip look
+// like it never appeared. Also covers the same client sitting on cards in other lists.
+function repaintSentCards(to){
+ const lc=String(to||'').trim().toLowerCase(); if(!lc)return;
+ document.querySelectorAll('.card').forEach(c=>{
+ const row=c._row; if(!row||String(row.email||'').trim().toLowerCase()!==lc)return;
+ const tmp=document.createElement('div'); tmp.innerHTML=cardHTML(c._list,row);
+ const fresh=tmp.firstElementChild; fresh._row=row; fresh._list=c._list;
+ if(c.classList.contains('active'))fresh.classList.add('active');
+ c.replaceWith(fresh);
+ loadNoteChips([fresh]); // keep the invoice/notes line painted (cached — no refetch)
+ if(active&&active.card===c)active.card=fresh;
+ });
+}
+
async function sendActive(btn){
if(!active)return; const {row:r,list}=active; const k=rowKey(list,r);
const to=(r.email||'').trim();
@@ -622,7 +644,8 @@ async function sendActive(btn){
const when=j.sentAt||new Date().toISOString();
btn.textContent='✓ Sent'; stat.style.color='var(--green)'; stat.textContent=`✓ emailed ${fmtWhen(when)}${j.inThread?' · in-thread':''}`;
setLS('sent:'+list+':'+k,{at:Date.parse(when)||Date.now(),messageId:j.messageId||''});
- if(active.card){ const nc=cardHTML(list,r); const tmp=document.createElement('div'); tmp.innerHTML=nc; const fresh=tmp.firstElementChild; fresh._row=r; fresh._list=list; fresh.classList.add('active'); active.card.replaceWith(fresh); active.card=fresh; }
+ SENT_IDX[to.toLowerCase()]=when; // so cards for this client in OTHER lists show the chip now too
+ repaintSentCards(to);
}catch(e){btn.disabled=false;btn.textContent=old;stat.style.color='var(--red)';stat.textContent='send failed: '+e.message}
}
@@ -683,6 +706,34 @@ document.getElementById('board').addEventListener('click',e=>{
if(e.target.matches('[data-nb-save]')) return saveNote(card);
});
// ---------- INTERNAL NOTES (FileMaker Invoice::Internal notes, dated entries) ----------
+// Per-card chip: invoice # + the current note, painted from a batched (and
+// server-cached) FM read as each render chunk lands. NOTES_CHIP mirrors the
+// server cache so scrolling/rerenders don't refetch.
+const NOTES_CHIP={}; // account -> {invoice, notes}
+function paintNoteline(card){
+ const el=card.querySelector('[data-noteline]'); const r=card._row;
+ if(!el||!r||!r.account) return;
+ const d=NOTES_CHIP[String(r.account)]; if(!d){el.setAttribute('hidden','');return}
+ const inv=d.invoice?`🧾 inv #${esc(d.invoice)}`:'🧾 inv —';
+ const first=(d.notes||'').split('\n').find(l=>l.trim())||'';
+ const noteTxt=first?esc(first.length>110?first.slice(0,110)+'…':first):'<i>no internal notes</i>';
+ el.innerHTML=`${inv} · 🗒 ${noteTxt}`;
+ el.title=d.notes||'No internal notes yet — click 🗒 Notes to add one';
+ el.removeAttribute('hidden');
+}
+async function loadNoteChips(cards){
+ const els=cards.filter(c=>c._row&&c._row.account);
+ els.forEach(c=>{ if(NOTES_CHIP[String(c._row.account)]) paintNoteline(c); });
+ const need=[...new Set(els.map(c=>String(c._row.account)).filter(a=>!NOTES_CHIP[a]))];
+ if(!need.length) return;
+ try{
+ const res=await fetch(API('/api/notes-batch'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({accounts:need})});
+ if(!res.ok) return; // chip is decorative — fail silent, the 🗒 button still works
+ const j=await res.json();
+ for(const a of need) NOTES_CHIP[a]=j[a]||{invoice:null,notes:''};
+ els.forEach(paintNoteline);
+ }catch{}
+}
async function toggleNotes(card){
const open=card.querySelector('.notebox'); if(open){open.remove();return}
const r=card._row; const acct=r&&r.account; if(!acct)return;
@@ -695,6 +746,7 @@ async function toggleNotes(card){
const res=await fetch(API('/api/notes?account='+encodeURIComponent(acct)));
const j=await res.json(); if(!res.ok)throw new Error(j.error||('HTTP '+res.status));
paintNotes(box,j);
+ NOTES_CHIP[String(acct)]={invoice:j.invoice,notes:j.notes}; paintNoteline(card);
}catch(err){ const cur=box.querySelector('.nb-cur'); if(cur)cur.innerHTML=`<span style="color:var(--red)">Could not load notes: ${esc(err.message)}</span>`; }
}
function paintNotes(box,j){
@@ -713,6 +765,7 @@ async function saveNote(card){
const res=await fetch(API('/api/notes'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account:String(acct),note})});
const j=await res.json(); if(!res.ok)throw new Error(j.error||('HTTP '+res.status));
paintNotes(box,j); ta.value='';
+ NOTES_CHIP[String(acct)]={invoice:j.invoice,notes:j.notes}; paintNoteline(card);
stat.textContent='✓ saved — '+j.added; stat.className='nb-stat ok';
}catch(err){ stat.textContent='✗ '+err.message; stat.className='nb-stat err'; }
btn.disabled=false;
diff --git a/server.js b/server.js
index fb6ec55..d044aab 100644
--- a/server.js
+++ b/server.js
@@ -824,11 +824,41 @@ app.post('/api/notes', async (req, res) => {
if (!note) return res.status(400).json({ error: 'note required' });
if (note.length > 2000) return res.status(400).json({ error: 'note too long (2000 max)' });
const out = await (await fmNotes()).addNote(account, note);
+ notesCache.set(account, { ts: Date.now(), data: { invoice: out.invoice, notes: out.notes } }); // keep the chip fresh
// Audit trail — every FM write from this tool is traceable (who/when/what).
try { fs.appendFileSync(path.join(DATA_DIR, 'notes-log.jsonl'), JSON.stringify({ ts: new Date().toISOString(), account, invoice: out.invoice, ip: clientIp(req), added: out.added }) + '\n'); } catch {}
res.json({ account: out.account, invoice: out.invoice, notes: out.notes, added: out.added });
} catch (e) { res.status(500).json({ error: e.message }); }
});
+// POST /api/notes-batch {accounts:[...]} → { "<acct>": {invoice, notes} } — feeds the
+// per-card invoice#+note line. Cached 10 min per account so scrolling doesn't re-hit FM.
+const notesCache = new Map(); // account -> { ts, data }
+const NOTES_TTL = 10 * 60 * 1000;
+app.post('/api/notes-batch', async (req, res) => {
+ try {
+ const accounts = [...new Set((Array.isArray(req.body?.accounts) ? req.body.accounts : [])
+ .map((a) => String(a).trim()).filter((a) => /^\d+$/.test(a)))].slice(0, 200);
+ if (!accounts.length) return res.json({});
+ const out = {}; const misses = [];
+ const now = Date.now();
+ for (const a of accounts) {
+ const hit = notesCache.get(a);
+ if (hit && now - hit.ts < NOTES_TTL) out[a] = hit.data; else misses.push(a);
+ }
+ if (misses.length) {
+ const fresh = await (await fmNotes()).getNotesBatch(misses);
+ for (const a of misses) {
+ const data = fresh[a] || { invoice: null, notes: '' };
+ notesCache.set(a, { ts: now, data });
+ out[a] = data;
+ }
+ if (notesCache.size > 5000) { // prune oldest so the cache can't grow unbounded
+ for (const [k, v] of notesCache) { if (now - v.ts > NOTES_TTL) notesCache.delete(k); }
+ }
+ }
+ res.json(out);
+ } catch (e) { res.status(500).json({ error: e.message }); }
+});
app.listen(PORT, '127.0.0.1', () => {
console.log(`[dw-pitch-followup] listening on http://127.0.0.1:${PORT} (auth ${BASIC_AUTH.split(':')[0]} / …) letters via ${LETTER_MODEL}`);
← 58c847e Add Internal-notes editor to pitch cards — 🗒 Notes button n
·
back to Dw Pitch Followup
·
fix: ✓ emailed date chip appears immediately after send — re a923a04 →