[object Object]

← back to Filemaker Mcp

chore: lint, refactor (shared fm-script-helpers), v0.2.0 (session close)

a4db9cad346fd88e987f13ecdea6d01f93f502b6 · 2026-07-02 08:08:54 -0700 · Steve

Files touched

Diff

commit a4db9cad346fd88e987f13ecdea6d01f93f502b6
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 2 08:08:54 2026 -0700

    chore: lint, refactor (shared fm-script-helpers), v0.2.0 (session close)
---
 lib/fm-script-helpers.js        | 63 +++++++++++++++++++++++++++++++++++++++++
 package-lock.json               |  4 +--
 package.json                    |  2 +-
 scripts/new-invoice-monitor.mjs | 26 +++++------------
 scripts/sales-summary.mjs       | 29 +++++--------------
 5 files changed, 80 insertions(+), 44 deletions(-)

diff --git a/lib/fm-script-helpers.js b/lib/fm-script-helpers.js
new file mode 100644
index 0000000..32cf8e7
--- /dev/null
+++ b/lib/fm-script-helpers.js
@@ -0,0 +1,63 @@
+// Shared helpers for scripts/sales-summary.mjs and scripts/new-invoice-monitor.mjs.
+// No external dependencies — uses only Node built-ins already available in the scripts.
+
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+/**
+ * Load a `.env` file adjacent to the package root into `process.env`.
+ * Silently skips keys already present (process env wins over file).
+ * Safe to call if the file is absent (launchd / shell already exports the vars).
+ *
+ * @param {string} importMetaUrl - pass `import.meta.url` from the caller
+ *   so the root is resolved correctly regardless of cwd.
+ */
+export function loadEnv(importMetaUrl) {
+  const root = join(dirname(fileURLToPath(importMetaUrl)), '..');
+  try {
+    for (const line of readFileSync(join(root, '.env'), 'utf8').split('\n')) {
+      const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
+      if (m && !(m[1] in process.env)) {
+        process.env[m[1]] = m[2].replace(/^["']|["']$/g, '').trim();
+      }
+    }
+  } catch { /* env may already be exported (e.g. launchd) */ }
+}
+
+/** @type {'America/Los_Angeles'} */
+export const TZ = 'America/Los_Angeles';
+
+/**
+ * Return the current Pacific-time {y, m, d} from a Date.
+ * Uses `en-CA` locale so formatToParts gives unambiguous year/month/day.
+ *
+ * @param {Date} d
+ * @returns {{ y: number, m: number, d: number }}
+ */
+export function ptYMD(d) {
+  const parts = new Intl.DateTimeFormat('en-CA', {
+    timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit',
+  }).formatToParts(d);
+  const get = (type) => parts.find((x) => x.type === type).value;
+  return { y: +get('year'), m: +get('month'), d: +get('day') };
+}
+
+/**
+ * Parse a value to a finite number; returns 0 for anything non-numeric.
+ * @param {unknown} v
+ * @returns {number}
+ */
+export function num(v) {
+  const n = parseFloat(v);
+  return Number.isFinite(n) ? n : 0;
+}
+
+/**
+ * Format a number as USD, e.g. `$1,234.56`.
+ * @param {number} n
+ * @returns {string}
+ */
+export function money(n) {
+  return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
+}
diff --git a/package-lock.json b/package-lock.json
index bb34578..3d10aad 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "filemaker-mcp",
-  "version": "0.1.1",
+  "version": "0.2.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "filemaker-mcp",
-      "version": "0.1.1",
+      "version": "0.2.0",
       "dependencies": {
         "@modelcontextprotocol/sdk": "^1.0.4",
         "amazon-cognito-identity-js": "^6.3.12",
diff --git a/package.json b/package.json
index b2ca7b9..b543d43 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "filemaker-mcp",
-  "version": "0.1.1",
+  "version": "0.2.0",
   "private": true,
   "type": "module",
   "description": "MCP server + CLI for reading and (confirmed) updating Designer Wallcoverings' FileMaker Cloud files (Clients, Invoice, WALLPAPER) via the FileMaker Data API.",
diff --git a/scripts/new-invoice-monitor.mjs b/scripts/new-invoice-monitor.mjs
index 330c8f5..abee875 100644
--- a/scripts/new-invoice-monitor.mjs
+++ b/scripts/new-invoice-monitor.mjs
@@ -9,16 +9,12 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
 import { fileURLToPath } from 'node:url';
 import { dirname, join } from 'node:path';
 import { findRecords } from '../src/fm-client.js';
+import { loadEnv, ptYMD, num, money } from '../lib/fm-script-helpers.js';
+
+loadEnv(import.meta.url);
 
 const __dir = dirname(fileURLToPath(import.meta.url));
-try {
-  for (const line of readFileSync(join(__dir, '..', '.env'), 'utf8').split('\n')) {
-    const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
-    if (m && !(m[1] in process.env)) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '').trim();
-  }
-} catch { /* env may already be exported */ }
 
-const TZ = 'America/Los_Angeles';
 const DB = 'invoice';
 const LAYOUT = 'GRAND TOTAL SALES';
 const CHANNEL = process.env.SALES_SUMMARY_CHANNEL || 'C06D9C2PG1K'; // #new-order
@@ -26,17 +22,9 @@ const DRY_RUN = process.argv.includes('--dry-run');
 const CURSOR = join(__dir, '..', 'data', '.last-invoice');
 const EXCLUDE = new Set(['999999']);
 
-const num = (v) => { const n = parseFloat(v); return Number.isFinite(n) ? n : 0; };
-const money = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
-
-function ptYMD(d) {
-  const p = new Intl.DateTimeFormat('en-CA', { timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(d);
-  const g = (t) => p.find((x) => x.type === t).value;
-  return { y: +g('year'), m: +g('month'), d: +g('day') };
-}
-
 async function post(text) {
   const token = process.env.SLACK_BOT_TOKEN;
+  if (!token) throw new Error('SLACK_BOT_TOKEN not set.');
   const res = await fetch('https://slack.com/api/chat.postMessage', {
     method: 'POST',
     headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json; charset=utf-8' },
@@ -77,9 +65,9 @@ async function main() {
 
   let maxPosted = lastSeen;
   for (const { n, f } of fresh) {
-    const client = String(f['Sold Company Name Lookup'] || 'Customer').replace(/\s+$/, '');
+    const client = String(f['Sold Company Name Lookup'] || 'Customer').trimEnd();
     const total = num(f['GRAND TOTAL 2']);
-    const item = String(f['DETAIL 1(1)'] || f['DETAIL 1'] || '').replace(/\s+$/, '').slice(0, 80);
+    const item = String(f['DETAIL 1(1)'] || f['DETAIL 1'] || '').trimEnd().slice(0, 80);
     const rep = f['SALES PERSON'] ? ` · ${f['SALES PERSON']}` : '';
     // Booked sale = payment received; otherwise it's an open quote (don't call it a sale).
     const paid = String(f['PAID ON ACCOUNT'] ?? '').trim();
@@ -87,7 +75,7 @@ async function main() {
     const tag = !booked ? '📝 *New quote* (no payment yet)' : (total < 0 ? '↩️ *Refund*' : '🛎️ *New sale*');
     const text = `${tag} — ${client} · Invoice #${n} · *${money(total)}*${rep}${item ? `\n• ${item}` : ''}`;
     if (DRY_RUN) console.log('[would post] ' + text.replace(/\n/g, '  '));
-    else { await post(text); }
+    else await post(text);
     if (n > maxPosted) maxPosted = n;
   }
   if (!DRY_RUN) writeFileSync(CURSOR, String(maxPosted));
diff --git a/scripts/sales-summary.mjs b/scripts/sales-summary.mjs
index eb7e1be..fa92a33 100644
--- a/scripts/sales-summary.mjs
+++ b/scripts/sales-summary.mjs
@@ -5,21 +5,11 @@
 //   node scripts/sales-summary.mjs            # compute + POST to Slack
 //   node scripts/sales-summary.mjs --dry-run  # compute + print only (no post)
 
-import { readFileSync } from 'node:fs';
-import { fileURLToPath } from 'node:url';
-import { dirname, join } from 'node:path';
 import { findRecords } from '../src/fm-client.js';
+import { loadEnv, TZ, ptYMD, num, money } from '../lib/fm-script-helpers.js';
 
-// --- load .env into process.env (dotenv isn't installed here) ---
-const __dir = dirname(fileURLToPath(import.meta.url));
-try {
-  for (const line of readFileSync(join(__dir, '..', '.env'), 'utf8').split('\n')) {
-    const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
-    if (m && !(m[1] in process.env)) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '').trim();
-  }
-} catch { /* env may already be exported (e.g. launchd) */ }
+loadEnv(import.meta.url);
 
-const TZ = 'America/Los_Angeles';
 const DB = 'invoice';
 const LAYOUT = 'GRAND TOTAL SALES';
 const TOTAL_FIELD = 'GRAND TOTAL 2';       // per-invoice grand total (merch + shipping + tax)
@@ -29,13 +19,6 @@ const DRY_RUN = process.argv.includes('--dry-run');
 const EXCLUDE_INVOICES = new Set(['999999']); // test/demo records
 
 const pad = (n) => String(n).padStart(2, '0');
-const num = (v) => { const n = parseFloat(v); return Number.isFinite(n) ? n : 0; };
-
-function ptYMD(d) {
-  const p = new Intl.DateTimeFormat('en-CA', { timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(d);
-  const g = (t) => p.find((x) => x.type === t).value;
-  return { y: +g('year'), m: +g('month'), d: +g('day') };
-}
 
 async function main() {
   const now = new Date();
@@ -53,7 +36,10 @@ async function main() {
 
   // A BOOKED order (= a real sale) has money received (PAID ON ACCOUNT ≠ 0).
   // No payment yet = an open QUOTE, which must NOT count as sales.
-  const isBooked = (f) => { const p = String(f['PAID ON ACCOUNT'] ?? '').trim(); return p !== '' && num(p) !== 0; };
+  const isBooked = (f) => {
+    const p = String(f['PAID ON ACCOUNT'] ?? '').trim();
+    return p !== '' && num(p) !== 0;
+  };
 
   let dayCount = 0, dayTotal = 0, mtdCount = 0, mtdTotal = 0, qCount = 0, qTotal = 0;
   for (const r of records) {
@@ -64,12 +50,11 @@ async function main() {
     if (isBooked(f)) {
       mtdCount += 1; mtdTotal += amt;
       if (isToday) { dayCount += 1; dayTotal += amt; }
-    } else if (isToday) {                 // today's quotes with no money received yet
+    } else if (isToday) {           // today's quotes with no money received yet
       qCount += 1; qTotal += amt;
     }
   }
 
-  const money = (n) => `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
   const dayLabel = new Intl.DateTimeFormat('en-US', { timeZone: TZ, weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }).format(now);
   const monthLabel = new Intl.DateTimeFormat('en-US', { timeZone: TZ, month: 'long', year: 'numeric' }).format(now);
 

← fec4102 add DWTA baxter dedup: archive+delete script for FM dup reco  ·  back to Filemaker Mcp  ·  daily sales summary: move fire time 23:55 -> 16:30 (4:30 PM 70f934b →