[object Object]

← back to Atomic50 Onboard

Add monthly propose-only Atomic 50 cadence (15th @ 08:00 PT, first run 2026-07-15)

a321e2f670165f61419d1f1fb733a17a9fdcae86 · 2026-07-13 16:43:53 -0700 · steve

Files touched

Diff

commit a321e2f670165f61419d1f1fb733a17a9fdcae86
Author: steve <steve@designerwallcoverings.com>
Date:   Mon Jul 13 16:43:53 2026 -0700

    Add monthly propose-only Atomic 50 cadence (15th @ 08:00 PT, first run 2026-07-15)
---
 cadence/atomic50-cadence.mjs             | 209 +++++++++++++++++++++++++++++++
 cadence/com.steve.atomic50-cadence.plist |  17 +++
 cadence/data/latest.json                 |  15 +++
 3 files changed, 241 insertions(+)

diff --git a/cadence/atomic50-cadence.mjs b/cadence/atomic50-cadence.mjs
new file mode 100644
index 0000000..fcd778e
--- /dev/null
+++ b/cadence/atomic50-cadence.mjs
@@ -0,0 +1,209 @@
+#!/usr/bin/env node
+/**
+ * atomic50-cadence.mjs — MONTHLY Atomic 50 Ceilings line maintenance.
+ *   PROPOSE-ONLY. NEVER writes Shopify, never publishes. Modeled on york-cadence.mjs.
+ *
+ * Each run (15th @ 08:00 PT):
+ *   a. Best-effort re-scrape / refresh atomic50_catalog (try/catch — a headless failure
+ *      must NOT crash the cadence). The atomic50-scraper-manager skill ships no runnable
+ *      script entrypoint today, so this pass currently SKIPS the re-scrape and notes it;
+ *      when an entrypoint lands, point RESCRAPE_CMD at it and it will run best-effort.
+ *   b. Diff catalog vs live Shopify (vendor "Atomic 50 Ceilings", keyed by sec/dwc mfr_sku):
+ *        - onboard-ready : real catalog SKU (not LBI-BOYD) not yet on Shopify
+ *        - disco         : catalog row flagged discontinued but still live ACTIVE
+ *        - image-drift    : live product carrying an Atomic 50 mfr_sku with NO image
+ *      Since all 58 real SKUs are currently mapped, the go-forward signal is NEW or REMOVED.
+ *   c. If anything is actionable -> ONE dated pending-approval memo + ONE CNCP parking-lot
+ *      card. If nothing changed -> write nothing (silent, like the canaries).
+ *   d. NEVER auto-write Shopify. Propose only.
+ *   e. Bump vendor_registry next_scheduled_crawl +1 month, last_product_update=now().
+ *   f. Append a run-log line to cadence/data/cadence.log.
+ *
+ * Cost: $0 — local PG (host=/tmp socket) + read-only Shopify Admin GraphQL (rate-limited).
+ */
+import fs from 'node:fs';
+import { execSync } from 'node:child_process';
+import { createRequire } from 'node:module';
+const require = createRequire(import.meta.url);
+const { Client } = require('pg');
+
+const ROOT = process.env.HOME + '/Projects/atomic50-onboard';
+const DATA = ROOT + '/cadence/data';
+const SHOP = 'designer-laboratory-sandbox.myshopify.com', VER = '2024-10';
+const URL = `https://${SHOP}/admin/api/${VER}/graphql.json`;
+const VENDOR = 'Atomic 50 Ceilings';
+// Set this to a runnable scraper entrypoint (e.g. `${process.env.HOME}/.../scrape.mjs`)
+// to enable best-effort re-scrape. Leave null to skip re-scrape (current state).
+const RESCRAPE_CMD = null;
+
+// ---- token (parse .env directly; do NOT shell-source — unquoted-value bug) ----
+function readToken() {
+  const env = fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8');
+  for (const line of env.split('\n')) {
+    const m = line.match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/);
+    if (m) return m[1].trim().replace(/^["']|["']$/g, '');
+  }
+  throw new Error('SHOPIFY_ADMIN_TOKEN not found');
+}
+const TOKEN = readToken();
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const norm = (s) => String(s || '').toUpperCase().trim();
+const stamp = () => new Date().toISOString();
+
+const gql = async (query, v) => {
+  for (let a = 0; a < 8; a++) {
+    let j;
+    try {
+      const r = await fetch(URL, {
+        method: 'POST',
+        headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+        body: JSON.stringify({ query, variables: v }),
+      });
+      j = await r.json();
+    } catch (e) { await sleep(1500 * (a + 1)); continue; }
+    if (j.errors) {
+      if (JSON.stringify(j.errors).includes('THROTTLED')) { await sleep(2000 * (a + 1)); continue; }
+      throw new Error(JSON.stringify(j.errors));
+    }
+    const t = j.extensions?.cost?.throttleStatus;
+    if (t && t.currentlyAvailable < 300) await sleep(1500);
+    return j.data;
+  }
+  throw new Error('gql retries exhausted');
+};
+
+// ---- a. best-effort re-scrape ------------------------------------------------
+function rescrape() {
+  if (!RESCRAPE_CMD) return { ran: false, note: 'no scraper entrypoint wired — re-scrape skipped this pass' };
+  try {
+    execSync(RESCRAPE_CMD, { stdio: 'ignore', timeout: 20 * 60 * 1000 });
+    return { ran: true, note: 'atomic50 re-scrape completed' };
+  } catch (e) {
+    return { ran: false, note: `re-scrape failed (continuing on existing catalog): ${String(e.message || e).slice(0, 200)}` };
+  }
+}
+
+async function main() {
+  fs.mkdirSync(DATA, { recursive: true });
+  const ts = stamp();
+  const rescr = rescrape();
+
+  const c = new Client({ host: '/tmp', database: 'dw_unified' });
+  await c.connect();
+
+  // ---- catalog snapshot (exclude the LBI-BOYD resource row) ----
+  const { rows: cat } = await c.query(`
+    SELECT mfr_sku, dw_sku, product_type, shopify_product_id, on_shopify,
+           discontinued, image_url
+    FROM atomic50_catalog
+    WHERE mfr_sku <> 'LBI-BOYD'`);
+  const catByMfr = new Map();
+  for (const r of cat) catByMfr.set(norm(r.mfr_sku), r);
+
+  // ---- live Shopify: vendor "Atomic 50 Ceilings", keyed by sec/dwc mfr_sku ----
+  const liveMfr = new Set();
+  const liveNoImage = [];
+  const liveActiveByMfr = new Map();
+  let cur = null;
+  for (let pg = 0; pg < 60; pg++) {
+    const d = (await gql(
+      `query($c:String){products(first:60,after:$c,query:"vendor:'Atomic 50 Ceilings'"){pageInfo{hasNextPage endCursor} nodes{id status title featuredImage{url} sec:metafield(namespace:"sec",key:"mfr_sku"){value} dwc:metafield(namespace:"dwc",key:"manufacturer_sku"){value} cust:metafield(namespace:"custom",key:"manufacturer_sku"){value}}}}`,
+      { c: cur }
+    ))?.products;
+    if (!d) break;
+    for (const p of d.nodes) {
+      const mfr = norm(p.sec?.value || p.dwc?.value || p.cust?.value);
+      if (!mfr) continue;
+      liveMfr.add(mfr);
+      if (p.status === 'ACTIVE') liveActiveByMfr.set(mfr, p);
+      if (!p.featuredImage?.url) liveNoImage.push({ mfr, title: p.title, status: p.status });
+    }
+    if (!d.pageInfo.hasNextPage) break;
+    cur = d.pageInfo.endCursor;
+  }
+
+  // ---- b. diff ----
+  // onboard-ready: real catalog SKU not present on live Shopify (NEW on the site since onboard)
+  const onboard = cat
+    .filter((r) => !liveMfr.has(norm(r.mfr_sku)))
+    .map((r) => ({ mfr: r.mfr_sku, dw_sku: r.dw_sku, type: r.product_type }));
+
+  // disco: catalog row flagged discontinued but still live ACTIVE (REMOVED from the site)
+  const disco = [];
+  for (const [mfr, p] of liveActiveByMfr) {
+    const row = catByMfr.get(mfr);
+    if (row && row.discontinued) disco.push({ mfr, title: p.title });
+  }
+
+  // image-drift: live Atomic 50 product with no image (kept as draft per DW rule, worth flagging)
+  const imageDrift = liveNoImage;
+
+  const actionable = onboard.length + disco.length + imageDrift.length;
+
+  const out = {
+    ts,
+    rescrape: rescr,
+    catalog_real: cat.length,
+    live_shopify: liveMfr.size,
+    onboard_ready: onboard.length,
+    disco: disco.length,
+    image_drift: imageDrift.length,
+    onboard_sample: onboard.slice(0, 12),
+    disco_sample: disco.slice(0, 10),
+    image_drift_sample: imageDrift.slice(0, 10),
+  };
+  fs.writeFileSync(DATA + '/latest.json', JSON.stringify(out, null, 2));
+
+  // ---- c. propose only on change (silent otherwise) ----
+  if (actionable > 0) {
+    const day = ts.slice(0, 10);
+    const memo =
+`# Atomic 50 cadence — ${actionable} actionable (${day})
+
+Monthly propose-only diff of atomic50_catalog vs live Shopify (vendor "${VENDOR}").
+All customer-facing writes stay GATED — this is a surface, not an auto-apply.
+
+- **Onboard-ready**: ${onboard.length} real catalog SKU(s) not yet on Shopify → run push-shopify.js (creates DRAFT, quote-only/sample-only). Sample: ${onboard.slice(0, 8).map((o) => o.mfr).join(', ') || '—'}
+- **Disco**: ${disco.length} live ACTIVE now flagged discontinued in catalog → ARCHIVE (never delete). Sample: ${disco.slice(0, 8).map((d) => d.mfr).join(', ') || '—'}
+- **Image-drift**: ${imageDrift.length} live Atomic 50 product(s) with NO image → keep DRAFT + tag Needs-Image until backfilled. Sample: ${imageDrift.slice(0, 8).map((i) => i.mfr).join(', ') || '—'}
+
+Re-scrape this pass: ${rescr.ran ? 'ran' : 'skipped'} — ${rescr.note}
+Data: cadence/data/latest.json
+
+## Decision
+- [ ] **APPROVE** — which action to run (onboard DRAFT / archive disco / backfill images)
+- [ ] **REVISE** — adjust scope
+- [ ] **BLOCK** — do nothing this cycle
+`;
+    fs.writeFileSync(process.env.HOME + '/.claude/yolo-queue/pending-approval/atomic50-cadence-actions.md', memo);
+    try {
+      execSync(
+        `curl -s -X POST http://127.0.0.1:3333/api/parking-lot -H 'Content-Type: application/json' -d ${JSON.stringify(
+          JSON.stringify({
+            project: 'atomic50-onboard',
+            title: `Atomic 50 cadence: ${actionable} actionable`,
+            note: `onboard ${onboard.length} / disco ${disco.length} / image-drift ${imageDrift.length}`,
+          })
+        )}`,
+        { stdio: 'ignore' }
+      );
+    } catch (e) { /* CNCP down — memo still written */ }
+  }
+
+  // ---- e. bump the schedule (+1 month) and stamp last_product_update ----
+  await c.query(`
+    UPDATE vendor_registry
+    SET next_scheduled_crawl = next_scheduled_crawl + interval '1 month',
+        last_product_update  = now()
+    WHERE vendor_name = $1`, [VENDOR]);
+
+  await c.end();
+
+  // ---- f. run-log line ----
+  const logLine = `${ts} | catalog ${cat.length} | live ${liveMfr.size} | onboard-ready ${onboard.length} | disco ${disco.length} | image-drift ${imageDrift.length} | actionable ${actionable} | rescrape=${rescr.ran ? 'ran' : 'skipped'}\n`;
+  fs.appendFileSync(DATA + '/cadence.log', logLine);
+  console.log('atomic50-cadence ' + logLine.trim());
+}
+
+main().catch((e) => { console.error('atomic50-cadence FAILED:', e); process.exit(1); });
diff --git a/cadence/com.steve.atomic50-cadence.plist b/cadence/com.steve.atomic50-cadence.plist
new file mode 100644
index 0000000..f58c2b7
--- /dev/null
+++ b/cadence/com.steve.atomic50-cadence.plist
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0"><dict>
+  <key>Label</key><string>com.steve.atomic50-cadence</string>
+  <key>ProgramArguments</key><array>
+    <string>/opt/homebrew/bin/node</string>
+    <string>/Users/macstudio3/Projects/atomic50-onboard/cadence/atomic50-cadence.mjs</string>
+  </array>
+  <key>StartCalendarInterval</key><dict>
+    <key>Day</key><integer>15</integer>
+    <key>Hour</key><integer>8</integer>
+    <key>Minute</key><integer>0</integer>
+  </dict>
+  <key>StandardOutPath</key><string>/Users/macstudio3/Projects/atomic50-onboard/cadence/data/cadence.log</string>
+  <key>StandardErrorPath</key><string>/Users/macstudio3/Projects/atomic50-onboard/cadence/data/cadence.err</string>
+  <key>RunAtLoad</key><false/>
+</dict></plist>
diff --git a/cadence/data/latest.json b/cadence/data/latest.json
new file mode 100644
index 0000000..9129307
--- /dev/null
+++ b/cadence/data/latest.json
@@ -0,0 +1,15 @@
+{
+  "ts": "2026-07-13T23:43:33.707Z",
+  "rescrape": {
+    "ran": false,
+    "note": "no scraper entrypoint wired — re-scrape skipped this pass"
+  },
+  "catalog_real": 58,
+  "live_shopify": 58,
+  "onboard_ready": 0,
+  "disco": 0,
+  "image_drift": 0,
+  "onboard_sample": [],
+  "disco_sample": [],
+  "image_drift_sample": []
+}
\ No newline at end of file

← 7949d34 Add finish-swatch hover gallery (grid + PDP)  ·  back to Atomic50 Onboard  ·  atomic50: feed-first scraper CLI + wire cadence re-scrape 138c903 →