← 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
A rolls-ar-deeplink/backfill-pattern-repeat.jsA rolls-ar-deeplink/coverage-result.jsonM rolls-ar-deeplink/deeplink.jsA rolls-ar-deeplink/pathA-dryrun-full.jsonA rolls-ar-deeplink/scan-metafield-coverage.jsA rolls-ar-deeplink/snippets/how-many-rolls.liquid
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">📏</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 →