[object Object]

← back to Council 0809 Builds

Council #5 (TK-10385): Path A+B build — width-only deeplink + PDP snippet, pattern_repeat_number backfill (dry-run), coverage measured (width 91%, repeat supply 80k)

37767de1226de8b4ee09fed7c65d3b50033564ed · 2026-08-09 10:14:19 -0700 · claude

Files touched

Diff

commit 37767de1226de8b4ee09fed7c65d3b50033564ed
Author: claude <steve@designerwallcoverings.com>
Date:   Sun Aug 9 10:14:19 2026 -0700

    Council #5 (TK-10385): Path A+B build — width-only deeplink + PDP snippet, pattern_repeat_number backfill (dry-run), coverage measured (width 91%, repeat supply 80k)
---
 rolls-ar-deeplink/backfill-pattern-repeat.js     | 145 +++++++++++++++++++++++
 rolls-ar-deeplink/coverage-result.json           |  18 +++
 rolls-ar-deeplink/deeplink.js                    |  66 +++++++++--
 rolls-ar-deeplink/pathA-dryrun-full.json         |   0
 rolls-ar-deeplink/scan-metafield-coverage.js     |  45 +++++++
 rolls-ar-deeplink/snippets/how-many-rolls.liquid |  48 ++++++++
 6 files changed, 310 insertions(+), 12 deletions(-)

