[object Object]

← back to Marketing Command Center

Streamline MCC: 28 tabs → 6 calendar-led groups; merge engine+composer → Compose (tabs+sound wiring) and calendars+calendar → Calendar hub; hide 8 mock panels under Preview (v1.9.0)

574b206e37356db3610f6587a530d68818960520 · 2026-08-12 13:52:19 -0700 · Steve

Files touched

Diff

commit 574b206e37356db3610f6587a530d68818960520
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Aug 12 13:52:19 2026 -0700

    Streamline MCC: 28 tabs → 6 calendar-led groups; merge engine+composer → Compose (tabs+sound wiring) and calendars+calendar → Calendar hub; hide 8 mock panels under Preview (v1.9.0)
---
 modules/calendarhub/index.js   | 13 +++++++
 modules/compose/index.js       | 13 +++++++
 modules/registry.js            |  6 ++--
 package.json                   |  2 +-
 public/app.js                  | 29 +++++++++------
 public/panels/calendarhub.html | 25 +++++++++++++
 public/panels/calendarhub.js   | 48 +++++++++++++++++++++++++
 public/panels/compose.html     | 35 ++++++++++++++++++
 public/panels/compose.js       | 80 ++++++++++++++++++++++++++++++++++++++++++
 public/panels/engine.js        |  4 ++-
 10 files changed, 241 insertions(+), 14 deletions(-)

