[object Object]

← back to Marketing Command Center

All Calendars: add Launched layer — already-live products on their launch date, own modal, v1.2.0

063dc2871a5e4983c0ef49523a648f642a55bc1f · 2026-07-16 09:25:20 -0700 · Steve Abrams

New 4th layer 'launched' (slate #3f5a73): products ACTIVE + online_store_published with
created_at_shopify in the last 30 days, placed on their real launch date (past-side companion
to the future-side staged/activation layer). Reuses the same enrichment via new shared
fieldsFrom() helper — carries vendor/material/price/color/style/book/hue, so the left-panel
facets filter it too. Launched chips are NOT draggable (historical) and open their OWN modal
(live product, Launched date, Shopify Admin + View-on-site links, no reschedule).

Verified local: 2,570 launched items over the window, render as live-bordered chips on June
days, legend toggles the layer, launched modal correct (tag/no-reschedule/live-links), 0 console
errors across the render. Ships live on the next (gated) MCC deploy alongside the DB-env fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit 063dc2871a5e4983c0ef49523a648f642a55bc1f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 16 09:25:20 2026 -0700

    All Calendars: add Launched layer — already-live products on their launch date, own modal, v1.2.0
    
    New 4th layer 'launched' (slate #3f5a73): products ACTIVE + online_store_published with
    created_at_shopify in the last 30 days, placed on their real launch date (past-side companion
    to the future-side staged/activation layer). Reuses the same enrichment via new shared
    fieldsFrom() helper — carries vendor/material/price/color/style/book/hue, so the left-panel
    facets filter it too. Launched chips are NOT draggable (historical) and open their OWN modal
    (live product, Launched date, Shopify Admin + View-on-site links, no reschedule).
    
    Verified local: 2,570 launched items over the window, render as live-bordered chips on June
    days, legend toggles the layer, launched modal correct (tag/no-reschedule/live-links), 0 console
    errors across the render. Ships live on the next (gated) MCC deploy alongside the DB-env fix.
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 modules/calendars/lib.js     | 63 +++++++++++++++++++++++++++++++++++---------
 package-lock.json            |  4 +--
 package.json                 |  2 +-
 public/panels/calendars.html |  5 +++-
 public/panels/calendars.js   | 42 +++++++++++++++++++++--------
 5 files changed, 89 insertions(+), 27 deletions(-)

diff --git a/modules/calendars/lib.js b/modules/calendars/lib.js
index 61a299e..332b948 100644
--- a/modules/calendars/lib.js
+++ b/modules/calendars/lib.js
@@ -51,6 +51,20 @@ const RR_SQL = `
   ) t
   ORDER BY rr, vendor, dw_sku`;
 
+// Products that already LAUNCHED (live on the storefront) in the last 30 days,
+// placed on the calendar on their real launch date — the past-side companion to
+// the future-side activation layer. Read-only, bounded by created_at_shopify.
+const LAUNCHED_DAYS = 30;
+const LAUNCHED_SQL = `
+  SELECT shopify_id, vendor, product_type, retail_price, min_variant_price, price,
+         tags, metafields, pattern_name, title, image_url, handle, dw_sku, mfr_sku,
+         created_at_shopify
+    FROM shopify_products
+   WHERE status = 'ACTIVE' AND online_store_published = true
+     AND created_at_shopify IS NOT NULL
+     AND created_at_shopify >= now() - ($1 || ' days')::interval
+   ORDER BY created_at_shopify DESC`;
+
 // which hourly :10 slot a within-day position lands in (0-based pos within the day)
 function slotHourForPos(pos) {
   let cum = 0;
@@ -89,10 +103,27 @@ function priceBand(p) {
   if (p < 500) return '$200–500'; if (p < 1000) return '$500–1,000'; return '$1,000+';
 }
 
+// Map a shopify_products row → the common filterable fields (shared by the
+// activation layer's enrichment join AND the launched layer's direct rows).
+function fieldsFrom(e) {
+  const tags = parseTags(e.tags);
+  const price = e.retail_price ?? e.min_variant_price ?? e.price ?? null;
+  return {
+    material: e.product_type || mfVal(e.metafields, 'Type1') || '',
+    price: price != null ? Number(price) : null,
+    priceBand: priceBand(price != null ? Number(price) : null),
+    color: tagVal(tags, 'color', 'colour') || '',
+    style: tagVal(tags, 'style') || mfVal(e.metafields, 'Style2') || '',
+    book: tagVal(tags, 'collection') || e.pattern_name || mfVal(e.metafields, 'Series') || '',
+    hue: tagVal(tags, 'hue') || '',
+    title: e.title || '', image_url: e.image_url || '', handle: e.handle || '',
+  };
+}
+
 // ── build the enriched activation layer from live dw_unified ─────────────────
 async function computeActivation() {
   const c = await pool().connect();
-  let rows, enrich = {};
+  let rows, enrich = {}, launchedRows = [];
   try {
     rows = (await c.query(RR_SQL)).rows;
     // enrich in chunks — one round-trip per 5k shopify_ids
@@ -105,6 +136,8 @@ async function computeActivation() {
            FROM shopify_products WHERE shopify_id = ANY($1::text[])`, [chunk]);
       for (const r of q.rows) enrich[r.shopify_id] = r;
     }
+    // already-launched products (last 30 days) — the past-side layer
+    launchedRows = (await c.query(LAUNCHED_SQL, [String(LAUNCHED_DAYS)])).rows;
   } finally { c.release(); }
 
   const overrides = store.readJson(OVERRIDES_FILE, {}) || {};
@@ -124,8 +157,6 @@ async function computeActivation() {
       dt = new Date(Y, M - 1, D, slot, SLOT_MIN, 0, 0);
     }
     const e = enrich[r.shopify_id] || {};
-    const tags = parseTags(e.tags);
-    const price = e.retail_price ?? e.min_variant_price ?? e.price ?? null;
     items[seq] = {
       key: r.shopify_id || r.dw_sku,
       layer: 'activation',
@@ -134,23 +165,31 @@ async function computeActivation() {
       slot_hour: slot,
       vendor: e.vendor || r.vendor || '',            // prefer sanitized storefront vendor
       dw_sku: r.dw_sku || '', mfr_sku: r.mfr_sku || '', fix_type: r.fix_type,
-      material: e.product_type || mfVal(e.metafields, 'Type1') || '',
-      price: price != null ? Number(price) : null,
-      priceBand: priceBand(price != null ? Number(price) : null),
-      color: tagVal(tags, 'color', 'colour') || '',
-      style: tagVal(tags, 'style') || mfVal(e.metafields, 'Style2') || '',
-      book: tagVal(tags, 'collection') || e.pattern_name || mfVal(e.metafields, 'Series') || '',
-      hue: tagVal(tags, 'hue') || '',
-      title: e.title || '', image_url: e.image_url || '', handle: e.handle || '',
+      ...fieldsFrom(e),
       shopify_id: r.shopify_id || '',
     };
   }
+
+  // launched layer — one item per already-live product, on its launch date
+  const launched = launchedRows.map(r => {
+    const d = new Date(r.created_at_shopify);
+    return {
+      key: r.shopify_id, layer: 'launched',
+      date: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`,
+      launched_at: (r.created_at_shopify instanceof Date ? r.created_at_shopify.toISOString() : String(r.created_at_shopify)),
+      vendor: r.vendor || '', dw_sku: r.dw_sku || '', mfr_sku: r.mfr_sku || '',
+      ...fieldsFrom(r),
+      shopify_id: r.shopify_id,
+    };
+  });
   const out = {
     generated_at: new Date().toISOString(),
     total, per_day: PER_DAY,
     start_date: items[0]?.date || null,
     end_date: items[total - 1]?.date || null,
     items,
+    launched,                          // already-live products (last 30 days)
+    launched_count: launched.length,
   };
   store.writeJson(CACHE_FILE, out);
   return out;
@@ -216,7 +255,7 @@ async function buildEvents(force) {
     return act.end_date && act.end_date > cand ? act.end_date : cand;
   })();
   const over = overlays(winStart, winEnd);
-  const items = [...(act.items || []), ...over];
+  const items = [...(act.items || []), ...(act.launched || []), ...over];
   const counts = items.reduce((a, it) => { a[it.layer] = (a[it.layer] || 0) + 1; return a; }, {});
   return {
     generated_at: act.generated_at,
diff --git a/package-lock.json b/package-lock.json
index cb34005..3e63c11 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "marketing-command-center",
-  "version": "1.1.0",
+  "version": "1.2.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "marketing-command-center",
-      "version": "1.1.0",
+      "version": "1.2.0",
       "dependencies": {
         "express": "^4.22.2",
         "pg": "^8.22.0"
diff --git a/package.json b/package.json
index 9c14a53..049ae23 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "marketing-command-center",
-  "version": "1.1.0",
+  "version": "1.2.0",
   "description": "DW Marketing Command Center — Constant Contact, marketing calendar, suggested copy, on-demand layouts",
   "main": "server.js",
   "scripts": {
diff --git a/public/panels/calendars.html b/public/panels/calendars.html
index 6617aa8..3018b5d 100644
--- a/public/panels/calendars.html
+++ b/public/panels/calendars.html
@@ -90,7 +90,9 @@
 #cwroot .cw-chip .cw-ph{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:calc(9px*var(--cwfs));font-weight:700;color:#fff}
 #cwroot .cw-chip .cw-vn{position:absolute;left:0;right:0;bottom:0;font-size:7px;line-height:1.15;background:rgba(31,27,23,.72);color:#f3ead7;padding:2px 3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;letter-spacing:.2px}
 #cwroot .cw-chip .cw-fx{position:absolute;top:2px;left:2px;width:6px;height:6px;border-radius:50%}
-#cwroot .cw-chip .cw-fx.s{background:var(--green)}#cwroot .cw-chip .cw-fx.r{background:var(--brass)}
+#cwroot .cw-chip .cw-fx.s{background:var(--green)}#cwroot .cw-chip .cw-fx.r{background:var(--brass)}#cwroot .cw-chip .cw-fx.l{background:#3f5a73}
+#cwroot .cw-chip.cw-chip-live{box-shadow:inset 0 0 0 2px #3f5a7355}
+#cwroot .cw-chip.cw-chip-live:hover{border-color:#3f5a73;box-shadow:0 0 0 1px #3f5a73}
 #cwroot.cw-noimg .cw-chip img{display:none}
 #cwroot.cw-noimg .cw-chip{display:flex;align-items:center;justify-content:center}
 #cwroot.cw-novend .cw-chip .cw-vn{display:none}
@@ -119,6 +121,7 @@
 #cwroot .cw-tag.marketing{background:var(--cb-brass);color:#7A571C}
 #cwroot .cw-tag.campaign{background:var(--cb-plum);color:#6E2A40}
 #cwroot .cw-tag.google{background:var(--cb-green);color:#2F5C43}
+#cwroot .cw-tag.launched{background:#e2e8ef;color:#3f5a73}
 #cwroot .cw-resched{display:flex;align-items:center;gap:8px;margin:12px 0;padding-top:12px;border-top:1px solid var(--hairline)}
 #cwroot .cw-resched input[type=date]{font:inherit;font-size:13px;border:1px solid var(--hairline);border-radius:8px;padding:8px 10px;background:var(--surface);color:var(--ink)}
 #cwroot .cw-cta{background:var(--plum);color:#fff;border:none;font-weight:600;font-size:13px;padding:9px 16px;border-radius:8px;cursor:pointer}
diff --git a/public/panels/calendars.js b/public/panels/calendars.js
index 86b632d..dbcdcc0 100644
--- a/public/panels/calendars.js
+++ b/public/panels/calendars.js
@@ -23,7 +23,8 @@ window.MCC_PANELS = window.MCC_PANELS || {};
   FIELDS.forEach(([k]) => FIL[k] = '');
   const FCAP = 40;
   const LAYER_META = {
-    activation: { label: 'Activation (SKUs)', color: '#B0894F' },
+    activation: { label: 'Staged (going live)', color: '#B0894F' },
+    launched: { label: 'Launched (live)', color: '#3f5a73' },
     marketing: { label: 'Marketing dates', color: '#7A571C' },
     campaign: { label: 'Campaigns', color: '#6E2A40' },
     google: { label: 'Google', color: '#3E7C5A' },
@@ -127,8 +128,9 @@ window.MCC_PANELS = window.MCC_PANELS || {};
     for (let dn = 1; dn <= days; dn++) {
       const iso = `${y}-${pad(m + 1)}-${pad(dn)}`;
       const list = byDate[iso] || [];
-      const acts = list.filter(x => x.layer === 'activation');
-      const bars = list.filter(x => x.layer !== 'activation');
+      const chipItems = list.filter(x => x.layer === 'activation' || x.layer === 'launched');
+      const acts = chipItems.filter(x => x.layer === 'activation');   // slot-time label is activation-only
+      const bars = list.filter(x => x.layer === 'marketing' || x.layer === 'campaign' || x.layer === 'google');
       const cell = document.createElement('div'); cell.className = 'cw-cell' + (iso === todayISO ? ' today' : '');
       let hrTxt = '';
       if (acts.length) { const hrs = acts.map(x => x.slot_hour).filter(h => h != null); if (hrs.length) hrTxt = ` · ${slotLabel(Math.min(...hrs))}–${slotLabel(Math.max(...hrs))}`; }
@@ -153,14 +155,17 @@ window.MCC_PANELS = window.MCC_PANELS || {};
         }
         cell.appendChild(bar);
       });
-      // activation image chips
-      if (acts.length) {
+      // image chips — staged (activation, draggable) + launched (live, not draggable)
+      if (chipItems.length) {
         const chips = document.createElement('div'); chips.className = 'cw-chips';
-        acts.forEach(s => {
-          const el = document.createElement('div'); el.className = 'cw-chip'; el.tabIndex = 0;
-          const fx = s.fix_type === 'add-sample' ? 's' : 'r';
+        chipItems.forEach(s => {
+          const isLaunched = s.layer === 'launched';
+          const el = document.createElement('div'); el.className = 'cw-chip' + (isLaunched ? ' cw-chip-live' : ''); el.tabIndex = 0;
+          const fx = isLaunched ? 'l' : (s.fix_type === 'add-sample' ? 's' : 'r');
           const abbr = (s.vendor || '···').slice(0, 3).toUpperCase();
-          el.title = `${skuLabel(s)} · ${s.vendor}${s.title ? ' · ' + s.title : ''} · ${s.fix_type}\n⏱ ${fmtGoLive(s.go_live_at)}`;
+          el.title = isLaunched
+            ? `${skuLabel(s)} · ${s.vendor}${s.title ? ' · ' + s.title : ''}\n✅ launched ${fmtGoLive(s.launched_at)}`
+            : `${skuLabel(s)} · ${s.vendor}${s.title ? ' · ' + s.title : ''} · ${s.fix_type}\n⏱ ${fmtGoLive(s.go_live_at)}`;
           const ph = `<div class="cw-ph" style="background:${vcolor(s.vendor)}">${esc(abbr)}</div>`;
           el.innerHTML = `<span class="cw-fx ${fx}"></span>` +
             (s.image_url && /^https?:/.test(s.image_url)
@@ -168,12 +173,12 @@ window.MCC_PANELS = window.MCC_PANELS || {};
             `<span class="cw-vn">${esc(s.vendor || '')}</span>`;
           el.onclick = () => openItemModal(s);
           el.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openItemModal(s); } };
-          if (s.key) { el.draggable = true; el.addEventListener('dragstart', ev => { ev.dataTransfer.setData('text/plain', JSON.stringify({ key: s.key, from: iso, layer: 'activation', label: skuLabel(s) })); ev.dataTransfer.effectAllowed = 'move'; el.classList.add('dragging'); }); el.addEventListener('dragend', () => el.classList.remove('dragging')); }
+          if (!isLaunched && s.key) { el.draggable = true; el.addEventListener('dragstart', ev => { ev.dataTransfer.setData('text/plain', JSON.stringify({ key: s.key, from: iso, layer: 'activation', label: skuLabel(s) })); ev.dataTransfer.effectAllowed = 'move'; el.classList.add('dragging'); }); el.addEventListener('dragend', () => el.classList.remove('dragging')); }
           chips.appendChild(el);
         });
         cell.appendChild(chips);
         const more = document.createElement('button'); more.type = 'button'; more.className = 'cw-more';
-        const closed = '▾ show all ' + acts.length; more.textContent = closed;
+        const closed = '▾ show all ' + chipItems.length; more.textContent = closed;
         more.onclick = () => { const open = cell.classList.toggle('expanded'); more.textContent = open ? '▴ collapse' : closed; };
         cell.appendChild(more);
         expanders.push({ chips, more });
@@ -210,6 +215,21 @@ window.MCC_PANELS = window.MCC_PANELS || {};
         </dl>
         <div class="cw-resched"><label style="margin:0">Reschedule</label><input type="date" id="cwDate" value="${esc((it.go_live_at || it.date || '').slice(0, 10))}"><button class="cw-cta" id="cwMove">Move</button></div>
         <div class="cw-links">${adminUrl ? `<a href="${adminUrl}" target="_blank" rel="noopener noreferrer">Shopify Admin ↗</a>` : ''}${siteUrl ? `<a href="${siteUrl}" target="_blank" rel="noopener noreferrer" title="staged items 404 until active">View on site ↗</a>` : ''}</div>`;
+    } else if (it.layer === 'launched') {
+      const idNum = it.shopify_id ? String(it.shopify_id).split('/').pop() : null;
+      const adminUrl = idNum ? 'https://admin.shopify.com/store/designer-laboratory-sandbox/products/' + idNum : null;
+      const siteUrl = it.handle ? 'https://www.designerwallcoverings.com/products/' + encodeURIComponent(it.handle) : null;
+      const hero = it.image_url && /^https?:/.test(it.image_url)
+        ? `<img src="${esc(it.image_url)}" alt="">` : `<div class="cw-ph" style="background:${vcolor(it.vendor)}">${esc((it.vendor || '···').slice(0, 3).toUpperCase())}</div>`;
+      inner = `<div class="cw-mhead"><div class="cw-mtitle">${esc(it.title || skuLabel(it))}</div><button class="cw-mclose" id="cwMX">✕</button></div>
+        <div class="cw-hero">${hero}</div>
+        <span class="cw-tag launched">launched · live</span>
+        <dl class="cw-dl" style="margin-top:12px">
+          ${kv('Vendor', esc(it.vendor))}${it.dw_sku ? kv('DW SKU', `<code>${esc(it.dw_sku)}</code>`) : ''}${it.mfr_sku ? kv('Mfr SKU', `<code>${esc(it.mfr_sku)}</code>`) : ''}
+          ${kv('Material', esc(it.material))}${it.price != null ? kv('Price', '$' + Number(it.price).toLocaleString()) : ''}${kv('Color', esc(it.color))}${kv('Style', esc(it.style))}${kv('Book', esc(it.book))}
+          ${kv('Launched', '✅ ' + fmtGoLive(it.launched_at))}
+        </dl>
+        <div class="cw-links">${adminUrl ? `<a href="${adminUrl}" target="_blank" rel="noopener noreferrer">Shopify Admin ↗</a>` : ''}${siteUrl ? `<a href="${siteUrl}" target="_blank" rel="noopener noreferrer">View on site ↗</a>` : ''}</div>`;
     } else if (it.layer === 'campaign') {
       inner = `<div class="cw-mhead"><div class="cw-mtitle">${esc(it.title)}</div><button class="cw-mclose" id="cwMX">✕</button></div>
         <span class="cw-tag campaign">campaign</span>

← d28f0d1 PR naturals mailer: SISAL search link to page 1 (verified 10  ·  back to Marketing Command Center  ·  linkedin panel: add Follow list mode (ported from li-clickli e347c43 →