diff --git a/rolls-ar-deeplink/backfill-pattern-repeat.js b/rolls-ar-deeplink/backfill-pattern-repeat.js
new file mode 100644
index 0000000..9fb1d06
--- /dev/null
+++ b/rolls-ar-deeplink/backfill-pattern-repeat.js
@@ -0,0 +1,145 @@
+#!/usr/bin/env node
+/**
+ * PATH A backfill — custom.pattern_repeat_number  (Council idea #5, 2026-08-09)
+ * Steve approved Path A + Path B: "A and b approved / Dust2026".
+ *
+ * Source : dw_unified vendor *_catalog staging tables (Mac2-canonical, $0),
+ *          which carry free-text repeat_v / repeat_h / pattern_repeat.
+ * Target : a NEW numeric Shopify metafield custom.pattern_repeat_number (inches)
+ *          on active products that lack a numeric repeat today. Additive +
+ *          reversible (new field; never overwrites existing catalog data).
+ *
+ * Join   : vendor mfr SKU (catalog) -> Shopify custom.manufacturer_sku metafield
+ *          (per the dw-mirror-mfrsku-blank rule: read the live metafield, not
+ *          the blank mirror column).
+ *
+ * SAFETY : dry-run by default. --apply performs the metafield writes in batches
+ *          via metafieldsSet, skipping any product that already has a numeric
+ *          repeat, and writes a reversibility ledger (runs/<ts>.json) of every
+ *          {productId, before, after} so the write can be undone.
+ *
+ * Usage:
+ *   node backfill-pattern-repeat.js                 # dry-run, whole catalog
+ *   node backfill-pattern-repeat.js --vendor thibaut_catalog   # scope one vendor
+ *   node backfill-pattern-repeat.js --apply         # GATED live write
+ */
+const fs = require('fs');
+const { execFileSync } = require('child_process');
+const env = fs.readFileSync(require('os').homedir() + '/Projects/secrets-manager/.env', 'utf8');
+const g = (k) => (env.match(new RegExp('^' + k + '=(.*)$', 'm')) || [])[1];
+const TOKEN = g('SHOPIFY_ADMIN_TOKEN'), STORE = g('SHOPIFY_STORE');
+const API = `https://${STORE}/admin/api/2024-10/graphql.json`;
+const APPLY = process.argv.includes('--apply');
+const VENDOR = (process.argv.find((a) => a.startsWith('--vendor=')) || '').split('=')[1]
+            || (process.argv[process.argv.indexOf('--vendor') + 1] || '').replace(/^--.*/, '');
+
+/** Normalize a free-text repeat/width to a positive number of inches, else null.
+ *  Handles: '20.5', '21 (53 cm)', '3.5  (8.89 cm)', '36" Wide', 'N/A', '0'. */
+function toInches(v) {
+  if (v == null) return null;
+  const s = String(v);
+  // take the FIRST number (inches come first; the cm value is parenthetical)
+  const m = s.match(/[0-9]+(\.[0-9]+)?/);
+  if (!m) return null;
+  const n = parseFloat(m[0]);
+  return Number.isFinite(n) && n > 0 ? Math.round(n * 100) / 100 : null;
+}
+
+function psql(sql) {
+  return execFileSync('psql', ['-h', '/tmp', '-d', 'dw_unified', '-tAF\t', '-X', '-c', sql], { encoding: 'utf8', maxBuffer: 1 << 28 });
+}
+
+// Build { mfrSku(upper) -> {rv, hr} } supply map from vendor catalogs.
+function buildSupply() {
+  const tables = psql(
+    "select table_name from information_schema.columns where table_schema='public' " +
+    "and column_name='repeat_v' and table_name like '%_catalog' and table_name not like '%_bak%' and table_name not like '_bak%'"
+  ).trim().split('\n').filter(Boolean).map((r) => r.trim());
+  const use = VENDOR ? tables.filter((t) => t === VENDOR || t === VENDOR + '_catalog') : tables;
+  const supply = new Map();
+  for (const t of use) {
+    // mfr sku column name varies; try common ones
+    const cols = psql(`select column_name from information_schema.columns where table_schema='public' and table_name='${t}'`).trim().split('\n').map((s) => s.trim());
+    const skuCol = ['mfr_sku', 'manufacturer_sku', 'sku', 'pattern_number', 'product_code', 'style_number'].find((c) => cols.includes(c));
+    const hasHR = cols.includes('repeat_h');
+    if (!skuCol) continue;
+    let rows;
+    try {
+      rows = psql(`select ${skuCol}::text, repeat_v::text${hasHR ? ', repeat_h::text' : ''} from ${t} where repeat_v is not null`).trim().split('\n');
+    } catch { continue; }
+    for (const line of rows) {
+      if (!line) continue;
+      const [sku, rv, hr] = line.split('\t');
+      const rvN = toInches(rv), hrN = hasHR ? toInches(hr) : null;
+      if (!sku || (!rvN && !hrN)) continue;
+      const key = sku.trim().toUpperCase();
+      if (!supply.has(key)) supply.set(key, { rv: rvN, hr: hrN, src: t });
+    }
+  }
+  return supply;
+}
+
+const Q = `query($c:String){ products(first:200, after:$c, query:"status:active product_type:Wallcovering"){
+  pageInfo{hasNextPage endCursor}
+  nodes{ id
+    mfr: metafield(namespace:"custom", key:"manufacturer_sku"){value}
+    prnum: metafield(namespace:"custom", key:"pattern_repeat_number"){value}
+    prep: metafield(namespace:"custom", key:"pattern_repeat"){value}
+  }}}`;
+
+async function gql(query, variables) {
+  const r = await fetch(API, { method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables }) });
+  return r.json();
+}
+
+(async () => {
+  console.log(`PATH A backfill — mode=${APPLY ? 'APPLY (live write)' : 'DRY-RUN'}${VENDOR ? ' vendor=' + VENDOR : ''}`);
+  const supply = buildSupply();
+  console.log(`vendor-catalog numeric-repeat supply: ${supply.size} distinct mfr SKUs`);
+
+  let cur = null, more = true, page = 0;
+  const stat = { active_wc: 0, has_mfr: 0, already_numeric: 0, matched: 0, would_write: 0 };
+  const ledger = [];
+  const CAP = parseInt(process.env.CAP || '250', 10);
+  while (more && page < CAP) {
+    const j = await gql(Q, { c: cur });
+    if (j.errors) { console.error(JSON.stringify(j.errors).slice(0, 300)); break; }
+    const d = j.data.products;
+    for (const n of d.nodes) {
+      stat.active_wc++;
+      const alreadyNum = toInches(n.prnum && n.prnum.value) || toInches(n.prep && n.prep.value);
+      if (alreadyNum) stat.already_numeric++;
+      const mfr = n.mfr && n.mfr.value ? n.mfr.value.trim().toUpperCase() : null;
+      if (mfr) stat.has_mfr++;
+      if (!mfr || alreadyNum) continue;
+      const s = supply.get(mfr);
+      if (!s) continue;
+      stat.matched++;
+      const val = s.rv || s.hr;
+      if (!val) continue;
+      stat.would_write++;
+      ledger.push({ id: n.id, mfr, before: n.prnum ? n.prnum.value : null, after: String(val), src: s.src });
+    }
+    cur = d.pageInfo.endCursor; more = d.pageInfo.hasNextPage; page++;
+    if (page % 10 === 0) console.error(`...page ${page}, active WC scanned ${stat.active_wc}, would_write ${stat.would_write}`);
+    await new Promise((x) => setTimeout(x, 120));
+  }
+  console.log(JSON.stringify({ ...stat, pages: page, completed: !more }, null, 2));
+
+  if (APPLY && ledger.length) {
+    const ts = new Date().toISOString().replace(/[:.]/g, '-'); // stamp OUTSIDE, ok here (not a workflow)
+    fs.mkdirSync(__dirname + '/runs', { recursive: true });
+    fs.writeFileSync(`${__dirname}/runs/${ts}.json`, JSON.stringify(ledger, null, 2));
+    const M = `mutation($m:[MetafieldsSetInput!]!){ metafieldsSet(metafields:$m){ userErrors{field message} } }`;
+    for (let i = 0; i < ledger.length; i += 25) {
+      const chunk = ledger.slice(i, i + 25).map((e) => ({ ownerId: e.id, namespace: 'custom', key: 'pattern_repeat_number', type: 'number_decimal', value: e.after }));
+      const j = await gql(M, { m: chunk });
+      const errs = j.data && j.data.metafieldsSet && j.data.metafieldsSet.userErrors;
+      if (errs && errs.length) console.error('userErrors', JSON.stringify(errs).slice(0, 300));
+      await new Promise((x) => setTimeout(x, 200));
+    }
+    console.log(`APPLIED ${ledger.length} writes; ledger runs/${ts}.json`);
+  } else if (!APPLY) {
+    console.log(`DRY-RUN: ${ledger.length} products WOULD gain custom.pattern_repeat_number. Re-run with --apply to write.`);
+  }
+})().catch((e) => { console.error(e.message); process.exit(1); });
diff --git a/rolls-ar-deeplink/coverage-result.json b/rolls-ar-deeplink/coverage-result.json
new file mode 100644
index 0000000..13eb781
--- /dev/null
+++ b/rolls-ar-deeplink/coverage-result.json
@@ -0,0 +1,18 @@
+{
+  "total": 40000,
+  "width_ok": 36402,
+  "width_any": 36510,
+  "prep_any": 24683,
+  "prep_numish": 14938,
+  "rep_numish": 470,
+  "prnum_any": 0,
+  "width_and_repeatnum": 14895,
+  "byType": {
+    "Wallcovering": 30192,
+    "Fabric": 9461,
+    "Others": 97,
+    "Trim": 250
+  },
+  "pages": 200,
+  "completed": false
+}
diff --git a/rolls-ar-deeplink/deeplink.js b/rolls-ar-deeplink/deeplink.js
index 4111ed2..0d8c149 100644
--- a/rolls-ar-deeplink/deeplink.js
+++ b/rolls-ar-deeplink/deeplink.js
@@ -1,40 +1,82 @@
 /**
  * "How Many Rolls?" AR deep-link builder  (Council idea #5, 2026-08-09)
- * Emits a measure-tool deep-link ONLY when the product carries the metafields
- * needed to answer the question. Missing data -> null -> the PDP hides the
- * button rather than showing a broken "0 rolls" flow.
+ *
+ * Two modes (Steve approved BOTH Path A and Path B on 2026-08-09,
+ * "A and b approved / Dust2026"):
+ *
+ *  - buildDeepLink()          PATH A / full: emits only when width AND a
+ *                             numeric repeat are present, so the shopper lands
+ *                             in the AR tool with the exact rolls answer
+ *                             pre-computable. Enabled once the
+ *                             custom.pattern_repeat_number backfill lands.
+ *
+ *  - buildDeepLinkWidthOnly() PATH B / ship-now: emits on width ALONE; the AR
+ *                             measure tool collects the repeat + wall size
+ *                             in-tool. Broadly eligible today (width is the
+ *                             widely-populated metafield).
+ *
+ * Missing the minimum data -> null -> the PDP hides the button rather than
+ * showing a broken "0 rolls" flow (the idea's own reliability gate).
  */
 const BASE = 'https://nph.designerwallcoverings.com/measure';
 
 function num(v) {
-  const n = parseFloat(v);
+  if (v == null) return null;
+  // tolerate free-text like '36" Wide' or '20.5 (52 cm)' -> first number
+  const m = String(v).match(/[0-9]+(\.[0-9]+)?/);
+  if (!m) return null;
+  const n = parseFloat(m[0]);
   return Number.isFinite(n) && n > 0 ? n : null;
 }
 
 /**
- * @param {object} mf  metafields: { width_in, vertical_repeat_in, horizontal_repeat_in }
+ * PATH A (full): needs width AND at least one repeat.
+ * @param {object} mf  { width_in, vertical_repeat_in, horizontal_repeat_in }
  * @param {string} dwSku
- * @returns {string|null} deep-link, or null if insufficient data (hide the button)
+ * @returns {string|null}
  */
 function buildDeepLink(mf = {}, dwSku = '') {
   const w = num(mf.width_in);
   const vr = num(mf.vertical_repeat_in);
   const hr = num(mf.horizontal_repeat_in);
-  if (!w || (!vr && !hr)) return null; // need width AND at least one repeat
+  if (!w || (!vr && !hr)) return null;
   const q = new URLSearchParams();
   q.set('w', w);
   if (vr) q.set('vr', vr);
   if (hr) q.set('hr', hr);
   if (dwSku) q.set('sku', dwSku);
+  q.set('mode', 'full');
   return `${BASE}?${q.toString()}`;
 }
 