diff --git a/modules/calendarhub/index.js b/modules/calendarhub/index.js
new file mode 100644
index 0000000..a011496
--- /dev/null
+++ b/modules/calendarhub/index.js
@@ -0,0 +1,13 @@
+// Calendar (hub) — the merged calendar surface that LEADS the nav and is the
+// default landing panel. Tabbed host: Timeline = the advanced "calendars" panel,
+// Plan = the simple "calendar" panel (curated moments + campaign create/delete).
+// No-op mount: it owns no API. calendarhub.js loads each real calendar panel's
+// html+js into a pane and calls that panel's own init(), so calendars.js /
+// calendar.js stay authoritative and their /api/calendars/* + /api/calendar/*
+// routers stay mounted (only their nav entries are hidden via app.js denylist).
+module.exports = {
+  id: 'calendarhub',
+  title: 'Calendar',
+  icon: '🗓️',
+  mount(_router) { /* intentionally empty — no /api/calendarhub/* routes */ },
+};
diff --git a/modules/compose/index.js b/modules/compose/index.js
new file mode 100644
index 0000000..9af3905
--- /dev/null
+++ b/modules/compose/index.js
@@ -0,0 +1,13 @@
+// Compose — the merged posting panel (Suggested = engine, Manual = composer)
+// under a single nav entry. This module is a NO-OP mount that exists only so the
+// shell gets a "Compose" nav entry + panel metadata. It owns NO API of its own:
+// the front-end panel (public/panels/compose.js) calls the still-mounted
+// /api/engine/*, /api/composer/*, and /api/channels/* directly. The engine and
+// composer modules therefore stay in registry.js (their routers must remain
+// mounted); only their NAV entries are hidden (denylist in public/app.js).
+module.exports = {
+  id: 'compose',
+  title: 'Compose',
+  icon: '✍️',
+  mount(_router) { /* intentionally empty — no /api/compose/* routes */ },
+};
diff --git a/modules/registry.js b/modules/registry.js
index fbbd272..a3c357e 100644
--- a/modules/registry.js
+++ b/modules/registry.js
@@ -7,12 +7,14 @@
 // — never this file or server.js (avoids cross-agent conflicts). To add a module,
 // the integrator appends one line here.
 module.exports = [
+  'calendarhub',        // Calendar (default landing + nav lead): tabbed host — Timeline = calendars (advanced), Plan = calendar (moments + create/delete). No-op mount; the two real calendar modules stay mounted, only their nav entries are hidden.
+  'calendars',          // Timeline view under Calendar: unified activation-SKU + marketing + campaign + Google layers with a left-panel field-filter rail (hidden from nav; loaded inside calendarhub)
+  'compose',            // Compose: merged Suggested (engine) + Manual (composer) posting under one panel — see modules/compose (no-op mount; APIs stay on engine/composer/channels)
   'composer',           // Composer board: combine an asset/banner + copy → publish/stage to all connected socials at once (assembles assets+copy+channels)
   'accounts',           // Amazon-style rail: every social account + connection status + inline "how to fix" credentials (read-only view over channels + linkedin)
   'board',              // Hootsuite-style streams board: one column per channel (social + email), dated cards
   'constant-contact',   // CC: campaigns, contacts, calendar, draft + schedule (gated sends)
-  'calendar',           // marketing calendar: retail/holiday dates + campaign schedule
-  'calendars',          // All Calendars: unified activation-SKU + marketing + campaign + Google layers with a left-panel field-filter rail
+  'calendar',           // marketing calendar: retail/holiday dates + campaign schedule (LEGACY — folded into calendars; hidden from nav, module kept mounted so /api/calendar/* stays live)
   'copy',               // suggested copy: subject lines, email/social body (LLM-backed)
   'layouts',            // on-demand HTML/CSS email + social layout mockups
   'assets',             // image asset library: upload / URL / live DW-catalog pull
diff --git a/package.json b/package.json
index 42fe2ec..d557b82 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "marketing-command-center",
-  "version": "1.8.3",
+  "version": "1.9.0",
   "description": "DW Marketing Command Center — Constant Contact, marketing calendar, suggested copy, on-demand layouts",
   "main": "server.js",
   "scripts": {
diff --git a/public/app.js b/public/app.js
index 80cccf7..4d1e2ab 100644
--- a/public/app.js
+++ b/public/app.js
@@ -7,17 +7,25 @@ const $ = s => document.querySelector(s);
 window.MCC_PANELS = window.MCC_PANELS || {};
 let PANELS = [];
 
-// Curated nav groups. Social Media leads. Any panel not listed falls into "More".
+// Curated nav groups — streamlined to 6, led by the Calendar (the daily driver).
+// Any panel not listed falls into "More" (should be empty). Some ids are merged
+// hosts: `calendarhub` = Calendar (Timeline+Plan), `compose` = Compose
+// (Suggested+Manual). Their underlying panels are hidden via HIDDEN below.
 const GROUPS = [
-  { name: 'Social Media', ids: ['accounts', 'board', 'social', 'channels', 'linkedin'] },
-  { name: 'Content Studio', ids: ['copy', 'layouts', 'assets', 'sounds', 'templates'] },
-  { name: 'Email', ids: ['constant-contact', 'send-times'] },
-  { name: 'Audience', ids: ['clients', 'segments', 'profiles', 'segment-perf'] },
-  { name: 'Automation', ids: ['journeys', 'browse-abandon', 'playbook'] },
-  { name: 'Analytics', ids: ['performance', 'follow-counts', 'ab-tests'] },
-  { name: 'Planning', ids: ['calendars', 'calendar', 'vendors'] },
+  { name: 'Calendar', ids: ['calendarhub'] },
+  { name: 'Plan', ids: ['playbook', 'vendors'] },
+  { name: 'Compose', ids: ['compose', 'linkedin', 'board', 'social'] },
+  { name: 'Library', ids: ['assets', 'sounds', 'reels', 'copy', 'layouts', 'templates'] },
+  { name: 'Accounts & Email', ids: ['accounts', 'channels', 'clients', 'constant-contact', 'browse-abandon'] },
+  { name: 'Preview · not live yet', ids: ['performance', 'segment-perf', 'send-times', 'segments', 'journeys', 'profiles', 'follow-counts', 'ab-tests'] },
 ];
 
+// Panels whose ROUTERS stay mounted (their APIs are still called) but whose NAV
+// entries are hidden — because they were merged into a host panel above. Removing
+// them from registry.js would unmount their APIs and break the host, so we hide
+// them here instead. calendars/calendar → calendarhub; engine/composer → compose.
+const HIDDEN = new Set(['calendars', 'calendar', 'engine', 'composer']);
+
 // Always fetch against the bare origin. If the page itself was opened with
 // embedded credentials (http://user:pass@host), relative URLs resolve against
 // that credentialed base and fetch() throws "URL that includes credentials".
@@ -35,7 +43,8 @@ function linkHTML(p) {
 }
 
 function renderNav() {
-  const byId = Object.fromEntries(PANELS.map(p => [p.id, p]));
+  const visible = PANELS.filter(p => !HIDDEN.has(p.id));   // hide merged-away panels
+  const byId = Object.fromEntries(visible.map(p => [p.id, p]));
   const used = new Set();
   let html = '';
   for (const g of GROUPS) {
@@ -45,7 +54,7 @@ function renderNav() {
     html += `<div class="navgroup"><div class="navhead">${g.name}</div>` +
       items.map(linkHTML).join('') + `</div>`;
   }
-  const leftover = PANELS.filter(p => !used.has(p.id));
+  const leftover = visible.filter(p => !used.has(p.id));
   if (leftover.length) {
     html += `<div class="navgroup"><div class="navhead">More</div>` +
       leftover.map(linkHTML).join('') + `</div>`;
diff --git a/public/panels/calendarhub.html b/public/panels/calendarhub.html
new file mode 100644
index 0000000..341161c
--- /dev/null
+++ b/public/panels/calendarhub.html
@@ -0,0 +1,25 @@
+<!-- Calendar — one panel, two views. [ Timeline ] reuses the advanced calendars
+     panel (activation/marketing/campaign layers, drag-to-reschedule). [ Plan ]
+     reuses the simple calendar panel (curated moments + "Plan it" + campaign
+     create/delete). calendarhub.js loads each real panel's html+js into its pane
+     and calls that panel's own init() — no code duplicated; calendars.js /
+     calendar.js stay authoritative. Both calendar BACKENDS stay mounted; only
+     their nav entries are hidden (denylist in app.js). -->
+<div class="calh">
+  <div class="calh-tabbar">
+    <button type="button" class="calh-tab active" data-tab="timeline">🗓️ Timeline</button>
+    <button type="button" class="calh-tab" data-tab="plan">📝 Plan</button>
+    <span class="calh-hint">Timeline = full activation + marketing calendar · Plan = curated moments + schedule a campaign</span>
+  </div>
+  <div class="calh-pane" data-pane="timeline"><div class="loading">Loading calendar…</div></div>
+  <div class="calh-pane" data-pane="plan" hidden></div>
+</div>
+
+<style>
+  .calh-tabbar{display:flex;align-items:center;gap:8px;margin-bottom:14px;flex-wrap:wrap}
+  .calh-tab{border:1px solid var(--line);background:#fff;border-radius:10px;cursor:pointer;
+    font:600 13px/1 Inter;color:var(--ink);padding:9px 15px}
+  .calh-tab:hover{background:var(--cream)}
+  .calh-tab.active{background:var(--ink);color:#fff;border-color:var(--ink)}
+  .calh-hint{font-size:11.5px;color:var(--mut);margin-left:4px}
+</style>
diff --git a/public/panels/calendarhub.js b/public/panels/calendarhub.js
new file mode 100644
index 0000000..62d8b3a
--- /dev/null
+++ b/public/panels/calendarhub.js
@@ -0,0 +1,48 @@
+// Calendar hub — merges the advanced "calendars" and simple "calendar" panels
+// into one tabbed surface WITHOUT duplicating their code. Each tab loads the real
+// panel's html into its pane and calls that panel's own MCC_PANELS[id].init(pane),
+// so calendars.js / calendar.js remain the single source of truth. Lazy: Timeline
+// inits on open, Plan on first click. (calendars.js scopes DOM by global document
+// queries on #cwroot, so it works fine loaded into a pane.)
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['calendarhub'] = {
+  async init(root) {
+    const ORIGIN = location.origin;
+    const paneT = root.querySelector('[data-pane="timeline"]');
+    const paneP = root.querySelector('[data-pane="plan"]');
+    let tInited = false, pInited = false;
+
+    async function ensureScript(id) {
+      if (window.MCC_PANELS[id]) return;
+      await new Promise(res => {
+        const s = document.createElement('script');
+        s.src = ORIGIN + '/panels/' + id + '.js';
+        s.onload = res; s.onerror = res;
+        document.body.appendChild(s);
+      });
+    }
+    async function mountInto(id, pane) {
+      try {
+        const r = await fetch(ORIGIN + '/panels/' + id + '.html', { credentials: 'same-origin' });
+        pane.innerHTML = r.ok ? await r.text() : `<div class="muted">Could not load ${id}.</div>`;
+      } catch { pane.innerHTML = `<div class="muted">Could not load ${id}.</div>`; return; }
+      await ensureScript(id);
+      try {
+        if (window.MCC_PANELS[id] && window.MCC_PANELS[id].init) await window.MCC_PANELS[id].init(pane);
+      } catch (e) { console.error('[calendarhub] ' + id + ' init failed', e); }
+    }
+
+    async function initTimeline() { if (tInited) return; tInited = true; await mountInto('calendars', paneT); }
+    async function initPlan() { if (pInited) return; pInited = true; await mountInto('calendar', paneP); }
+
+    root.querySelectorAll('.calh-tab').forEach(btn => btn.onclick = () => {
+      const t = btn.dataset.tab;
+      root.querySelectorAll('.calh-tab').forEach(b => b.classList.toggle('active', b === btn));
+      paneT.hidden = t !== 'timeline';
+      paneP.hidden = t !== 'plan';
+      if (t === 'plan') initPlan();
+    });
+
+    initTimeline(); // Timeline is the default view
+  },
+};
diff --git a/public/panels/compose.html b/public/panels/compose.html
new file mode 100644
index 0000000..da0b28d
--- /dev/null
+++ b/public/panels/compose.html
@@ -0,0 +1,35 @@
+<!-- Compose — one panel, two flows. [ Suggested ] reuses the engine panel (daily
+     AI-suggested posts); [ Manual ] reuses the composer panel (build-a-post).
+     compose.js loads each real panel's html+js into its pane and calls that
+     panel's own init() — no code is duplicated; engine.js/composer.js stay
+     authoritative. The engine + composer BACKENDS stay mounted; only their nav
+     entries are hidden (denylist in app.js). -->
+<div class="cmp2">
+  <div class="cmp2-tabbar">
+    <button type="button" class="cmp2-tab active" data-tab="suggested">✨ Suggested</button>
+    <button type="button" class="cmp2-tab" data-tab="manual">✍️ Manual<span class="cmp2-badge" id="cmp2-badge" hidden></span></button>
+    <span class="cmp2-hint">Suggested = today's AI picks to approve · Manual = build a post from scratch</span>
+  </div>
+  <div class="cmp2-pane" data-pane="suggested"><div class="loading">Loading suggestions…</div></div>
+  <div class="cmp2-pane" data-pane="manual" hidden></div>
+</div>
+
+<style>
+  .cmp2-tabbar{display:flex;align-items:center;gap:8px;margin-bottom:14px;flex-wrap:wrap}
+  .cmp2-tab{position:relative;border:1px solid var(--line);background:#fff;border-radius:10px;cursor:pointer;
+    font:600 13px/1 Inter;color:var(--ink);padding:9px 15px}
+  .cmp2-tab:hover{background:var(--cream)}
+  .cmp2-tab.active{background:var(--ink);color:#fff;border-color:var(--ink)}
+  .cmp2-badge{display:inline-block;margin-left:7px;background:var(--accent);color:#fff;border-radius:999px;
+    font:700 10px/1 Inter;padding:2px 6px;min-width:14px;text-align:center;vertical-align:top}
+  .cmp2-tab.active .cmp2-badge{background:#fff;color:var(--ink)}
+  .cmp2-hint{font-size:11.5px;color:var(--mut);margin-left:4px}
+  /* the sound-queue offer bar injected into the Manual pane */
+  .cmp2-soundoffer{display:flex;align-items:center;gap:10px;flex-wrap:wrap;background:var(--cream);
+    border:1px solid var(--line);border-radius:10px;padding:8px 11px;margin-bottom:9px;font-size:12.5px;color:var(--ink)}
+  .cmp2-soundoffer .cmp2-addbtn{border:1px solid var(--accent);background:var(--accent);color:#fff;border-radius:8px;
+    cursor:pointer;font:600 11.5px/1 Inter;padding:7px 11px}
+  .cmp2-soundoffer .cmp2-addbtn:hover{filter:brightness(1.05)}
+  .cmp2-soundoffer .cmp2-dismiss{margin-left:auto;border:0;background:transparent;color:var(--mut);cursor:pointer;font:700 15px/1 Inter}
+  .cmp2-soundoffer .cmp2-dismiss:hover{color:var(--accent)}
+</style>
diff --git a/public/panels/compose.js b/public/panels/compose.js
new file mode 100644
index 0000000..5036758
--- /dev/null
+++ b/public/panels/compose.js
@@ -0,0 +1,80 @@
+// Compose — merges the engine ("Suggested") and composer ("Manual") panels into
+// one tabbed surface WITHOUT duplicating their code. Each tab loads the real
+// panel's html into its pane and calls that panel's own MCC_PANELS[id].init(pane),
+// so engine.js / composer.js remain the single source of truth. Lazy: Suggested
+// inits on open, Manual on first click. The sounds panel's queued songs
+// (localStorage 'mccSndSocial') are offered into the Manual caption.
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['compose'] = {
+  async init(root) {
+    const ORIGIN = location.origin;
+    const paneS = root.querySelector('[data-pane="suggested"]');
+    const paneM = root.querySelector('[data-pane="manual"]');
+    let sInited = false, mInited = false;
+
+    // Ensure a sibling panel's JS is loaded (it self-registers MCC_PANELS[id]).
+    async function ensureScript(id) {
+      if (window.MCC_PANELS[id]) return;
+      await new Promise(res => {
+        const s = document.createElement('script');
+        s.src = ORIGIN + '/panels/' + id + '.js';
+        s.onload = res; s.onerror = res;
+        document.body.appendChild(s);
+      });
+    }
+    // Load <id>.html into a pane, then run that panel's own init against the pane.
+    async function mountInto(id, pane) {
+      try {
+        const r = await fetch(ORIGIN + '/panels/' + id + '.html', { credentials: 'same-origin' });
+        pane.innerHTML = r.ok ? await r.text() : `<div class="muted">Could not load ${id}.</div>`;
+      } catch { pane.innerHTML = `<div class="muted">Could not load ${id}.</div>`; return; }
+      await ensureScript(id);
+      try {
+        if (window.MCC_PANELS[id] && window.MCC_PANELS[id].init) await window.MCC_PANELS[id].init(pane);
+      } catch (e) { console.error('[compose] ' + id + ' init failed', e); }
+    }
+
+    async function initSuggested() { if (sInited) return; sInited = true; await mountInto('engine', paneS); }
+    async function initManual() { if (mInited) return; mInited = true; await mountInto('composer', paneM); wireSoundOffer(paneM); }
+
+    // Offer the sounds panel's queued songs into the Manual caption (no silent
+    // mutation — a dismissible bar the user clicks). sounds.js writes the queue.
+    function wireSoundOffer(pane) {
+      let q; try { q = JSON.parse(localStorage.getItem('mccSndSocial') || '[]'); } catch (_) { q = []; }
+      if (!q.length) return;
+      const cap = pane.querySelector('#cmp-caption');
+      if (!cap) return;
+      const bar = document.createElement('div');
+      bar.className = 'cmp2-soundoffer';
+      bar.innerHTML = `🎵 <span><b>${q.length}</b> song${q.length === 1 ? '' : 's'} queued in Sounds.</span>` +
+        `<button type="button" class="cmp2-addbtn">Add to caption</button>` +
+        `<button type="button" class="cmp2-dismiss" title="Dismiss">×</button>`;
+      cap.parentNode.insertBefore(bar, cap);
+      bar.querySelector('.cmp2-addbtn').onclick = () => {
+        const lines = q.map(s => `🎵 Sound: ${s.name} — ${s.url}`).join('\n');
+        cap.value = (cap.value.trim() ? cap.value.trim() + '\n\n' : '') + lines;
+        cap.dispatchEvent(new Event('input', { bubbles: true })); // composer's countIt listens on input
+        bar.remove();
+      };
+      bar.querySelector('.cmp2-dismiss').onclick = () => bar.remove();
+    }
+
+    // Tabs: toggle panes; lazy-init Manual on first reveal.
+    root.querySelectorAll('.cmp2-tab').forEach(btn => btn.onclick = () => {
+      const t = btn.dataset.tab;
+      root.querySelectorAll('.cmp2-tab').forEach(b => b.classList.toggle('active', b === btn));
+      paneS.hidden = t !== 'suggested';
+      paneM.hidden = t !== 'manual';
+      if (t === 'manual') initManual();
+    });
+
+    // Badge the Manual tab when songs are queued (so the sound offer is discoverable).
+    try {
+      const q = JSON.parse(localStorage.getItem('mccSndSocial') || '[]');
+      const badge = root.querySelector('#cmp2-badge');
+      if (q.length && badge) { badge.hidden = false; badge.textContent = q.length; }
+    } catch (_) {}
+
+    initSuggested(); // Suggested is the default tab
+  },
+};
diff --git a/public/panels/engine.js b/public/panels/engine.js
index f0ed1b3..71101ce 100644
--- a/public/panels/engine.js
+++ b/public/panels/engine.js
@@ -421,7 +421,9 @@ window.MCC_PANELS['engine'] = {
     // ── auto-refresh (30s), paused when the tab is hidden ─────────────────────
     let timer = null;
     function tick() {
-      if (document.hidden || state.generating) return;
+      // pause when the browser tab is hidden OR this panel's root is hidden
+      // (e.g. mounted inside Compose's Suggested pane while the Manual tab shows).
+      if (document.hidden || root.hidden || state.generating) return;
       // don't stomp an open editor
       if (Object.values(state.editing).some(Boolean)) { refreshStatus(); return; }
       refreshStatus();

← 75ad81c auto-data-snapshot: 2026-08-12T12:59:45 (1 data files) — pac  ·  back to Marketing Command Center  ·  deploy: streamlined MCC (6 groups, Compose+Calendar hubs) li 0e5e905 →