[object Object]

← 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

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 →