← back to Marketing Command Center
public/panels/calendars.js
310 lines
/* 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: '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' },
};
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 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))}`; }
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);
});
// image chips — staged (activation, draggable) + launched (live, not draggable)
if (chipItems.length) {
const chips = document.createElement('div'); chips.className = 'cw-chips';
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 = 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)
? `<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 (!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 ' + 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 });
}
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 === '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>
<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>';
let d;
try {
const r = await fetch(ORIGIN + '/api/calendars/events' + (fresh ? '?fresh=1' : ''), { credentials: 'same-origin' });
if (!r.ok) throw new Error('HTTP ' + r.status);
d = await r.json();
} catch (e) {
$('#cwCal').innerHTML = `<div class="cw-note" style="padding:20px">Couldn't load calendars (${esc(e.message)}). <button class="cw-btn" onclick="location.reload()">Retry</button></div>`;
return;
}
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 { const s = localStorage.getItem('cw.fieldvis'); if (s) FVIS = { ...FVIS, ...JSON.parse(s) }; } 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);
},
};
})();