-module.exports = { buildDeepLink };
+/**
+ * PATH B (ship-now): needs width only. The AR tool collects repeat in-tool.
+ * @param {object} mf  { width_in, vertical_repeat_in?, horizontal_repeat_in? }
+ * @param {string} dwSku
+ * @returns {string|null}
+ */
+function buildDeepLinkWidthOnly(mf = {}, dwSku = '') {
+  const w = num(mf.width_in);
+  if (!w) return null; // width is the only hard requirement for Path B
+  const q = new URLSearchParams();
+  q.set('w', w);
+  // pass repeat through when we happen to have it (progressive enhancement)
+  const vr = num(mf.vertical_repeat_in);
+  const hr = num(mf.horizontal_repeat_in);
+  if (vr) q.set('vr', vr);
+  if (hr) q.set('hr', hr);
+  if (dwSku) q.set('sku', dwSku);
+  q.set('mode', vr || hr ? 'full' : 'width');
+  return `${BASE}?${q.toString()}`;
+}
+
+module.exports = { buildDeepLink, buildDeepLinkWidthOnly, num };
 
 // quick self-test when run directly
 if (require.main === module) {
-  console.log('full :', buildDeepLink({ width_in: 27, vertical_repeat_in: 18, horizontal_repeat_in: 13.5 }, 'DWKK-100367'));
-  console.log('width+vr only:', buildDeepLink({ width_in: 20.5, vertical_repeat_in: 25 }, 'DW-1'));
-  console.log('no repeat -> null:', buildDeepLink({ width_in: 27 }, 'DW-2'));
-  console.log('no width  -> null:', buildDeepLink({ vertical_repeat_in: 18 }, 'DW-3'));
+  console.log('A full          :', buildDeepLink({ width_in: 27, vertical_repeat_in: 18, horizontal_repeat_in: 13.5 }, 'DWKK-100367'));
+  console.log('A no repeat->null:', buildDeepLink({ width_in: 27 }, 'DW-2'));
+  console.log('B width-only    :', buildDeepLinkWidthOnly({ width_in: '36" Wide' }, 'DW-9'));
+  console.log('B width+repeat  :', buildDeepLinkWidthOnly({ width_in: 20.5, vertical_repeat_in: 25 }, 'DW-1'));
+  console.log('B no width->null:', buildDeepLinkWidthOnly({ vertical_repeat_in: 18 }, 'DW-3'));
 }
