← 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
A modules/calendarhub/index.jsA modules/compose/index.jsM modules/registry.jsM package.jsonM public/app.jsA public/panels/calendarhub.htmlA public/panels/calendarhub.jsA public/panels/compose.htmlA public/panels/compose.jsM public/panels/engine.js
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 →