← back to Designer Wallcoverings
Add deployment memo + testing guide for collection filter UI transformation
369a5d7ed4c0761cc2aaf927da5963563b675283 · 2026-06-22 13:49:04 -0700 · Steve Abrams
Files touched
A COLLECTION-UI-TESTING.mdA shopify/scripts/bucketB-rebelwalls-roll-variants.js
Diff
commit 369a5d7ed4c0761cc2aaf927da5963563b675283
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jun 22 13:49:04 2026 -0700
Add deployment memo + testing guide for collection filter UI transformation
---
COLLECTION-UI-TESTING.md | 182 +++++++++++++++
.../scripts/bucketB-rebelwalls-roll-variants.js | 260 +++++++++++++++++++++
2 files changed, 442 insertions(+)
diff --git a/COLLECTION-UI-TESTING.md b/COLLECTION-UI-TESTING.md
new file mode 100644
index 00000000..91639d77
--- /dev/null
+++ b/COLLECTION-UI-TESTING.md
@@ -0,0 +1,182 @@
+# Collection Page UI Testing Guide
+
+## Local Testing (Browser DevTools)
+
+### Setup
+1. Open any Designer Wallcoverings collection page on the dev/live store
+2. Open Chrome DevTools (F12)
+3. Go to Console tab
+
+### Test 1: Horizontal Filters Display
+```javascript
+// Check if filter container is using flexbox
+const filters = document.querySelector('.boost-sd__filter-tree-wrapper');
+console.log('Filter display:', window.getComputedStyle(filters).display);
+console.log('Filter flex-direction:', window.getComputedStyle(filters).flexDirection);
+console.assert(
+ window.getComputedStyle(filters).display === 'flex',
+ 'Filters should be flex'
+);
+```
+
+**Expected**: Console shows `display: flex` and `flex-direction: row`
+
+### Test 2: Grid Column Range
+```javascript
+// Check grid slider range
+const slider = document.getElementById('dw-grid-range');
+console.log('Slider min:', slider.min); // Should be 3
+console.log('Slider max:', slider.max); // Should be 12
+console.assert(slider.max === '12', 'Max should be 12 columns');
+```
+
+**Expected**: Slider max shows `12` (not 8)
+
+### Test 3: Grid Columns Apply Correctly
+```javascript
+// Test applying different column values
+const slider = document.getElementById('dw-grid-range');
+const grid = document.querySelector('.boost-sd__product-list');
+
+for (let cols of [3, 6, 9, 12]) {
+ slider.value = cols;
+ slider.dispatchEvent(new Event('input'));
+ const gridCols = window.getComputedStyle(grid).gridTemplateColumns;
+ console.log(`${cols} columns:`, gridCols.split(' ').length, 'tracks');
+}
+```
+
+**Expected**: Each value sets the correct number of grid columns
+
+### Test 4: localStorage Persistence
+```javascript
+// Simulate user preference
+const slider = document.getElementById('dw-grid-range');
+slider.value = 8;
+slider.dispatchEvent(new Event('change'));
+
+// Check localStorage
+console.log('Saved columns:', localStorage.getItem('dw-grid-cols'));
+
+// Simulate page reload
+location.reload();
+// (page reloads, should restore to 8 columns)
+```
+
+**Expected**: After reload, grid shows 8 columns (localStorage persisted)
+
+### Test 5: Full-Width Grid
+```javascript
+// Check grid takes full width (no sidebar padding)
+const grid = document.querySelector('.boost-sd__product-list');
+const padding = window.getComputedStyle(grid).paddingRight;
+console.log('Grid padding-right:', padding);
+console.assert(padding === '0px', 'Grid should have 0 right padding');
+```
+
+**Expected**: `paddingRight: 0px`
+
+### Test 6: Mobile Responsive (640px)
+```javascript
+// Simulate mobile viewport
+window.resizeTo(375, 812); // iPhone size
+// Or manually resize browser to <640px width
+
+// Check filter container adapts
+const filters = document.querySelector('.boost-sd__filter-tree-wrapper');
+const style = window.getComputedStyle(filters);
+console.log('Mobile filter max-height:', style.maxHeight);
+console.log('Mobile filter overflow:', style.overflowY);
+```
+
+**Expected**: On mobile, filters have `max-height: 120px` and `overflow-y: auto`
+
+## Manual Browser Testing
+
+### Desktop (1920px+)
+- [ ] Load `/collections/all` or any collection page
+- [ ] Verify filter tabs appear horizontally above grid (Color, Style, Brand, Durability, Price)
+- [ ] Click filter options—products should filter without page reload
+- [ ] Adjust grid density slider: 3 → 4 → 6 → 8 → 10 → 12 columns
+- [ ] Verify grid extends full viewport width
+- [ ] Refresh page: grid should restore to your last-selected column count
+- [ ] Verify sort dropdown works (usually top-left of toolbar)
+- [ ] Verify "Show" per-page selector works (inside grid slider pill)
+
+### Tablet (768px)
+- [ ] Filter tabs should still appear horizontally but may wrap to 2-3 lines
+- [ ] Grid should adapt column count (may reduce on smaller screen)
+- [ ] Density slider should remain responsive
+
+### Mobile (375px)
+- [ ] Filter tabs in scrollable container (not taking full viewport height)
+- [ ] Grid density slider should be touch-friendly
+- [ ] Grid should show 2-3 columns maximum (responsive breakpoint)
+- [ ] No horizontal scroll overflow
+
+## Expected Behavior Changes
+
+### Before (Vertical Sidebar)
+```
+┌─────────────────────────────────┐
+│ Filter Toggle | Grid Slider │
+├────┬──────────────────────────┤
+│ Co │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
+│ lo │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
+│ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
+│ St │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
+│ yl │ │
+│ e │ │
+└────┴──────────────────────────┘
+```
+
+### After (Horizontal Filters)
+```
+┌───────────────────────────────────┐
+│ Color Style Brand | Grid Slider │
+├────────────────────────────────────┤
+│ ▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓ │
+│ ▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓ │
+│ ▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓▓ │
+└────────────────────────────────────┘
+```
+
+## Troubleshooting
+
+### Problem: Filters still appear vertically
+**Solution**:
+- Check Boost AI app version (may have changed HTML structure)
+- Open DevTools → Inspect `.boost-sd__filter-tree-wrapper`
+- Compare actual classes to CSS selectors in collection.liquid
+- Update selectors if Boost changed class names
+
+### Problem: Grid doesn't extend full width
+**Solution**:
+- Check if `.boost-sd__product-list` has competing padding/margin
+- Add more `!important` flags to overrides
+- Verify `.boost-sd__filter-products-wrap` is also full width
+
+### Problem: Grid column control doesn't work
+**Solution**:
+- Check if slider is actually present: `document.getElementById('dw-grid-range')`
+- Verify JavaScript is running: check Console for errors
+- Check if Boost grid uses a different class (not `.boost-sd__product-list`)
+- May need to target specific Boost grid class variants
+
+### Problem: Mobile layout breaks
+**Solution**:
+- Adjust 640px breakpoint if needed for your target devices
+- Reduce filter container height if tabs are too tall
+- Test on actual mobile device (not just browser resize)
+
+## Commit & Deployment Checklist
+
+- [ ] All manual tests pass on dev theme
+- [ ] No console errors in DevTools
+- [ ] Filters visibly horizontal (not hidden/broken)
+- [ ] Grid density 3-12 works end-to-end
+- [ ] localStorage persists user preference
+- [ ] Mobile view responsive and usable
+- [ ] Prepare deployment memo for Steve
+- [ ] Await approval for live push
+
diff --git a/shopify/scripts/bucketB-rebelwalls-roll-variants.js b/shopify/scripts/bucketB-rebelwalls-roll-variants.js
new file mode 100644
index 00000000..b4e14755
--- /dev/null
+++ b/shopify/scripts/bucketB-rebelwalls-roll-variants.js
@@ -0,0 +1,260 @@
+#!/usr/bin/env node
+/**
+ * bucketB-rebelwalls-roll-variants.js (2026-06-22) — owner: vp-dw-commerce
+ *
+ * Bucket B — Rebel Walls sample-only recovery (Steve APPROVED, draft-gated, reversible).
+ * The first feed-first vendor in the approved re-scrape worklist. For the 1,181
+ * Rebel Walls sample-only products (Sample variant present, no sellable ROLL), add
+ * the missing ROLL variant `{DW_SKU}` alongside the existing `{DW_SKU}-Sample` ($4.25).
+ *
+ * PRICE SOURCE — recover from PG first (DTD verdict A, 2026-06-22, 3/3):
+ * rebel_walls_catalog.our_price (else price_retail), joined on the product's
+ * mfr_sku (exact) and on the suffix-stripped mfr_base (R13041-5 -> R13041).
+ * ONLY 129/1181 recover a real per-product price this way; the other ~1052
+ * (old RS#### distributor codes not on the live site, pattern-only titles, no
+ * stored vendor URL) get NO roll variant — they stay sample-only + tag Needs-Price.
+ * We do NOT force the flat $80.48 tier onto unmatched rows (rejected option C),
+ * and we do NOT fuzzy-slug-scrape RS#### (rejected option B, high mis-match risk).
+ * That price enrichment is produced upstream into data/bucketB-enriched.tsv by a
+ * PG join; this script never invents a price.
+ *
+ * PRICING RULE (Rebel Walls is NOT Kravet-family):
+ * our_price/price_retail from the catalog is ALREADY our retail (per single roll),
+ * so use it directly. No cost/0.65/0.85 derivation needed (and no cost is stored
+ * for these — so we could not derive one anyway). roll_price is taken verbatim.
+ *
+ * ORDER OF OPS (PostgreSQL BEFORE Shopify):
+ * 1. read recovered roll price from data/bucketB-enriched.tsv (PG-sourced)
+ * 2. WRITE the roll price into dw_unified.shopify_products.retail_price for the
+ * matching shopify_id -- source of truth first
+ * 3. create the ROLL variant on the Shopify product (sku on inventoryItem),
+ * reorder roll-first, set metafields (mfr_sku, unit_of_measure, price_updated_at)
+ * 4. for unmatched (price_source=none): tag Needs-Price (+ Needs-Width/Needs-Image
+ * where the catalog gave us no width/image) — NO variant, status untouched
+ *
+ * HARD RULES enforced:
+ * - NEVER DUPLICATE — operate on the EXISTING product by shopify_id; idempotent
+ * skip if a non-Sample variant already exists. Never create a 2nd product.
+ * - PRICE GATE — no recovered price => row stays sample-only, tag Needs-Price.
+ * - PG dw_unified BEFORE Shopify.
+ * - NEVER flip status. These are ACTIVE; we never set ACTIVE (no img+width work
+ * here) and never demote. If a product were DRAFT it stays DRAFT.
+ * - never touch title / customer-facing vendor field / display_variant tag.
+ * - never the word "Wallpaper"; SKU never "Unknown" (sample sku is authoritative).
+ *
+ * Resumable: idempotent per product (skips already-has-roll; re-tagging is additive).
+ * Variant cap: --max-create caps roll creations per run (default 900, under the 1k/day
+ * cap with headroom). ≥90s between bulk batches handled by --batch-size + --batch-gap.
+ *
+ * node bucketB-rebelwalls-roll-variants.js # DRY-RUN
+ * node bucketB-rebelwalls-roll-variants.js --apply # LIVE
+ * node bucketB-rebelwalls-roll-variants.js --apply --max-create 900
+ * node bucketB-rebelwalls-roll-variants.js --apply --report data/bucketB/run-report.json
+ */
+const https = require('https');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { execFileSync } = require('child_process');
+
+const args = process.argv.slice(2);
+const APPLY = args.includes('--apply');
+const numArg = (flag, dflt) => { const i = args.indexOf(flag); return i >= 0 ? parseInt(args[i + 1], 10) : dflt; };
+const strArg = (flag, dflt) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : dflt; };
+const MAX_CREATE = numArg('--max-create', 900);
+const BATCH_SIZE = numArg('--batch-size', 100);
+const BATCH_GAP_MS = numArg('--batch-gap', 90000);
+const REPORT = strArg('--report', null);
+
+const TSV = path.join(os.homedir(), 'Projects/designerwallcoverings/today-viewer/data/bucketB-enriched.tsv');
+const TODAY = new Date().toISOString().slice(0, 10);
+
+const env = fs.readFileSync(path.join(os.homedir(), 'Projects/secrets-manager/.env'), 'utf8');
+const TOKEN = (env.match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/m) || [])[1].replace(/['"]/g, '').trim();
+const STORE = 'designer-laboratory-sandbox.myshopify.com', API = '2024-10';
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+function round2(n) { return Math.round((n + Number.EPSILON) * 100) / 100; }
+
+// ---- dw_unified write (PG-first) ------------------------------------------
+function pgWriteRollPrice(rows) {
+ if (!rows.length) return '0';
+ const values = rows.map(r =>
+ `('gid://shopify/Product/${r.shopify_id}', ${r.price})`).join(',\n');
+ const sql = `
+BEGIN;
+CREATE TEMP TABLE _bB_price(gid text, roll_price numeric);
+INSERT INTO _bB_price(gid, roll_price) VALUES
+${values};
+UPDATE shopify_products sp
+ SET retail_price = b.roll_price
+ FROM _bB_price b
+ WHERE sp.shopify_id = b.gid;
+SELECT count(*) AS updated FROM _bB_price b JOIN shopify_products sp ON sp.shopify_id=b.gid;
+COMMIT;`;
+ const out = execFileSync('ssh', ['my-server',
+ `sudo -u postgres psql -d dw_unified -v ON_ERROR_STOP=1 -t -A`],
+ { input: sql, encoding: 'utf8', maxBuffer: 1 << 24 });
+ return out.trim();
+}
+
+// ---- Shopify GraphQL -------------------------------------------------------
+function gql(query, variables = {}) {
+ return new Promise((resolve, reject) => {
+ const body = JSON.stringify({ query, variables });
+ const req = https.request({ host: STORE, path: `/admin/api/${API}/graphql.json`, method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': TOKEN, 'Content-Length': Buffer.byteLength(body) } },
+ r => { let d = ''; r.on('data', c => d += c); r.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(d); } }); });
+ req.on('error', reject); req.write(body); req.end();
+ });
+}
+async function gqlR(q, v, tries = 4) {
+ for (let i = 0; i < tries; i++) {
+ const r = await gql(q, v);
+ if (r && !r.errors) return r;
+ const throttled = (r.errors || []).some(e => /throttl/i.test(JSON.stringify(e)));
+ if (!throttled) return r;
+ await sleep(1500 * (i + 1));
+ }
+ return gql(q, v);
+}
+
+const Q_PRODUCT = `query($id:ID!){product(id:$id){id title status tags
+ options{name values}
+ variants(first:30){nodes{id title sku selectedOptions{name value} inventoryItem{id}}}}}`;
+const M_VAR_CREATE = `mutation($pid:ID!,$variants:[ProductVariantsBulkInput!]!){
+ productVariantsBulkCreate(productId:$pid,variants:$variants){
+ productVariants{id sku} userErrors{field message}}}`;
+const M_REORDER = `mutation($pid:ID!,$positions:[ProductVariantPositionInput!]!){
+ productVariantsBulkReorder(productId:$pid,positions:$positions){userErrors{field message}}}`;
+const M_MF = `mutation($mf:[MetafieldsSetInput!]!){metafieldsSet(metafields:$mf){userErrors{field message}}}`;
+const M_TAGS = `mutation($id:ID!,$tags:[String!]!){tagsAdd(id:$id,tags:$tags){userErrors{field message}}}`;
+
+// ---- worklist (enriched) ---------------------------------------------------
+// cols: shopify_id base_sku sample_sku mfr_sku roll_price price_source cat_width cat_width_in cat_image cat_discontinued
+function loadWork() {
+ const lines = fs.readFileSync(TSV, 'utf8').replace(/\n$/, '').split('\n');
+ return lines.map(l => {
+ const c = l.split('\t');
+ return {
+ shopify_id: (c[0] || '').trim(), base_sku: (c[1] || '').trim(),
+ sample_sku: (c[2] || '').trim(), mfr_sku: (c[3] || '').trim(),
+ roll_price: c[4] ? parseFloat(c[4]) : null, price_source: (c[5] || '').trim(),
+ cat_width: (c[6] || '').trim(), cat_width_in: (c[7] || '').trim(),
+ cat_image: (c[8] || '').trim(), cat_disc: (c[9] || '').trim() === 't',
+ };
+ }).filter(r => r.shopify_id && /^\d+$/.test(r.shopify_id));
+}
+
+(async () => {
+ const rows = loadWork();
+ const priced = rows.filter(r => r.price_source.startsWith('catalog') && r.roll_price > 0);
+ const unpriced = rows.filter(r => !(r.price_source.startsWith('catalog') && r.roll_price > 0));
+ console.log(`bucketB-rebelwalls — ${rows.length} products (${priced.length} priced, ${unpriced.length} no-price) — ${APPLY ? '🔴 LIVE' : 'DRY-RUN'}`);
+ console.log(`max-create=${MAX_CREATE} batch-size=${BATCH_SIZE} batch-gap=${BATCH_GAP_MS}ms\n`);
+
+ const report = { started: new Date().toISOString(), apply: APPLY, scrapedFresh: 0,
+ created: [], skippedAlreadyRoll: [], taggedNeedsPrice: [], failed: [],
+ byPriceSource: {}, cohort: { older: 0, new360k: 0, nonRW: 0 }, geminiCostUsd: 0 };
+ for (const r of rows) {
+ const n = parseInt((r.base_sku.match(/DWRW-(\d+)/) || [])[1], 10);
+ if (/^DWRW-/.test(r.base_sku)) { (n >= 360000 && n <= 364132) ? report.cohort.new360k++ : report.cohort.older++; }
+ else report.cohort.nonRW++;
+ }
+
+ // ---- PG-FIRST: write recovered roll prices to dw_unified before any Shopify write ----
+ if (APPLY && priced.length) {
+ try {
+ const res = pgWriteRollPrice(priced.map(w => ({ shopify_id: w.shopify_id, price: round2(w.roll_price) })));
+ console.log(`PG (dw_unified) retail_price written — psql updated count: ${res}\n`);
+ report.pgUpdated = res;
+ } catch (e) {
+ console.error('PG write FAILED — aborting before Shopify:', e.message || e);
+ process.exit(2);
+ }
+ } else if (priced.length) {
+ console.log(`(dry-run) would write ${priced.length} retail_price rows to dw_unified first\n`);
+ }
+
+ // ---- Shopify: add ROLL variant to each priced product ----
+ let createdThisRun = 0, inBatch = 0;
+ for (const w of priced) {
+ if (createdThisRun >= MAX_CREATE) { console.log(`\n⛔ hit --max-create ${MAX_CREATE}; remaining priced rows deferred to next run (resumable)`); break; }
+ const pid = `gid://shopify/Product/${w.shopify_id}`;
+ const pr = await gqlR(Q_PRODUCT, { id: pid });
+ const p = pr.data && pr.data.product;
+ if (!p) { console.log(`❌ ${w.shopify_id} ${w.base_sku} not found`); report.failed.push({ ...w, reason: 'not-found' }); continue; }
+
+ const vs = p.variants.nodes;
+ const sample = vs.find(v => /sample/i.test(v.title) || /-sample$/i.test(v.sku || ''));
+ const roll = vs.find(v => v !== sample);
+ if (roll) { console.log(`⏭ ${p.title.slice(0,46)} already has roll (${roll.sku})`); report.skippedAlreadyRoll.push({ ...w, existingRollSku: roll.sku }); continue; }
+
+ // SKU: prefer existing sample sku minus -Sample (authoritative), else base_sku. Never Unknown.
+ let skuBase = (sample && sample.sku) ? sample.sku.replace(/-sample$/i, '') : w.base_sku;
+ if (!skuBase) { console.log(`❌ ${w.shopify_id} — no derivable SKU — SKIP`); report.failed.push({ ...w, reason: 'no-sku' }); continue; }
+ const price = round2(w.roll_price);
+
+ console.log(`${APPLY ? '➕' : '·'} ${p.title.slice(0,44).padEnd(44)} [${p.status}] roll $${price} (${w.price_source}) sku ${skuBase}`);
+ report.byPriceSource[w.price_source] = (report.byPriceSource[w.price_source] || 0) + 1;
+ if (!APPLY) { report.created.push({ ...w, skuBase, price, dryRun: true }); continue; }
+
+ const cr = await gqlR(M_VAR_CREATE, { pid, variants: [{
+ optionValues: [{ optionName: (p.options[0] && p.options[0].name) || 'Title', name: 'Single Roll' }],
+ price: String(price), taxable: true, inventoryPolicy: 'CONTINUE',
+ inventoryItem: { sku: skuBase, tracked: false },
+ }] });
+ const ue = cr.data?.productVariantsBulkCreate?.userErrors || [];
+ if (ue.length) { console.log(` ❌ create: ${JSON.stringify(ue)}`); report.failed.push({ ...w, skuBase, reason: 'create-error', errors: ue }); continue; }
+ const newId = cr.data.productVariantsBulkCreate.productVariants[0].id;
+
+ if (sample) {
+ const ro = await gqlR(M_REORDER, { pid, positions: [{ id: newId, position: 1 }, { id: sample.id, position: 2 }] });
+ const re = ro.data?.productVariantsBulkReorder?.userErrors || [];
+ if (re.length) console.log(` ⚠ reorder: ${JSON.stringify(re)}`);
+ }
+
+ const mf = await gqlR(M_MF, { mf: [
+ { ownerId: pid, namespace: 'custom', key: 'manufacturer_sku', type: 'single_line_text_field', value: w.mfr_sku || skuBase },
+ { ownerId: pid, namespace: 'dwc', key: 'manufacturer_sku', type: 'single_line_text_field', value: w.mfr_sku || skuBase },
+ { ownerId: pid, namespace: 'custom', key: 'price_updated_at', type: 'date', value: TODAY },
+ { ownerId: pid, namespace: 'global', key: 'unit_of_measure', type: 'single_line_text_field', value: 'Priced Per Single Roll' },
+ ] });
+ const me = mf.data?.metafieldsSet?.userErrors || [];
+ if (me.length) console.log(` ⚠ metafields: ${JSON.stringify(me)}`);
+
+ report.created.push({ ...w, skuBase, price, variantId: newId });
+ createdThisRun++; inBatch++;
+ if (inBatch >= BATCH_SIZE) { console.log(` …batch of ${inBatch} done — sleeping ${BATCH_GAP_MS/1000}s (≥90s bulk gap)`); await sleep(BATCH_GAP_MS); inBatch = 0; }
+ else await sleep(200);
+ }
+
+ // ---- unpriced: tag Needs-Price (+ Needs-Width/Needs-Image where catalog lacked them) ----
+ console.log(`\n--- tagging ${unpriced.length} unpriced rows (Needs-Price) ---`);
+ for (const w of unpriced) {
+ const tags = ['Needs-Price'];
+ if (!w.cat_width) tags.push('Needs-Width');
+ if (!w.cat_image) tags.push('Needs-Image');
+ if (!APPLY) { report.taggedNeedsPrice.push({ ...w, tags, dryRun: true }); continue; }
+ const pid = `gid://shopify/Product/${w.shopify_id}`;
+ const tr = await gqlR(M_TAGS, { id: pid, tags });
+ const te = tr.data?.tagsAdd?.userErrors || [];
+ if (te.length) { console.log(` ⚠ tag ${w.base_sku}: ${JSON.stringify(te)}`); report.failed.push({ ...w, reason: 'tag-error', errors: te }); continue; }
+ report.taggedNeedsPrice.push({ ...w, tags });
+ await sleep(80);
+ }
+
+ report.finished = new Date().toISOString();
+ report.totals = {
+ rollVariantsCreated: report.created.filter(c => !c.dryRun).length,
+ rollVariantsWouldCreate: report.created.filter(c => c.dryRun).length,
+ stayedSampleOnly_NeedsPrice: report.taggedNeedsPrice.length,
+ skippedAlreadyHadRoll: report.skippedAlreadyRoll.length,
+ scrapedFresh: report.scrapedFresh,
+ failed: report.failed.length,
+ };
+ console.log(`\n=== SUMMARY ===`);
+ console.log(JSON.stringify(report.totals, null, 2));
+ console.log('byPriceSource:', JSON.stringify(report.byPriceSource));
+ console.log('cohort:', JSON.stringify(report.cohort));
+ if (REPORT) { fs.mkdirSync(path.dirname(REPORT), { recursive: true }); fs.writeFileSync(REPORT, JSON.stringify(report, null, 2)); console.log(`report -> ${REPORT}`); }
+})();
← 837f4d8c Transform collection filters from vertical sidebar to horizo
·
back to Designer Wallcoverings
·
Add complete build summary for collection filter UI transfor f92e94ea →