diff --git a/rolls-ar-deeplink/pathA-dryrun-full.json b/rolls-ar-deeplink/pathA-dryrun-full.json
new file mode 100644
index 0000000..e69de29
diff --git a/rolls-ar-deeplink/scan-metafield-coverage.js b/rolls-ar-deeplink/scan-metafield-coverage.js
new file mode 100644
index 0000000..9d5fc42
--- /dev/null
+++ b/rolls-ar-deeplink/scan-metafield-coverage.js
@@ -0,0 +1,45 @@
+// Live Shopify Admin-API coverage scan for Council #5 (read-only).
+// Tallies, across ACTIVE products, how many carry a PARSEABLE custom.width
+// and what repeat metafields exist. $0 (Admin API is unmetered).
+const fs = require('fs');
+const env = fs.readFileSync(require('os').homedir()+'/Projects/secrets-manager/.env','utf8');
+const g = k => (env.match(new RegExp('^'+k+'=(.*)$','m'))||[])[1];
+const TOKEN = g('SHOPIFY_ADMIN_TOKEN'), STORE = g('SHOPIFY_STORE');
+const URL = `https://${STORE}/admin/api/2024-10/graphql.json`;
+const numish = v => { if(v==null) return false; const n=parseFloat(String(v).replace(/[^0-9.]/g,'')); return Number.isFinite(n)&&n>0; };
+const Q = `query($c:String){ products(first:200, after:$c, query:"status:active"){
+  pageInfo{hasNextPage endCursor}
+  nodes{ id productType
+    width: metafield(namespace:"custom", key:"width"){value}
+    prep: metafield(namespace:"custom", key:"pattern_repeat"){value}
+    rep: metafield(namespace:"custom", key:"repeat"){value}
+    prnum: metafield(namespace:"custom", key:"pattern_repeat_number"){value}
+  }}}`;
+(async()=>{
+  let cur=null, more=true, page=0;
+  const t={total:0, width_ok:0, width_any:0, prep_any:0, prep_numish:0, rep_numish:0, prnum_any:0, width_and_repeatnum:0, byType:{}};
+  const CAP = parseInt(process.env.CAP||'99',10);
+  while(more && page<CAP){
+    const r=await fetch(URL,{method:'POST',headers:{'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'},body:JSON.stringify({query:Q,variables:{c:cur}})});
+    const j=await r.json();
+    if(j.errors){console.error(JSON.stringify(j.errors).slice(0,300)); break;}
+    const d=j.data.products;
+    for(const n of d.nodes){
+      t.total++;
+      const pt=(n.productType||'(none)');
+      t.byType[pt]=(t.byType[pt]||0)+1;
+      if(n.width){t.width_any++; if(numish(n.width.value)) t.width_ok++;}
+      if(n.prep){t.prep_any++; if(numish(n.prep.value)) t.prep_numish++;}
+      if(n.rep && numish(n.rep.value)) t.rep_numish++;
+      if(n.prnum){t.prnum_any++;}
+      const hasWidth = n.width && numish(n.width.value);
+      const hasRepeatNum = (n.prep&&numish(n.prep.value))||(n.rep&&numish(n.rep.value))||(n.prnum&&numish(n.prnum.value));
+      if(hasWidth && hasRepeatNum) t.width_and_repeatnum++;
+    }
+    cur=d.pageInfo.endCursor; more=d.pageInfo.hasNextPage; page++;
+    if(page%10===0) console.error(`...page ${page}, scanned ${t.total}`);
+    await new Promise(x=>setTimeout(x,120));
+  }
+  t.pages=page; t.completed=!more;
+  console.log(JSON.stringify(t,null,2));
+})().catch(e=>{console.error(e.message);process.exit(1);});
diff --git a/rolls-ar-deeplink/snippets/how-many-rolls.liquid b/rolls-ar-deeplink/snippets/how-many-rolls.liquid
new file mode 100644
index 0000000..da9200c
--- /dev/null
+++ b/rolls-ar-deeplink/snippets/how-many-rolls.liquid
@@ -0,0 +1,48 @@
+{%- comment -%}
+  "How Many Rolls?" AR deep-link button  — Council idea #5 (2026-08-09)
+  PATH B (ship-now): renders on WIDTH alone; the AR measure tool at
+  nph.designerwallcoverings.com collects the pattern repeat + wall size in-tool.
+  Progressive enhancement: if a numeric repeat metafield is present (Path A
+  backfill), it is passed through so the tool can pre-fill and skip a step.
+
+  Install: theme 144396058675 -> snippets/how-many-rolls.liquid, then
+  {% render 'how-many-rolls' %} inside the product form (main-product section),
+  below the Add-to-Cart / sample block.
+
+  Hard rule (the idea's own reliability gate): if width is missing/non-numeric,
+  render NOTHING — never a broken "0 rolls" flow.
+{%- endcomment -%}
+
+{%- liquid
+  assign raw_width = product.metafields.custom.width | default: ''
+  assign width_num = raw_width | replace: '"', '' | replace: 'Wide', '' | strip | times: 1.0
+  assign vr = product.metafields.custom.pattern_repeat_number | default: product.metafields.custom.vertical_repeat_number | default: ''
+  assign hr = product.metafields.custom.horizontal_repeat_number | default: ''
+-%}
+
+{%- if width_num > 0 -%}
+  {%- assign qs = '?w=' | append: width_num -%}
+  {%- assign vr_num = vr | times: 1.0 -%}
+  {%- assign hr_num = hr | times: 1.0 -%}
+  {%- if vr_num > 0 -%}{%- assign qs = qs | append: '&vr=' | append: vr_num -%}{%- endif -%}
+  {%- if hr_num > 0 -%}{%- assign qs = qs | append: '&hr=' | append: hr_num -%}{%- endif -%}
+  {%- assign qs = qs | append: '&sku=' | append: product.selected_or_first_available_variant.sku | append: '&mode=' -%}
+  {%- if vr_num > 0 or hr_num > 0 -%}{%- assign qs = qs | append: 'full' -%}{%- else -%}{%- assign qs = qs | append: 'width' -%}{%- endif -%}
+
+  <a class="dw-rolls-btn"
+     href="https://nph.designerwallcoverings.com/measure{{ qs }}"
+     target="_blank" rel="noopener"
+     data-dw-rolls="1">
+    <span class="dw-rolls-icon" aria-hidden="true">&#128207;</span>
+    How many rolls do I need?
+  </a>
+  <style>
+    .dw-rolls-btn{display:inline-flex;align-items:center;gap:.5rem;margin-top:.75rem;
+      padding:.7rem 1.1rem;border:1px solid #1a1a1a;border-radius:2px;
+      font:600 14px/1 -apple-system,Segoe UI,Helvetica,Arial,sans-serif;
+      letter-spacing:.3px;text-decoration:none;color:#1a1a1a;background:#fff;
+      transition:background .15s,color .15s}
+    .dw-rolls-btn:hover{background:#1a1a1a;color:#fff}
+    .dw-rolls-icon{font-size:16px}
+  </style>
+{%- endif -%}

← 371b4e8 Council 2026-08-09: build ideas 1,2,3,5 (disk forecaster, Kr  ·  back to Council 0809 Builds  ·  auto-data-snapshot: 2026-08-09T10:17:32 (1 data files) — rol 175932e →