← back to Marketing Command Center
All Calendars: front-end panel (facet rail + multi-layer month grid + drag reschedule)
c3e1ea58a791a52c4e1419f9611d178d9df28be3 · 2026-07-15 16:33:59 -0700 · Steve Abrams
public/panels/calendars.{html,js} — scoped under #cwroot (MCC style.css also defines
.grid/.card/.chip). Ports the astek left-panel field-toggle rail (search + type-ahead +
one collapsed table per field: vendor/material/price/color/style/book/hue/build-step/
channel/status/month, click-to-filter + count) and the dw-activation-calendar month grid
(image chips + slot times + drag-to-reschedule + detail modal), restyled to MCC's luxe
theme. Legend toggles each layer. Density slider scales chip size + type.
Verified headless (chromium): 42 cells, 8 facet tables, 12.3k chips render, vendor filter
20,356→4,394, zero console errors. Gitignores the 13MB regenerable snapshot.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
M .gitignoreM data/calendars-activation.jsonA public/panels/calendars.htmlA public/panels/calendars.js
Diff
commit c3e1ea58a791a52c4e1419f9611d178d9df28be3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jul 15 16:33:59 2026 -0700
All Calendars: front-end panel (facet rail + multi-layer month grid + drag reschedule)
public/panels/calendars.{html,js} — scoped under #cwroot (MCC style.css also defines
.grid/.card/.chip). Ports the astek left-panel field-toggle rail (search + type-ahead +
one collapsed table per field: vendor/material/price/color/style/book/hue/build-step/
channel/status/month, click-to-filter + count) and the dw-activation-calendar month grid
(image chips + slot times + drag-to-reschedule + detail modal), restyled to MCC's luxe
theme. Legend toggles each layer. Density slider scales chip size + type.
Verified headless (chromium): 42 cells, 8 facet tables, 12.3k chips render, vendor filter
20,356→4,394, zero console errors. Gitignores the 13MB regenerable snapshot.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
.gitignore | 4 +
data/calendars-activation.json | 4 +-
public/panels/calendars.html | 171 +++++++++++++++++++++++++
public/panels/calendars.js | 282 +++++++++++++++++++++++++++++++++++++++++
4 files changed, 459 insertions(+), 2 deletions(-)
diff --git a/.gitignore b/.gitignore
index 003d749..d8d1b8b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,3 +36,7 @@ data/meta-token-health*
# Connie cc-agent proxy creds (CC_AGENT_*) — never commit the password
.cc-agent.json
+
+# All Calendars — regenerable projection snapshot (13MB) + runtime drag overrides
+data/calendars-activation.json
+data/calendars-overrides.json
diff --git a/data/calendars-activation.json b/data/calendars-activation.json
index 6c97258..abd770e 100644
--- a/data/calendars-activation.json
+++ b/data/calendars-activation.json
@@ -8,8 +8,8 @@
{
"key": "gid://shopify/Product/7815364182067",
"layer": "activation",
- "date": "2026-07-16",
- "go_live_at": "2026-07-16T07:10:00.000Z",
+ "date": "2026-08-01",
+ "go_live_at": "2026-08-01T07:10:00.000Z",
"slot_hour": 0,
"vendor": "Andrew Martin",
"dw_sku": "",
diff --git a/public/panels/calendars.html b/public/panels/calendars.html
new file mode 100644
index 0000000..6617aa8
--- /dev/null
+++ b/public/panels/calendars.html
@@ -0,0 +1,171 @@
+<!-- All Calendars — unified multi-layer calendar with a left-panel field-filter
+ rail. All markup + styles scoped under #cwroot so nothing bleeds into (or
+ inherits from) the MCC shell, whose style.css also defines .grid/.card/.chip. -->
+<div id="cwroot">
+<style>
+#cwroot{
+ --ground:#FAF8F3;--surface:#fff;--ink:#1F1B17;--muted:#8A8178;--faint:#C2BBAE;
+ --hairline:#E7E1D6;--plum:#5E2B3C;--brass:#B0894F;--green:#3E7C5A;--acc:#B0894F;
+ --cb-brass:#F4ECDD;--cb-plum:#F3E7EC;--cb-green:#E8F0EA;
+ --cwchip:56px;--cwfs:1;
+ color:var(--ink);
+}
+#cwroot *{box-sizing:border-box}
+/* ── top toolbar ── */
+#cwroot .cw-top{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:10px}
+#cwroot .cw-month{display:flex;align-items:center;gap:6px}
+#cwroot .cw-nav{background:none;border:none;color:var(--muted);font-size:22px;line-height:1;padding:2px 8px;border-radius:8px;cursor:pointer}
+#cwroot .cw-nav:hover{color:var(--ink);background:var(--ground)}
+#cwroot .cw-ml{font:600 22px/1 "Cormorant Garamond",Georgia,serif;min-width:180px;letter-spacing:.2px}
+#cwroot .cw-today,#cwroot .cw-btn{background:var(--surface);border:1px solid var(--hairline);color:var(--ink);font-size:13px;font-weight:500;padding:7px 14px;border-radius:999px;cursor:pointer}
+#cwroot .cw-today:hover,#cwroot .cw-btn:hover{border-color:var(--brass)}
+#cwroot .cw-spacer{flex:1}
+#cwroot .cw-count{font-size:12px;color:var(--muted)}
+#cwroot .cw-ctl{display:flex;align-items:center;gap:7px;font-size:11px;color:var(--muted)}
+#cwroot input[type=range]{accent-color:var(--brass)}
+#cwroot .cw-live{font-size:11px;color:var(--muted)}
+/* ── legend ── */
+#cwroot .cw-legend{display:flex;gap:14px;flex-wrap:wrap;font-size:11px;color:var(--muted);margin-bottom:12px}
+#cwroot .cw-legend .li{display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none}
+#cwroot .cw-legend .li.off{opacity:.38;text-decoration:line-through}
+#cwroot .cw-legend .sw{width:11px;height:11px;border-radius:3px;display:inline-block}
+/* ── layout: aside + calendar ── */
+#cwroot .cw-layout{display:flex;align-items:flex-start;gap:16px}
+#cwroot .cw-aside{position:sticky;top:8px;width:222px;min-width:222px;max-height:calc(100vh - 140px);overflow:auto;
+ padding:2px 8px 24px 0;border-right:1px solid var(--hairline)}
+#cwroot .cw-aside input[type=text]{width:100%;margin-bottom:7px;font:inherit;font-size:12.5px;padding:6px 9px;
+ border:1px solid var(--hairline);border-radius:8px;background:var(--surface);color:var(--ink)}
+#cwroot .cw-aside details{border-bottom:1px solid var(--hairline)}
+#cwroot .cw-aside summary{cursor:pointer;list-style:none;display:flex;align-items:center;gap:6px;padding:8px 4px;
+ font-size:10.5px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.6px;user-select:none}
+#cwroot .cw-aside summary::-webkit-details-marker{display:none}
+#cwroot .cw-aside summary::before{content:'▸';font-size:9px;color:var(--muted);transition:transform .12s}
+#cwroot .cw-aside details[open] summary::before{transform:rotate(90deg)}
+#cwroot .cw-aside summary:hover{color:var(--ink)}
+#cwroot .cw-aside summary .cur{margin-left:auto;color:var(--brass);text-transform:none;letter-spacing:0;font-weight:600;
+ max-width:96px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+#cwroot .cw-fbody{padding:2px 0 8px}
+#cwroot .cw-frow{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:3px 8px;border-radius:6px;
+ cursor:pointer;color:var(--muted);font-size:12.5px;user-select:none}
+#cwroot .cw-frow:hover{background:var(--ground);color:var(--ink)}
+#cwroot .cw-frow.active{background:var(--ground);color:var(--ink);outline:1px solid var(--brass);outline-offset:-1px}
+#cwroot .cw-frow .cw-fl{display:inline-flex;align-items:center;gap:7px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+#cwroot .cw-frow .n{font-variant-numeric:tabular-nums;font-size:11px;color:var(--muted);flex:none}
+#cwroot .cw-frow .dot{width:10px;height:10px;border-radius:3px;border:1px solid #0002;display:inline-block;flex:none}
+#cwroot .cw-frow input[type=checkbox]{accent-color:var(--brass);margin:0 2px 0 0}
+#cwroot .cw-fmore{padding:3px 8px;font-size:11px;color:var(--muted);font-style:italic}
+#cwroot .cw-clear{display:none;margin-top:12px;width:100%}
+#cwroot .cw-main{flex:1;min-width:0}
+/* ── calendar grid ── */
+#cwroot .cw-cal{border:1px solid var(--hairline);border-radius:14px;overflow:hidden;background:var(--surface)}
+#cwroot .cw-dow-row{display:grid;grid-template-columns:repeat(7,1fr);border-bottom:1px solid var(--hairline)}
+#cwroot .cw-dow{height:38px;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:1.5px}
+#cwroot .cw-grid{display:grid;grid-template-columns:repeat(7,1fr)}
+#cwroot .cw-cell{min-height:118px;border-right:1px solid var(--hairline);border-bottom:1px solid var(--hairline);padding:7px 8px;display:flex;flex-direction:column;gap:5px;overflow:hidden}
+#cwroot .cw-cell:nth-child(7n){border-right:none}
+#cwroot .cw-grid .cw-cell:nth-last-child(-n+7){border-bottom:none}
+#cwroot .cw-cell.out{background:var(--ground)}
+#cwroot .cw-cell.out .cw-dn{color:var(--faint)}
+#cwroot .cw-dh{display:flex;align-items:center;justify-content:space-between}
+#cwroot .cw-dn{font-size:13px;font-weight:500;color:var(--ink);width:26px;height:26px;display:flex;align-items:center;justify-content:center}
+#cwroot .cw-cell.today .cw-dn{background:var(--plum);color:#fff;border-radius:50%}
+#cwroot .cw-cnt{font-size:10px;font-weight:600;color:var(--brass)}
+#cwroot .cw-cell.drop-target{outline:2px dashed var(--brass);outline-offset:-3px;background:var(--cb-brass)}
+/* overlay bars (marketing / campaign / google) */
+#cwroot .cw-bar{display:block;width:100%;text-align:left;border:none;border-radius:6px;padding:3px 7px;font-size:10.5px;
+ line-height:1.3;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;cursor:pointer;color:inherit}
+#cwroot .cw-bar.marketing{background:var(--cb-brass);color:#7A571C}
+#cwroot .cw-bar.campaign{background:var(--cb-plum);color:#6E2A40;cursor:grab}
+#cwroot .cw-bar.google{background:var(--cb-green);color:#2F5C43}
+#cwroot .cw-bar:hover{filter:brightness(.97)}
+/* activation image chips */
+#cwroot .cw-chips{display:flex;flex-wrap:wrap;gap:5px;align-content:flex-start;max-height:calc(var(--cwchip)*3 + 12px);overflow:hidden}
+#cwroot .cw-cell.expanded .cw-chips{max-height:none;overflow:visible}
+#cwroot .cw-chip{position:relative;display:block;border-radius:6px;overflow:hidden;background:var(--ground);border:1px solid var(--hairline);
+ width:var(--cwchip);height:var(--cwchip);flex:0 0 auto;padding:0;cursor:grab}
+#cwroot .cw-chip:active{cursor:grabbing}
+#cwroot .cw-chip.dragging{opacity:.4}
+#cwroot .cw-chip:hover{border-color:var(--brass);box-shadow:0 0 0 1px var(--brass)}
+#cwroot .cw-chip img{width:100%;height:100%;object-fit:cover;display:block}
+#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-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}
+#cwroot .cw-more{margin-top:4px;align-self:stretch;font-size:10px;font-weight:600;letter-spacing:.4px;color:var(--muted);
+ background:var(--ground);border:1px dashed var(--hairline);border-radius:6px;padding:3px 6px;cursor:pointer}
+#cwroot .cw-more:hover{color:var(--ink);border-color:var(--faint)}
+#cwroot .cw-empty{padding:50px;text-align:center;color:var(--muted)}
+#cwroot .cw-loading{padding:50px;text-align:center;color:var(--muted)}
+/* ── modal ── */
+#cwroot .cw-modal-bd{position:fixed;inset:0;background:rgba(31,27,23,.30);backdrop-filter:blur(1px);z-index:60;display:flex;align-items:center;justify-content:center;padding:24px}
+#cwroot .cw-modal{background:var(--surface);border:1px solid var(--hairline);border-radius:16px;box-shadow:0 24px 64px rgba(31,27,23,.22);
+ width:420px;max-width:100%;max-height:88vh;overflow:auto;padding:20px 24px 24px}
+#cwroot .cw-mhead{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;margin-bottom:14px}
+#cwroot .cw-mtitle{font:600 21px/1.15 "Cormorant Garamond",Georgia,serif;color:var(--ink)}
+#cwroot .cw-mclose{width:30px;height:30px;border-radius:50%;background:var(--ground);color:var(--muted);font-size:16px;line-height:1;display:flex;align-items:center;justify-content:center;cursor:pointer;border:none;flex:none}
+#cwroot .cw-mclose:hover{background:var(--hairline);color:var(--ink)}
+#cwroot .cw-hero{width:100%;height:170px;border-radius:10px;overflow:hidden;margin-bottom:14px;background:var(--ground)}
+#cwroot .cw-hero img{width:100%;height:100%;object-fit:cover;display:block}
+#cwroot .cw-hero .cw-ph{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:24px;font-weight:700;color:#fff}
+#cwroot .cw-dl{display:grid;grid-template-columns:96px 1fr;gap:6px 12px;font-size:13px;margin-bottom:8px}
+#cwroot .cw-dl dt{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.5px}
+#cwroot .cw-dl dd{margin:0;color:var(--ink)}
+#cwroot .cw-dl code{font-family:ui-monospace,Menlo,monospace;font-size:12px}
+#cwroot .cw-tag{display:inline-block;font-size:11px;font-weight:700;border-radius:999px;padding:2px 10px;text-transform:capitalize}
+#cwroot .cw-tag.activation{background:var(--cb-brass);color:#7A571C}
+#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-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}
+#cwroot .cw-cta:hover{background:#4d2230}
+#cwroot .cw-links{display:flex;flex-wrap:wrap;gap:8px;margin-top:6px}
+#cwroot .cw-links a{text-decoration:none;color:var(--plum);border:1px solid var(--hairline);border-radius:8px;padding:6px 11px;font-size:12px}
+#cwroot .cw-links a:hover{background:var(--ground)}
+#cwroot .cw-note{font-size:12.5px;color:var(--muted);line-height:1.5}
+@media(max-width:860px){
+ #cwroot .cw-layout{display:block}
+ #cwroot .cw-aside{width:auto;min-width:0;max-height:none;border-right:none;border-bottom:1px solid var(--hairline);margin-bottom:12px;position:static}
+}
+</style>
+
+<div class="cw-top">
+ <div class="cw-month">
+ <button class="cw-nav" id="cwPrev" aria-label="Previous month">‹</button>
+ <span class="cw-ml" id="cwML">…</span>
+ <button class="cw-nav" id="cwNext" aria-label="Next month">›</button>
+ <button class="cw-today" id="cwToday">Today</button>
+ </div>
+ <span class="cw-spacer"></span>
+ <span class="cw-count" id="cwCount"></span>
+ <div class="cw-ctl"><label>Density</label><input type="range" id="cwDensity" min="3" max="10" step="1"></div>
+ <button class="cw-btn" id="cwRefresh" title="Re-project the activation schedule from live dw_unified">↻ Refresh</button>
+</div>
+<div class="cw-legend" id="cwLegend"></div>
+
+<div class="cw-layout">
+ <aside class="cw-aside" id="cwAside" aria-label="filters">
+ <input type="text" id="cwQ" placeholder="Search title / sku / vendor / color…">
+ <input type="text" id="cwPat" list="cwPatList" placeholder="Vendor type-ahead…">
+ <datalist id="cwPatList"></datalist>
+ <details id="cwFieldsBox"><summary>Chip display</summary>
+ <div class="cw-fbody">
+ <label class="cw-frow"><span class="cw-fl"><input type="checkbox" data-fv="img" checked>Images</span></label>
+ <label class="cw-frow"><span class="cw-fl"><input type="checkbox" data-fv="vend" checked>Vendor label</span></label>
+ </div>
+ </details>
+ <div id="cwFacets"></div>
+ <button class="cw-btn cw-clear" id="cwClear">✕ Clear filters</button>
+ </aside>
+ <main class="cw-main">
+ <div class="cw-cal" id="cwCal"><div class="cw-loading">Loading calendars…</div></div>
+ <div class="cw-empty" id="cwEmpty" style="display:none">No items match these filters.</div>
+ </main>
+</div>
+
+<div class="cw-modal-bd" id="cwModalBd" style="display:none"><div class="cw-modal" id="cwModal"></div></div>
+</div>
diff --git a/public/panels/calendars.js b/public/panels/calendars.js
new file mode 100644
index 0000000..82fbb28
--- /dev/null
+++ b/public/panels/calendars.js
@@ -0,0 +1,282 @@
+/* All Calendars panel — facet rail + multi-layer month grid + drag reschedule.
+ Ports the astek internal-line-viewer filter pattern + the dw-activation-calendar
+ grid, adapted to MCC's light luxe theme and scoped to #cwroot. */
+window.MCC_PANELS = window.MCC_PANELS || {};
+(function () {
+ const ORIGIN = location.origin;
+ const $ = (s) => document.querySelector(s);
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+ // ── state (reset each init) ──
+ let ALL = [], META = {}, FCOUNTS = {}, viewMonth = new Date();
+ const FIL = {}; // field -> active value
+ const LAYERS_OFF = new Set(); // layers hidden via the legend
+ const VCOLORS = {};
+ let FVIS = { img: true, vend: true };
+
+ // data fields → left-panel tables (layer is controlled by the legend, not here)
+ const FIELDS = [
+ ['vendor', 'Vendor'], ['material', 'Material'], ['priceBand', 'Price'],
+ ['color', 'Color'], ['style', 'Style'], ['book', 'Book · series'], ['hue', 'Hue'],
+ ['fix_type', 'Build step'], ['channel', 'Channel'], ['status', 'Status'], ['month', 'Go-live month'],
+ ];
+ FIELDS.forEach(([k]) => FIL[k] = '');
+ const FCAP = 40;
+ const LAYER_META = {
+ activation: { label: 'Activation (SKUs)', color: '#B0894F' },
+ marketing: { label: 'Marketing dates', color: '#7A571C' },
+ campaign: { label: 'Campaigns', color: '#6E2A40' },
+ google: { label: 'Google', color: '#3E7C5A' },
+ };
+
+ function vcolor(v) {
+ if (!v) return '#9a8f7d';
+ if (VCOLORS[v]) return VCOLORS[v];
+ let h = 0; for (const c of v) h = (h * 31 + c.charCodeAt(0)) % 360;
+ return VCOLORS[v] = `hsl(${h},34%,52%)`;
+ }
+ const pad = (n) => String(n).padStart(2, '0');
+ function slotLabel(h) { if (h == null) return ''; const ap = h < 12 ? 'AM' : 'PM'; let hh = h % 12; if (hh === 0) hh = 12; return hh + ' ' + ap; }
+ function fmtGoLive(iso) { if (!iso) return '—'; try { return new Date(iso).toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); } catch { return iso; } }
+ function fmtMonth(d) { return d.toLocaleString(undefined, { month: 'long', year: 'numeric' }); }
+ function skuLabel(s) { return s.dw_sku || s.mfr_sku || (s.shopify_id ? '#' + String(s.shopify_id).split('/').pop() : '(no sku)'); }
+
+ function setDensity(v) {
+ v = Number(v);
+ const chip = Math.round(40 + (v - 3) * (34 / 7)); // 3→40px … 10→74px
+ const fs = (0.85 + (v - 3) * (0.3 / 7)).toFixed(3);
+ const r = document.getElementById('cwroot'); if (!r) return;
+ r.style.setProperty('--cwchip', chip + 'px'); r.style.setProperty('--cwfs', fs);
+ }
+ function applyFieldVis() {
+ const r = document.getElementById('cwroot'); if (!r) return;
+ r.classList.toggle('cw-noimg', !FVIS.img);
+ r.classList.toggle('cw-novend', !FVIS.vend);
+ document.querySelectorAll('#cwFieldsBox input[data-fv]').forEach(cb => cb.checked = !!FVIS[cb.dataset.fv]);
+ try { localStorage.setItem('cw.fieldvis', JSON.stringify(FVIS)); } catch {}
+ }
+
+ // ── facets ──
+ function buildFacetCache() {
+ FCOUNTS = {};
+ for (const [k] of FIELDS) {
+ const m = {};
+ for (const it of ALL) { const v = it[k]; if (v == null || v === '') continue; m[v] = (m[v] || 0) + 1; }
+ FCOUNTS[k] = Object.entries(m).sort((a, b) => b[1] - a[1]);
+ }
+ const vendors = (FCOUNTS.vendor || []).map(([v]) => v).sort((a, b) => String(a).localeCompare(String(b)));
+ $('#cwPatList').innerHTML = vendors.map(v => `<option value="${esc(v)}">`).join('');
+ }
+ function renderSide() {
+ const box = $('#cwFacets'); if (!box) return;
+ const openNow = new Set([...box.querySelectorAll('details[open]')].map(d => d.dataset.f));
+ box.innerHTML = FIELDS.map(([k, label]) => {
+ const all = FCOUNTS[k] || []; if (!all.length) return ''; // skip empty fields (e.g. no campaigns → no channel)
+ const rows = all.slice(0, FCAP).map(([v, n]) =>
+ `<div class="cw-frow${FIL[k] === String(v) ? ' active' : ''}" onclick="cwPickF('${esc(k)}',this.dataset.v)" data-v="${esc(v)}">
+ <span class="cw-fl">${esc(v)}</span><span class="n">${n.toLocaleString()}</span></div>`).join('');
+ const more = all.length > FCAP ? `<div class="cw-fmore">+ ${(all.length - FCAP).toLocaleString()} more — use search</div>` : '';
+ const cur = FIL[k] ? `<span class="cur" title="${esc(FIL[k])}">${esc(FIL[k])}</span>` : '';
+ return `<details data-f="${esc(k)}"${openNow.has(k) ? ' open' : ''}><summary>${esc(label)}${cur}</summary><div class="cw-fbody">${rows}${more}</div></details>`;
+ }).join('');
+ const anyFilter = Object.values(FIL).some(Boolean) || $('#cwQ').value || $('#cwPat').value || LAYERS_OFF.size;
+ $('#cwClear').style.display = anyFilter ? 'block' : 'none';
+ }
+ window.cwPickF = function (k, v) { FIL[k] = (FIL[k] === v ? '' : v); refreshView(); };
+
+ function renderLegend() {
+ const counts = ALL.reduce((a, it) => { a[it.layer] = (a[it.layer] || 0) + 1; return a; }, {});
+ $('#cwLegend').innerHTML = Object.keys(LAYER_META).filter(l => counts[l]).map(l => {
+ const m = LAYER_META[l];
+ return `<span class="li${LAYERS_OFF.has(l) ? ' off' : ''}" onclick="cwToggleLayer('${l}')" title="show/hide this calendar">
+ <span class="sw" style="background:${m.color}"></span>${esc(m.label)} <b style="color:var(--ink)">${counts[l].toLocaleString()}</b></span>`;
+ }).join('');
+ }
+ window.cwToggleLayer = function (l) { LAYERS_OFF.has(l) ? LAYERS_OFF.delete(l) : LAYERS_OFF.add(l); renderLegend(); refreshView(); };
+
+ function filteredItems() {
+ const q = ($('#cwQ').value || '').trim().toLowerCase(), pat = ($('#cwPat').value || '').trim().toLowerCase();
+ return ALL.filter(it => {
+ if (LAYERS_OFF.has(it.layer)) return false;
+ for (const [k] of FIELDS) { if (FIL[k] && String(it[k] ?? '') !== FIL[k]) return false; }
+ if (pat && !String(it.vendor || '').toLowerCase().includes(pat)) return false;
+ if (q && ![it.title, it.vendor, it.dw_sku, it.mfr_sku, it.color, it.book, it.style, it.suggestion].some(v => v && String(v).toLowerCase().includes(q))) return false;
+ return true;
+ });
+ }
+
+ // ── calendar grid ──
+ function outCell(dn) { const c = document.createElement('div'); c.className = 'cw-cell out'; c.innerHTML = `<div class="cw-dh"><span class="cw-dn">${dn}</span></div>`; return c; }
+
+ function renderMonth() {
+ const cal = $('#cwCal'); if (!cal) return;
+ $('#cwML').textContent = fmtMonth(viewMonth);
+ const items = filteredItems();
+ $('#cwCount').textContent = items.length.toLocaleString() + ' items in view · ' + ALL.length.toLocaleString() + ' total';
+ const byDate = {}; items.forEach(it => (byDate[it.date] = byDate[it.date] || []).push(it));
+ cal.innerHTML = '';
+ const y = viewMonth.getFullYear(), m = viewMonth.getMonth();
+ const dowRow = document.createElement('div'); dowRow.className = 'cw-dow-row';
+ ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].forEach(d => { const h = document.createElement('div'); h.className = 'cw-dow'; h.textContent = d; dowRow.appendChild(h); });
+ cal.appendChild(dowRow);
+ const grid = document.createElement('div'); grid.className = 'cw-grid'; cal.appendChild(grid);
+ const first = new Date(y, m, 1), startDow = first.getDay(), days = new Date(y, m + 1, 0).getDate(), prevDays = new Date(y, m, 0).getDate();
+ const now = new Date(); const todayISO = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
+ const expanders = [];
+ for (let i = startDow - 1; i >= 0; i--) grid.appendChild(outCell(prevDays - i));
+ 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 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))}`; }
+ cell.innerHTML = `<div class="cw-dh"><span class="cw-dn">${dn}</span>${list.length ? `<span class="cw-cnt">${list.length}${hrTxt}</span>` : ''}</div>`;
+ // drop target
+ cell.addEventListener('dragover', ev => { ev.preventDefault(); ev.dataTransfer.dropEffect = 'move'; cell.classList.add('drop-target'); });
+ cell.addEventListener('dragleave', ev => { if (!cell.contains(ev.relatedTarget)) cell.classList.remove('drop-target'); });
+ cell.addEventListener('drop', ev => {
+ ev.preventDefault(); cell.classList.remove('drop-target');
+ let d; try { d = JSON.parse(ev.dataTransfer.getData('text/plain') || '{}'); } catch { return; }
+ if (d.key && d.from !== iso) moveItem(d.key, iso, d.layer, d.label);
+ });
+ // overlay bars (marketing / campaign / google)
+ bars.forEach(it => {
+ const bar = document.createElement('button'); bar.type = 'button'; bar.className = 'cw-bar ' + it.layer;
+ bar.textContent = (it.layer === 'campaign' ? '🎯 ' : it.layer === 'google' ? '📅 ' : '★ ') + (it.title || '(untitled)');
+ bar.title = (it.title || '') + (it.suggestion ? '\n' + it.suggestion : '') + (it.status ? '\n' + it.status : '');
+ bar.onclick = () => openItemModal(it);
+ if (it.layer === 'campaign' && it.key) { // only campaigns are draggable among overlays
+ bar.draggable = true;
+ bar.addEventListener('dragstart', ev => { ev.dataTransfer.setData('text/plain', JSON.stringify({ key: it.key, from: iso, layer: 'campaign', label: it.title })); ev.dataTransfer.effectAllowed = 'move'; });
+ }
+ cell.appendChild(bar);
+ });
+ // activation image chips
+ if (acts.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';
+ 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)}`;
+ 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)
+ ? `<img loading="lazy" src="${esc(s.image_url)}" onerror="this.remove()">${ph}` : ph) +
+ `<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')); }
+ 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;
+ more.onclick = () => { const open = cell.classList.toggle('expanded'); more.textContent = open ? '▴ collapse' : closed; };
+ cell.appendChild(more);
+ expanders.push({ chips, more });
+ }
+ grid.appendChild(cell);
+ }
+ const trail = (7 - ((startDow + days) % 7)) % 7;
+ for (let dn = 1; dn <= trail; dn++) grid.appendChild(outCell(dn));
+ $('#cwEmpty').style.display = items.length ? 'none' : 'block';
+ requestAnimationFrame(() => expanders.forEach(({ chips, more }) => { if (chips.scrollHeight <= chips.clientHeight + 2) more.remove(); }));
+ }
+
+ function refreshView() { renderSide(); renderMonth(); }
+
+ // ── detail modal ──
+ function closeModal() { $('#cwModalBd').style.display = 'none'; $('#cwModal').innerHTML = ''; }
+ function openItemModal(it) {
+ const bd = $('#cwModalBd'), box = $('#cwModal');
+ const kv = (k, v) => v ? `<dt>${k}</dt><dd>${v}</dd>` : '';
+ let inner;
+ if (it.layer === 'activation') {
+ 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 activation">activation</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('Build step', esc(it.fix_type))}${kv('Goes active', '⏱ ' + fmtGoLive(it.go_live_at) + (it.slot_hour != null ? ' · ' + slotLabel(it.slot_hour) + ' slot' : ''))}
+ </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 === '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>
+ <dl class="cw-dl" style="margin-top:12px">${kv('Date', esc(it.date))}${kv('Channel', esc(it.channel))}${kv('Status', esc(it.status))}${kv('Notes', esc(it.notes))}</dl>
+ <div class="cw-resched"><label style="margin:0">Reschedule</label><input type="date" id="cwDate" value="${esc(it.date)}"><button class="cw-cta" id="cwMove">Move</button></div>
+ <div class="cw-note">Manage campaign details in the Marketing Calendar panel.</div>`;
+ } else {
+ const tag = it.layer;
+ 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 ${tag}">${tag}${it.style ? ' · ' + esc(it.style) : ''}</span>
+ <dl class="cw-dl" style="margin-top:12px">${kv('Date', esc(it.date))}</dl>
+ ${it.suggestion ? `<div class="cw-note">${esc(it.suggestion)}</div>` : ''}
+ <div class="cw-note" style="margin-top:10px">Read-only — curated ${tag === 'marketing' ? 'marketing moment' : 'event'}.</div>`;
+ }
+ box.innerHTML = inner; bd.style.display = 'flex';
+ $('#cwMX').onclick = closeModal;
+ const mv = $('#cwMove'), di = $('#cwDate');
+ if (mv) mv.onclick = async () => { const to = di.value; if (!to) return; mv.disabled = true; mv.textContent = 'Moving…'; await moveItem(it.key, to, it.layer, it.title || skuLabel(it)); closeModal(); };
+ }
+
+ // ── drag / reschedule persist (patch locally, no 13MB refetch) ──
+ async function moveItem(key, toISO, layer, label) {
+ try {
+ const r = await fetch(ORIGIN + '/api/calendars/move', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ key, to_date: toISO, layer }) });
+ const j = await r.json().catch(() => ({}));
+ if (!r.ok || !j.ok) { alert('Reschedule failed: ' + (j.error || 'HTTP ' + r.status)); return; }
+ const it = ALL.find(x => x.key === key);
+ if (it) { it.date = toISO; it.month = toISO.slice(0, 7); if (it.go_live_at) { const [Y, M, D] = toISO.split('-').map(Number); it.go_live_at = new Date(Y, M - 1, D, it.slot_hour ?? 10, 10).toISOString(); } }
+ buildFacetCache(); refreshView();
+ } catch (e) { alert('Reschedule error: ' + e.message); }
+ }
+
+ // ── load + wire ──
+ async function load(fresh) {
+ $('#cwCal').innerHTML = '<div class="cw-loading">' + (fresh ? 'Refreshing from live dw_unified…' : 'Loading calendars…') + '</div>';
+ const r = await fetch(ORIGIN + '/api/calendars/events' + (fresh ? '?fresh=1' : ''), { credentials: 'same-origin' });
+ const d = await r.json();
+ ALL = (d.items || []).map(it => ({ ...it, month: (it.date || '').slice(0, 7) }));
+ META = d;
+ if (d.mock || d.error) $('#cwCal').insertAdjacentHTML('afterbegin', `<div class="cw-note" style="padding:10px 14px">Live catalog unavailable (${esc(d.error || 'db')}). Showing ${ALL.length} cached/overlay items.</div>`);
+ buildFacetCache(); renderLegend();
+ // land on the month of the first activation date, else today
+ const firstAct = ALL.find(x => x.layer === 'activation');
+ const seed = firstAct ? new Date(firstAct.date + 'T00:00:00') : new Date();
+ viewMonth = new Date(seed.getFullYear(), seed.getMonth(), 1);
+ refreshView();
+ }
+
+ window.MCC_PANELS['calendars'] = {
+ async init() {
+ // restore prefs
+ try { FVIS = JSON.parse(localStorage.getItem('cw.fieldvis') || '') || FVIS; } catch {}
+ const dens = localStorage.getItem('cw.density') || '5';
+ $('#cwDensity').value = dens; setDensity(dens);
+ applyFieldVis();
+ // wire controls
+ $('#cwDensity').oninput = () => { setDensity($('#cwDensity').value); localStorage.setItem('cw.density', $('#cwDensity').value); };
+ document.querySelectorAll('#cwFieldsBox input[data-fv]').forEach(cb => cb.onchange = () => { FVIS[cb.dataset.fv] = cb.checked; applyFieldVis(); });
+ let t; $('#cwQ').oninput = () => { clearTimeout(t); t = setTimeout(refreshView, 250); };
+ let tp; $('#cwPat').oninput = () => { clearTimeout(tp); tp = setTimeout(refreshView, 250); };
+ $('#cwClear').onclick = () => { FIELDS.forEach(([k]) => FIL[k] = ''); LAYERS_OFF.clear(); $('#cwQ').value = ''; $('#cwPat').value = ''; renderLegend(); refreshView(); };
+ $('#cwPrev').onclick = () => { viewMonth = new Date(viewMonth.getFullYear(), viewMonth.getMonth() - 1, 1); renderMonth(); };
+ $('#cwNext').onclick = () => { viewMonth = new Date(viewMonth.getFullYear(), viewMonth.getMonth() + 1, 1); renderMonth(); };
+ $('#cwToday').onclick = () => { const n = new Date(); viewMonth = new Date(n.getFullYear(), n.getMonth(), 1); renderMonth(); };
+ $('#cwModalBd').onclick = (e) => { if (e.target === $('#cwModalBd')) closeModal(); };
+ $('#cwRefresh').onclick = async () => { const b = $('#cwRefresh'); b.disabled = true; b.textContent = '↻ Refreshing…'; try { await load(true); } finally { b.disabled = false; b.textContent = '↻ Refresh'; } };
+ await load(false);
+ },
+ };
+})();
← bd25186 channels: resolve IG per-page (bulk field dead) + add /meta-
·
back to Marketing Command Center
·
Untrack regenerable 13MB activation snapshot (gitignored — w b93e4e1 →