← 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
M modules/calendars/lib.jsM package-lock.jsonM package.jsonM public/panels/calendars.htmlM public/panels/calendars.js
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 →