[object Object]

← back to Designer Wallcoverings

Activated viewer: GraphQL feed with essentials audit (priced/image/specs/width) — per-card gap badges, coverage summary bar, gaps-only filter

c14beea559143c8b79631292ccc48cf9e31aa9ea · 2026-06-20 09:53:35 -0700 · Steve

Files touched

Diff

commit c14beea559143c8b79631292ccc48cf9e31aa9ea
Author: Steve <steve@designerwallcoverings.com>
Date:   Sat Jun 20 09:53:35 2026 -0700

    Activated viewer: GraphQL feed with essentials audit (priced/image/specs/width) — per-card gap badges, coverage summary bar, gaps-only filter
---
 shopify/activated-viewer/audit-result.json | 490 +++++++++++++++++++++++++++++
 shopify/activated-viewer/audit.js          |  65 ++++
 shopify/activated-viewer/public/index.html |  83 ++---
 shopify/activated-viewer/server.js         |  42 ++-
 4 files changed, 632 insertions(+), 48 deletions(-)

diff --git a/shopify/activated-viewer/audit-result.json b/shopify/activated-viewer/audit-result.json
new file mode 100644
index 00000000..ed40c6e3
--- /dev/null
+++ b/shopify/activated-viewer/audit-result.json
@@ -0,0 +1,490 @@
+{
+ "n": 1250,
+ "fail": {
+  "priced": [
+   "GRS-32064-sample",
+   "GRS-32004-sample",
+   "PRO-43969-sample",
+   "STR-54844-sample",
+   "PRW-920018-Sample",
+   "PRW-620045-Sample",
+   "STR-54905-sample",
+   "STR-54898-sample",
+   "488-418-sample",
+   "XCD-69430-sample",
+   "488-439-sample",
+   "XG8-66955-sample",
+   "488-427-sample",
+   "XB8-66605-sample",
+   "XE7-66840-sample",
+   "XE7-66831-sample",
+   "XB5-66575-sample",
+   "XB2-66516-sample",
+   "XB5-66569-sample",
+   "XB2-66506-sample",
+   "STR-54887-sample",
+   "XC2-66667-sample",
+   "PRT-47513-sample",
+   "STR-54878-sample",
+   "PRO-43745-sample",
+   "STR-54858-sample",
+   "PRO-43962-sample",
+   "STR-54852-sample",
+   "488-417-sample",
+   "PRO-43756-sample",
+   "PRO-43753-sample",
+   "488-419-sample",
+   "XE7-66827-sample",
+   "PRO-43747-sample",
+   "PRO-43502-sample",
+   "PRO-43643-sample",
+   "GRS-32017-sample",
+   "PRO-43573-sample",
+   "GRS-32041-sample",
+   "DWPX-500092-Sample",
+   "DWPX-500087-Sample",
+   "DWPX-500075-Sample",
+   "DWPX-500069-Sample",
+   "DWPX-500101-Sample",
+   "DWPX-500095-Sample",
+   "DWPX-500051-Sample",
+   "DWPX-500100-Sample",
+   "DWPX-500094-Sample",
+   "DWPX-500066-Sample",
+   "DWPX-500077-Sample",
+   "DWPX-500015-Sample",
+   "DWPX-500060-Sample",
+   "DWPX-500054-Sample",
+   "DWPX-500065-Sample",
+   "DWPX-500093-Sample",
+   "DWPW-430181-sample",
+   "DWPX-500102-Sample",
+   "DWPX-500096-Sample",
+   "DWPX-500076-Sample",
+   "DWPW-430139-sample",
+   "DWPX-500018-Sample",
+   "DWPX-500086-Sample",
+   "DWPX-500064-Sample",
+   "DWPX-500058-Sample",
+   "DWPW-430206-sample",
+   "DWPX-500017-Sample",
+   "DWPX-500068-Sample",
+   "DWPX-500073-Sample",
+   "DWPX-500067-Sample",
+   "DWPW-430160-sample",
+   "DWPX-500061-Sample",
+   "DWPW-430048-sample",
+   "DWPW-430159-sample",
+   "DWPW-430115-sample",
+   "DWPX-500016-Sample"
+  ],
+  "image": [
+   "DWTB-11459"
+  ],
+  "specs": [
+   "DWTB-10170",
+   "DWTB-11459",
+   "GRS-32064-sample",
+   "GRS-32004-sample",
+   "PRO-43969-sample",
+   "STR-54844-sample",
+   "PRW-920018-Sample",
+   "PRW-620045-Sample",
+   "STR-54905-sample",
+   "STR-54898-sample",
+   "488-418-sample",
+   "XCD-69430-sample",
+   "488-439-sample",
+   "XG8-66955-sample",
+   "488-427-sample",
+   "XB8-66605-sample",
+   "XE7-66840-sample",
+   "XE7-66831-sample",
+   "XB5-66575-sample",
+   "XB2-66516-sample",
+   "XB5-66569-sample",
+   "XB2-66506-sample",
+   "STR-54887-sample",
+   "XC2-66667-sample",
+   "PRT-47513-sample",
+   "STR-54878-sample",
+   "PRO-43745-sample",
+   "STR-54858-sample",
+   "PRO-43962-sample",
+   "STR-54852-sample",
+   "488-417-sample",
+   "PRO-43756-sample",
+   "PRO-43753-sample",
+   "488-419-sample",
+   "XE7-66827-sample",
+   "PRO-43747-sample",
+   "PRO-43502-sample",
+   "PRO-43643-sample",
+   "GRS-32017-sample",
+   "PRO-43573-sample",
+   "GRS-32041-sample",
+   "DWPX-500092-Sample",
+   "DWPX-500087-Sample",
+   "DWPX-500075-Sample",
+   "DWPX-500069-Sample",
+   "DWPX-500101-Sample",
+   "DWPX-500095-Sample",
+   "DWPX-500051-Sample",
+   "DWPX-500100-Sample",
+   "DWPX-500094-Sample",
+   "DWPX-500066-Sample",
+   "DWPX-500077-Sample",
+   "DWPX-500015-Sample",
+   "DWPX-500060-Sample",
+   "DWPX-500054-Sample",
+   "DWPX-500065-Sample",
+   "DWPX-500093-Sample",
+   "DWPW-430181-sample",
+   "DWPX-500102-Sample",
+   "DWPX-500096-Sample",
+   "DWPX-500076-Sample",
+   "DWPW-430139-sample",
+   "DWPX-500018-Sample",
+   "DWPX-500086-Sample",
+   "DWPX-500064-Sample",
+   "DWPX-500058-Sample",
+   "DWPW-430206-sample",
+   "DWPX-500017-Sample",
+   "DWPX-500068-Sample",
+   "DWPX-500073-Sample",
+   "DWPX-500067-Sample",
+   "DWPW-430160-sample",
+   "DWPX-500061-Sample",
+   "DWPW-430048-sample",
+   "DWPW-430159-sample",
+   "DWPW-430115-sample",
+   "DWPX-500016-Sample"
+  ],
+  "width": [
+   "W6295-06-OSB",
+   "NCW4597-04-OSB",
+   "DWTB-11293",
+   "DWTB-11294",
+   "DWTB-11295",
+   "DWTB-11296",
+   "DWTB-11297",
+   "DWTB-11298",
+   "DWTB-11299",
+   "DWTB-11300",
+   "DWTB-11301",
+   "DWTB-11302",
+   "DWTB-11303",
+   "DWTB-11304",
+   "DWTB-10922",
+   "DWTB-11067",
+   "DWTB-11068",
+   "DWTB-11069",
+   "DWTB-11070",
+   "DWTB-11071",
+   "DWTB-11072",
+   "DWTB-11073",
+   "DWTB-11074",
+   "DWTB-11075",
+   "DWTB-11076",
+   "DWTB-11077",
+   "DWTB-11078",
+   "DWTB-11079",
+   "DWTB-11080",
+   "DWTB-11081",
+   "DWTB-11082",
+   "DWTB-11083",
+   "DWTB-11153",
+   "DWTB-11154",
+   "DWTB-11155",
+   "DWTB-11156",
+   "DWTB-11157",
+   "DWTB-11158",
+   "DWTB-11159",
+   "DWTB-11160",
+   "DWTB-11161",
+   "DWTB-11162",
+   "DWTB-11163",
+   "DWTB-11164",
+   "DWTB-11165",
+   "DWTB-11166",
+   "DWTB-11167",
+   "DWTB-11168",
+   "DWTB-11169",
+   "DWTB-11170",
+   "DWTB-11171",
+   "DWTB-11172",
+   "DWTB-11173",
+   "DWTB-11174",
+   "DWTB-11175",
+   "DWTB-11176",
+   "DWTB-11177",
+   "DWTB-11178",
+   "DWTB-11179",
+   "DWTB-11180",
+   "DWTB-11181",
+   "DWTB-11182",
+   "DWTB-11183",
+   "DWTB-11184",
+   "DWTB-11185",
+   "DWTB-11186",
+   "DWTB-11187",
+   "DWTB-11188",
+   "DWTB-11189",
+   "DWTB-11190",
+   "DWTB-11191",
+   "DWTB-11192",
+   "DWTB-11193",
+   "DWTB-11194",
+   "DWTB-11084",
+   "DWTB-11085",
+   "DWTB-11086",
+   "DWTB-11087",
+   "DWTB-11088",
+   "DWTB-11089",
+   "DWTB-11090",
+   "DWTB-11091",
+   "DWTB-11092",
+   "DWTB-11093",
+   "DWTB-11094",
+   "DWTB-11095",
+   "DWTB-11096",
+   "DWTB-11097",
+   "DWTB-11098",
+   "DWTB-11099",
+   "DWTB-11100",
+   "DWTB-11101",
+   "DWTB-11375",
+   "DWTB-11376",
+   "DWTB-11377",
+   "DWTB-11378",
+   "DWTB-11379",
+   "DWTB-11380",
+   "DWTB-11381",
+   "DWTB-11382",
+   "DWTB-11383",
+   "DWTB-11384",
+   "DWTB-11385",
+   "DWTB-11386",
+   "DWTB-11387",
+   "DWTB-11388",
+   "DWTB-11389",
+   "DWTB-11390",
+   "DWTB-11391",
+   "DWTB-11392",
+   "DWTB-11393",
+   "DWTB-11394",
+   "DWTB-11395",
+   "DWTB-11396",
+   "DWTB-11397",
+   "DWTB-11398",
+   "DWTB-11399",
+   "DWTB-11400",
+   "DWTB-11401",
+   "DWTB-11402",
+   "DWTB-11403",
+   "DWTB-11404",
+   "DWTB-11405",
+   "DWTB-11406",
+   "DWTB-11407",
+   "DWTB-11408",
+   "DWTB-11409",
+   "DWTB-11410",
+   "DWTB-11411",
+   "DWTB-11412",
+   "DWTB-11413",
+   "DWTB-11414",
+   "DWTB-11415",
+   "DWTB-11416",
+   "DWTB-10148",
+   "DWTB-10149",
+   "DWTB-10150",
+   "DWTB-10151",
+   "DWTB-10152",
+   "DWTB-10153",
+   "DWTB-10154",
+   "DWTB-10155",
+   "DWTB-10156",
+   "DWTB-10157",
+   "DWTB-10158",
+   "DWTB-10159",
+   "DWTB-10160",
+   "DWTB-10161",
+   "DWTB-10162",
+   "DWTB-10163",
+   "DWTB-10164",
+   "DWTB-10165",
+   "DWTB-10166",
+   "DWTB-10167",
+   "DWTB-10168",
+   "DWTB-10169",
+   "DWTB-10170",
+   "DWTB-10171",
+   "DWTB-10172",
+   "DWTB-10173",
+   "DWTB-10174",
+   "DWTB-10175",
+   "DWTB-10176",
+   "DWTB-10177",
+   "DWTB-10178",
+   "DWTB-10179",
+   "DWTB-10180",
+   "DWTB-10181",
+   "DWTB-11102",
+   "DWTB-11103",
+   "DWTB-11104",
+   "DWTB-11105",
+   "DWTB-11106",
+   "DWTB-11107",
+   "DWTB-11108",
+   "DWTB-11109",
+   "DWTB-11110",
+   "DWTB-11111",
+   "DWTB-11112",
+   "DWTB-11113",
+   "DWTB-11114",
+   "DWTB-11115",
+   "DWTB-11116",
+   "DWTB-11117",
+   "DWTB-11118",
+   "DWTB-11455",
+   "DWTB-11456",
+   "DWTB-11457",
+   "DWTB-11458",
+   "DWTB-11459",
+   "DWTB-11460",
+   "DWTB-11461",
+   "DWTB-11462",
+   "DWTB-11463",
+   "DWTB-11464",
+   "DWTB-11465",
+   "DWTB-11466",
+   "DWTB-11467",
+   "DWTB-11468",
+   "DWTB-11469",
+   "DWTB-11470",
+   "DWTB-11471",
+   "DWTB-11472",
+   "DWTB-11473",
+   "DWTB-11474",
+   "DWTB-11475",
+   "DWTB-11476",
+   "DWTB-11477",
+   "DWTB-11478",
+   "DWTB-11479",
+   "DWTB-11480",
+   "DWTB-11481",
+   "DWTB-11482",
+   "DWTB-11483",
+   "DWTB-11484",
+   "DWTB-11485",
+   "DWTB-11486",
+   "DWTB-11487",
+   "DWTB-11488",
+   "DWTB-11489",
+   "DWTB-11490",
+   "DWTB-11491",
+   "DWTB-11492",
+   "DWTB-11119",
+   "DWTB-11120",
+   "DWTB-11121",
+   "DWTB-11122",
+   "DWTB-11123",
+   "DWTB-11124",
+   "DWTB-11125",
+   "DWTB-11126",
+   "DWTB-11127",
+   "DWTB-11128",
+   "DWTB-11129",
+   "DWTB-11130",
+   "DWTB-11131",
+   "DWTB-11132",
+   "DWTB-11133",
+   "DWTB-11134",
+   "GRS-32064-sample",
+   "GRS-32004-sample",
+   "PRO-43969-sample",
+   "STR-54844-sample",
+   "PRW-920018-Sample",
+   "PRW-620045-Sample",
+   "STR-54905-sample",
+   "STR-54898-sample",
+   "488-418-sample",
+   "XCD-69430-sample",
+   "488-439-sample",
+   "XG8-66955-sample",
+   "488-427-sample",
+   "XB8-66605-sample",
+   "XE7-66840-sample",
+   "XE7-66831-sample",
+   "XB5-66575-sample",
+   "XB2-66516-sample",
+   "XB5-66569-sample",
+   "XB2-66506-sample",
+   "STR-54887-sample",
+   "XC2-66667-sample",
+   "PRT-47513-sample",
+   "STR-54878-sample",
+   "PRO-43745-sample",
+   "STR-54858-sample",
+   "PRO-43962-sample",
+   "STR-54852-sample",
+   "488-417-sample",
+   "PRO-43756-sample",
+   "PRO-43753-sample",
+   "488-419-sample",
+   "XE7-66827-sample",
+   "PRO-43747-sample",
+   "PRO-43502-sample",
+   "PRO-43643-sample",
+   "GRS-32017-sample",
+   "PRO-43573-sample",
+   "GRS-32041-sample",
+   "DWPX-500092-Sample",
+   "DWPX-500087-Sample",
+   "DWPX-500075-Sample",
+   "DWPX-500069-Sample",
+   "DWPX-500101-Sample",
+   "DWPX-500095-Sample",
+   "DWPX-500051-Sample",
+   "DWPX-500100-Sample",
+   "DWPX-500094-Sample",
+   "DWPX-500066-Sample",
+   "DWPX-500077-Sample",
+   "DWPX-500015-Sample",
+   "DWPX-500060-Sample",
+   "DWPX-500054-Sample",
+   "DWPX-500065-Sample",
+   "DWPX-500093-Sample",
+   "DWPW-430181-sample",
+   "DWPX-500102-Sample",
+   "DWPX-500096-Sample",
+   "DWPX-500076-Sample",
+   "DWPW-430139-sample",
+   "DWPX-500018-Sample",
+   "DWPX-500086-Sample",
+   "DWPX-500064-Sample",
+   "DWPX-500058-Sample",
+   "DWPW-430206-sample",
+   "DWPX-500017-Sample",
+   "DWPX-500068-Sample",
+   "DWPX-500073-Sample",
+   "DWPX-500067-Sample",
+   "DWPW-430160-sample",
+   "DWPX-500061-Sample",
+   "DWPW-430048-sample",
+   "DWPW-430159-sample",
+   "DWPW-430115-sample",
+   "DWPX-500016-Sample",
+   "DWQW-61461-sample",
+   "DWQW-61460-sample",
+   "DWQW-61459-sample",
+   "DWQW-61458-sample",
+   "DWQW-61457-sample",
+   "DWQW-61456-sample",
+   "DWQW-61455-sample",
+   "DWQW-61454-sample",
+   "DWQW-61453-sample"
+  ]
+ },
+ "at": "2026-06-20T16:49:30.821Z"
+}
\ No newline at end of file
diff --git a/shopify/activated-viewer/audit.js b/shopify/activated-viewer/audit.js
new file mode 100644
index 00000000..688026cf
--- /dev/null
+++ b/shopify/activated-viewer/audit.js
@@ -0,0 +1,65 @@
+'use strict';
+// Audit recently-ACTIVATED products against the 3 essentials: specs-in-metafields,
+// image (factory), priced (has a real roll/bolt, not sample-only). Read-only GraphQL.
+const fs = require('fs'), path = require('path');
+for (const p of [path.join(__dirname, '..', '.env'), path.join(__dirname, '.env')])
+  if (fs.existsSync(p)) for (const l of fs.readFileSync(p, 'utf8').split('\n')) { const m = l.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, ''); }
+const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN, VER = '2024-10';
+const MAX_PAGES = Number(process.env.MAX_PAGES || 5); // 5×250 = up to 1250
+
+const Q = `query($cur:String){ products(first:250, query:"status:active", sortKey:CREATED_AT, reverse:true, after:$cur){
+  pageInfo{ hasNextPage endCursor }
+  nodes{ id title handle tags featuredImage{ url } mediaCount{ count }
+    variants(first:15){ nodes{ price sku title } }
+    metafields(first:60){ nodes{ namespace key value } } } } }`;
+
+async function gql(cur) {
+  const r = await fetch(`https://${STORE}/admin/api/${VER}/graphql.json`, {
+    method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ query: Q, variables: { cur } })
+  });
+  const j = await r.json();
+  if (j.errors) throw new Error(JSON.stringify(j.errors).slice(0, 200));
+  return j.data.products;
+}
+
+(async () => {
+  let cur = null, all = [];
+  for (let p = 0; p < MAX_PAGES; p++) {
+    const pg = await gql(cur); all.push(...pg.nodes);
+    if (!pg.pageInfo.hasNextPage) break; cur = pg.pageInfo.endCursor;
+    await new Promise(r => setTimeout(r, 350)); // gentle on the API
+  }
+  const fail = { priced: [], image: [], specs: [], width: [] };
+  for (const p of all) {
+    const sku = (p.variants.nodes[0] && p.variants.nodes[0].sku) || p.handle;
+    const priced = p.variants.nodes.some(v => parseFloat(v.price) > 4.25);
+    const hasImage = !!(p.featuredImage && p.featuredImage.url) && (p.mediaCount ? p.mediaCount.count > 0 : true);
+    const mfKeys = p.metafields.nodes.map(m => `${m.namespace}.${m.key}`);
+    const hasSpecs = p.metafields.nodes.some(m => m.namespace === 'specs') ||
+      mfKeys.includes('custom.ai_enriched_at') ||
+      p.metafields.nodes.filter(m => m.namespace === 'custom').length >= 5;
+    const hasWidth = mfKeys.some(k => /\.width$|\.roll_width$|\.product_width$/.test(k)) ||
+      p.metafields.nodes.some(m => m.namespace === 'custom' && /width/i.test(m.key) && m.value);
+    if (!priced) fail.priced.push(sku);
+    if (!hasImage) fail.image.push(sku);
+    if (!hasSpecs) fail.specs.push(sku);
+    if (!hasWidth) fail.width.push(sku);
+  }
+  const n = all.length, pct = x => ((n - x) / n * 100).toFixed(1);
+  const show = a => a.slice(0, 12).join(', ') + (a.length > 12 ? ` …+${a.length - 12}` : '');
+  console.log(`\n=== ACTIVATED-ITEM ESSENTIALS AUDIT — ${n} most-recent active products ===\n`);
+  console.log(`PRICED (roll/bolt > $4.25)  : ${n - fail.priced.length}/${n}  (${pct(fail.priced.length)}%)  — ${fail.priced.length} sample-only/unpriced`);
+  console.log(`HAS IMAGE                   : ${n - fail.image.length}/${n}  (${pct(fail.image.length)}%)  — ${fail.image.length} missing`);
+  console.log(`SPECS IN METAFIELDS         : ${n - fail.specs.length}/${n}  (${pct(fail.specs.length)}%)  — ${fail.specs.length} missing specs`);
+  console.log(`WIDTH METAFIELD (hard gate) : ${n - fail.width.length}/${n}  (${pct(fail.width.length)}%)  — ${fail.width.length} missing width`);
+  console.log('');
+  if (fail.priced.length) console.log(`  ✗ not priced : ${show(fail.priced)}`);
+  if (fail.image.length)  console.log(`  ✗ no image   : ${show(fail.image)}`);
+  if (fail.specs.length)  console.log(`  ✗ no specs   : ${show(fail.specs)}`);
+  if (fail.width.length)  console.log(`  ✗ no width   : ${show(fail.width)}`);
+  // persist the fail lists for follow-up
+  fs.writeFileSync(path.join(__dirname, 'audit-result.json'), JSON.stringify({ n, fail, at: new Date().toISOString() }, null, 1));
+  console.log(`\nfull fail-lists → audit-result.json`);
+})().catch(e => { console.error('AUDIT FAIL:', e.message); process.exit(1); });
diff --git a/shopify/activated-viewer/public/index.html b/shopify/activated-viewer/public/index.html
index 2b69053a..4c2ee274 100644
--- a/shopify/activated-viewer/public/index.html
+++ b/shopify/activated-viewer/public/index.html
@@ -5,17 +5,20 @@
 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 <title>DW — Activated Items by Date</title>
 <style>
-  :root{ --ink:#1b1b1b; --muted:#6b6b6b; --line:#e6e6e6; --bg:#f5f4f1; --card:#fff; --accent:#1f6f54; --cols:260px; }
+  :root{ --ink:#1b1b1b; --muted:#6b6b6b; --line:#e6e6e6; --bg:#f5f4f1; --card:#fff; --accent:#1f6f54; --bad:#b00020; --cols:260px; }
   *{box-sizing:border-box}
   body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--ink)}
   header{position:sticky;top:0;z-index:10;background:#fff;border-bottom:1px solid var(--line);padding:12px 20px;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
   header h1{font-size:18px;margin:0;font-weight:800;letter-spacing:-.3px}
   header .meta{font-size:12.5px;color:var(--muted)}
-  .controls{margin-left:auto;display:flex;align-items:center;gap:18px;flex-wrap:wrap}
+  .controls{margin-left:auto;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
   .controls label{font-size:12px;color:var(--muted);font-weight:700;display:flex;align-items:center;gap:7px}
   select,button{font-family:inherit;font-size:13px;border:1px solid var(--line);border-radius:8px;padding:7px 11px;background:#fff;cursor:pointer}
   button:hover{background:#f0f0ee}
   input[type=range]{accent-color:var(--accent)}
+  .summary{position:sticky;top:57px;z-index:9;display:flex;gap:22px;flex-wrap:wrap;padding:10px 20px;background:#fffdf8;border-bottom:1px solid var(--line);font-size:12.5px;align-items:center}
+  .summary .s b{font-size:15px} .summary .s span{color:var(--muted)}
+  .summary .ok{color:var(--accent)} .summary .warn{color:var(--bad)}
   .wrap{padding:18px 20px 60px;max-width:1500px;margin:0 auto}
   .daygroup{margin-bottom:26px}
   .dayhd{display:flex;align-items:baseline;gap:10px;margin:0 2px 12px;padding-bottom:7px;border-bottom:2px solid var(--line)}
@@ -24,13 +27,19 @@
   .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--cols),1fr));gap:14px}
   .card{background:var(--card);border:1px solid var(--line);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;transition:box-shadow .15s}
   .card:hover{box-shadow:0 4px 16px rgba(0,0,0,.10)}
+  .card.gap{outline:2px solid #f0b3b3;outline-offset:-1px}
   .thumb{aspect-ratio:1/1;background:#eee center/cover no-repeat;position:relative}
-  .thumb.none{display:grid;place-items:center;color:#bbb;font-size:12px}
+  .thumb.none{display:grid;place-items:center;color:#bbb;font-size:12px;background:#f6e9e9}
   .body{padding:10px 12px 12px;display:flex;flex-direction:column;gap:6px;flex:1}
   .title{font-size:13.5px;font-weight:600;line-height:1.3;color:#222;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+  .rowx{display:flex;justify-content:space-between;align-items:center;gap:8px}
   .sku{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--accent);font-weight:700;word-break:break-all}
+  .price{font-size:12.5px;font-weight:800;color:#222;white-space:nowrap}
+  .price.sample{color:var(--bad)}
   .when{font-size:11.5px;color:var(--muted);display:flex;align-items:center;gap:5px;margin-top:auto}
-  .links{display:flex;gap:8px;margin-top:4px}
+  .badges{display:flex;flex-wrap:wrap;gap:4px}
+  .bdg{font-size:9.5px;font-weight:800;letter-spacing:.3px;padding:2px 6px;border-radius:6px;background:#fdeaea;color:var(--bad)}
+  .links{display:flex;gap:8px}
   .links a{font-size:11.5px;font-weight:700;color:var(--accent);text-decoration:none}
   .links a:hover{text-decoration:underline}
   .empty,.loading{padding:60px;text-align:center;color:var(--muted)}
@@ -50,84 +59,88 @@
         <option value="sku">SKU A→Z</option>
       </select>
     </label>
+    <label><input type="checkbox" id="gaps" /> Gaps only</label>
     <label>Density <input type="range" id="density" min="160" max="360" step="20" /></label>
     <button id="refresh">↻ Refresh</button>
   </div>
 </header>
+<div class="summary" id="summary"></div>
 <div class="wrap"><div id="out" class="loading">Loading activated items from the live store…</div></div>
 
 <script>
 let ITEMS = [], STORE = '';
 const $ = s => document.querySelector(s);
-const sortSel = $('#sort'), density = $('#density');
+const sortSel = $('#sort'), density = $('#density'), gapsOnly = $('#gaps');
 
-// persisted controls (standing rule)
 sortSel.value = localStorage.getItem('av-sort') || 'newest';
 density.value = localStorage.getItem('av-density') || '260';
+gapsOnly.checked = localStorage.getItem('av-gaps') === '1';
 applyDensity();
 sortSel.onchange = () => { localStorage.setItem('av-sort', sortSel.value); render(); };
 density.oninput = () => { localStorage.setItem('av-density', density.value); applyDensity(); };
-function applyDensity(){ document.documentElement.style.setProperty('--cols', density.value + 'px'); }
-
+gapsOnly.onchange = () => { localStorage.setItem('av-gaps', gapsOnly.checked ? '1' : '0'); render(); };
 $('#refresh').onclick = () => load(true);
+function applyDensity(){ document.documentElement.style.setProperty('--cols', density.value + 'px'); }
 
-function fmtDate(iso){
-  if(!iso) return '—';
-  return new Date(iso).toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
-}
-function dayKey(iso){ return iso ? new Date(iso).toLocaleDateString(undefined,{year:'numeric',month:'long',day:'numeric',weekday:'short'}) : 'Unknown date'; }
+const fmtDate = iso => iso ? new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}) : '—';
+const dayKey = iso => iso ? new Date(iso).toLocaleDateString(undefined,{year:'numeric',month:'long',day:'numeric',weekday:'short'}) : 'Unknown date';
+const gapsOf = it => { const g=[]; if(!it.priced)g.push('NO PRICE'); if(!it.hasImage)g.push('NO IMAGE'); if(!it.hasSpecs)g.push('NO SPECS'); if(!it.hasWidth)g.push('NO WIDTH'); return g; };
 
 function card(it){
+  const g = gapsOf(it);
   const adminUrl = `https://${STORE}/admin/products/${it.id}`;
   const pdp = `https://designerwallcoverings.com/products/${it.handle}`;
-  const thumb = it.image
-    ? `<div class="thumb" style="background-image:url('${it.image}&width=400')"></div>`
-    : `<div class="thumb none">no image</div>`;
-  return `<div class="card">
+  const thumb = it.image ? `<div class="thumb" style="background-image:url('${it.image}&width=400')"></div>` : `<div class="thumb none">⚠ no image</div>`;
+  const price = it.priced ? `<span class="price">$${Number(it.price).toFixed(2)}</span>` : `<span class="price sample">sample-only</span>`;
+  return `<div class="card ${g.length?'gap':''}">
     ${thumb}
     <div class="body">
-      <div class="sku">${it.sku || it.handle || '—'}</div>
+      <div class="rowx"><span class="sku">${it.sku || it.handle || '—'}</span>${price}</div>
       <div class="title">${it.title}</div>
-      <div class="when" title="${it.activated || ''}">🕓 ${fmtDate(it.activated)}</div>
+      ${g.length?`<div class="badges">${g.map(b=>`<span class="bdg">${b}</span>`).join('')}</div>`:''}
+      <div class="when" title="${it.activated||''}">🕓 ${fmtDate(it.activated)}</div>
       <div class="links"><a href="${pdp}" target="_blank">Store ↗</a><a href="${adminUrl}" target="_blank">Admin ↗</a></div>
     </div>
   </div>`;
 }
 
+function summaryBar(){
+  const n = ITEMS.length || 1;
+  const pc = f => (ITEMS.filter(f).length/n*100).toFixed(1);
+  const cell = (label,f)=>{ const p=pc(f); const cls=p>=99?'ok':'warn'; return `<div class="s"><b class="${cls}">${p}%</b> <span>${label}</span></div>`; };
+  $('#summary').innerHTML =
+    cell('priced', i=>i.priced) + cell('image', i=>i.hasImage) + cell('specs', i=>i.hasSpecs) + cell('width', i=>i.hasWidth) +
+    `<div class="s" style="margin-left:auto"><b class="warn">${ITEMS.filter(i=>gapsOf(i).length).length}</b> <span>items with gaps</span></div>`;
+}
+
 function render(){
+  summaryBar();
   const mode = sortSel.value;
   let items = ITEMS.slice();
-  if(mode==='title') items.sort((a,b)=> (a.title||'').localeCompare(b.title||''));
-  else if(mode==='sku') items.sort((a,b)=> (a.sku||a.handle||'').localeCompare(b.sku||b.handle||''));
+  if(gapsOnly.checked) items = items.filter(i=>gapsOf(i).length);
+  if(mode==='title') items.sort((a,b)=>(a.title||'').localeCompare(b.title||''));
+  else if(mode==='sku') items.sort((a,b)=>(a.sku||a.handle||'').localeCompare(b.sku||b.handle||''));
   else items.sort((a,b)=> mode==='oldest' ? new Date(a.activated)-new Date(b.activated) : new Date(b.activated)-new Date(a.activated));
 
   const out = $('#out');
-  if(!items.length){ out.className='empty'; out.textContent='No activated items found.'; return; }
+  if(!items.length){ out.className='empty'; out.textContent = gapsOnly.checked ? '✓ No gaps in view.' : 'No activated items found.'; return; }
   out.className='';
-
   if(mode==='newest' || mode==='oldest'){
-    // group by activation date
-    const groups = [];
-    let cur = null;
-    for(const it of items){ const k = dayKey(it.activated); if(!cur || cur.k!==k){ cur={k, items:[]}; groups.push(cur);} cur.items.push(it); }
-    out.innerHTML = groups.map(g=>`<div class="daygroup">
-      <div class="dayhd"><b>${g.k}</b><span class="pill">${g.items.length} activated</span></div>
-      <div class="grid">${g.items.map(card).join('')}</div>
-    </div>`).join('');
+    const groups=[]; let cur=null;
+    for(const it of items){ const k=dayKey(it.activated); if(!cur||cur.k!==k){cur={k,items:[]};groups.push(cur);} cur.items.push(it); }
+    out.innerHTML = groups.map(g=>`<div class="daygroup"><div class="dayhd"><b>${g.k}</b><span class="pill">${g.items.length}</span></div><div class="grid">${g.items.map(card).join('')}</div></div>`).join('');
   } else {
     out.innerHTML = `<div class="grid">${items.map(card).join('')}</div>`;
   }
 }
 
 async function load(refresh){
-  $('#out').className='loading'; $('#out').textContent='Loading…';
+  $('#out').className='loading'; $('#out').textContent='Loading from the live store (this pulls metafields, ~a few seconds)…';
   const r = await fetch('/api/activated' + (refresh?'?refresh=1':''));
   const j = await r.json();
   ITEMS = j.items || []; STORE = j.store || '';
   const days = new Set(ITEMS.map(i=>dayKey(i.activated))).size;
-  $('#meta').innerHTML = j.error
-    ? `<span style="color:#b00">⚠ ${j.error}</span>`
-    : `${j.count} active items · ${days} days · ${j.store} · fetched ${fmtDate(new Date(j.fetchedAt).toISOString())}`;
+  $('#meta').innerHTML = j.error ? `<span style="color:#b00">⚠ ${j.error}</span>` : `${j.count} active · ${days} days · ${j.store}`;
   render();
 }
 load(false);
diff --git a/shopify/activated-viewer/server.js b/shopify/activated-viewer/server.js
index 4f7cccb3..ccbad577 100644
--- a/shopify/activated-viewer/server.js
+++ b/shopify/activated-viewer/server.js
@@ -21,27 +21,43 @@ const MAX_PAGES = Number(process.env.MAX_PAGES || 4); // 4×250 = up to 1000 mos
 let cache = { at: 0, data: null, err: null };
 const TTL = 5 * 60 * 1000;
 
+const GQL = `query($cur:String){ products(first:250, query:"status:active", sortKey:CREATED_AT, reverse:true, after:$cur){
+  pageInfo{ hasNextPage endCursor }
+  nodes{ id title handle publishedAt createdAt featuredImage{ url } mediaCount{ count }
+    variants(first:15){ nodes{ price sku } }
+    metafields(first:60){ nodes{ namespace key value } } } } }`;
+
 async function fetchActivated() {
   if (!TOKEN) throw new Error('SHOPIFY_ADMIN_TOKEN not set');
   const out = [];
-  let url = `https://${STORE}/admin/api/${VER}/products.json?status=active&limit=250&order=created_at+desc&fields=id,title,handle,status,created_at,published_at,variants,image`;
-  for (let p = 0; p < MAX_PAGES && url; p++) {
-    const r = await fetch(url, { headers: { 'X-Shopify-Access-Token': TOKEN } });
+  let cur = null;
+  for (let p = 0; p < MAX_PAGES; p++) {
+    const r = await fetch(`https://${STORE}/admin/api/${VER}/graphql.json`, {
+      method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+      body: JSON.stringify({ query: GQL, variables: { cur } })
+    });
     if (!r.ok) throw new Error('shopify ' + r.status + ' ' + (await r.text()).slice(0, 120));
     const j = await r.json();
-    for (const pr of j.products || []) {
-      const v = (pr.variants && pr.variants[0]) || {};
+    if (j.errors) throw new Error(JSON.stringify(j.errors).slice(0, 160));
+    const pg = j.data.products;
+    for (const pr of pg.nodes) {
+      const vs = pr.variants.nodes, mf = pr.metafields.nodes;
+      const keys = mf.map(m => `${m.namespace}.${m.key}`);
       out.push({
-        id: pr.id, title: pr.title, handle: pr.handle,
-        sku: v.sku || '', price: v.price || '',
-        image: (pr.image && pr.image.src) || '',
-        published_at: pr.published_at, created_at: pr.created_at,
-        activated: pr.published_at || pr.created_at
+        id: String(pr.id).split('/').pop(), title: pr.title, handle: pr.handle,
+        sku: (vs[0] && vs[0].sku) || '',
+        price: vs.reduce((mx, v) => Math.max(mx, parseFloat(v.price) || 0), 0),
+        image: (pr.featuredImage && pr.featuredImage.url) || '',
+        published_at: pr.publishedAt, created_at: pr.createdAt,
+        activated: pr.publishedAt || pr.createdAt,
+        priced: vs.some(v => parseFloat(v.price) > 4.25),
+        hasImage: !!(pr.featuredImage && pr.featuredImage.url) && (!pr.mediaCount || pr.mediaCount.count > 0),
+        hasSpecs: mf.some(m => m.namespace === 'specs') || keys.includes('custom.ai_enriched_at') || mf.filter(m => m.namespace === 'custom').length >= 5,
+        hasWidth: mf.some(m => /width/i.test(m.key) && m.value && String(m.value).trim() !== '')
       });
     }
-    const link = r.headers.get('link') || '';
-    const m = link.match(/<([^>]+)>;\s*rel="next"/);
-    url = m ? m[1] : null;
+    if (!pg.pageInfo.hasNextPage) break; cur = pg.pageInfo.endCursor;
+    await new Promise(r => setTimeout(r, 300));
   }
   out.sort((a, b) => new Date(b.activated) - new Date(a.activated));
   return out;

← afcf25ff Add activated-items viewer: live Shopify pull, grouped by ac  ·  back to Designer Wallcoverings  ·  Activation gate: enforce specs+description+ALL-vendor-images 6396e193 →