← back to Marketing Command Center
public/panels/calendar.js
227 lines
// Marketing Calendar panel — month grid + curated moments + campaign scheduler.
// Registers into the shell's MCC_PANELS map (see public/app.js).
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['calendar'] = {
init(root) {
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
const pad = n => String(n).padStart(2, '0');
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const now = new Date();
const state = {
year: now.getFullYear(),
month: now.getMonth() + 1, // 1-based
dates: [], // curated moments
schedule: [], // user campaigns this month
editingDate: null, // iso of the day cell with the open form
prefill: null, // {title, channel, notes} to seed the form
};
const $ = sel => root.querySelector(sel);
const grid = $('#cal-grid');
const formTpl = $('#cal-form-tpl');
async function load() {
const qs = `year=${state.year}&month=${pad(state.month)}`;
try {
const [d, s] = await Promise.all([
fetch(`/api/calendar/dates?${qs}`).then(r => r.json()),
fetch(`/api/calendar/schedule?${qs}`).then(r => r.json()),
]);
state.dates = (d && d.dates) || [];
state.schedule = (s && s.schedule) || [];
} catch (e) {
state.dates = []; state.schedule = [];
}
render();
}
function eventsForDay(iso) {
const moments = state.dates.filter(x => x.date === iso)
.map(x => ({ kind: x.type, title: x.title }));
const camps = state.schedule.filter(x => x.date === iso)
.map(x => ({ kind: 'campaign', title: x.title }));
return moments.concat(camps);
}
function render() {
$('#cal-title').textContent = `${MONTHS[state.month - 1]} ${state.year}`;
renderGrid();
renderMoments();
renderSchedule();
}
function renderGrid() {
const first = new Date(state.year, state.month - 1, 1);
const startDow = first.getDay(); // 0=Sun
const daysInMonth = new Date(state.year, state.month, 0).getDate();
const todayIso = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
let cells = '';
for (let i = 0; i < startDow; i++) cells += `<div class="cal-cell empty"></div>`;
for (let d = 1; d <= daysInMonth; d++) {
const iso = `${state.year}-${pad(state.month)}-${pad(d)}`;
const evts = eventsForDay(iso);
const shown = evts.slice(0, 3);
const dots = shown.map(e =>
`<div class="cal-evt"><i class="t-${e.kind}"></i><span title="${esc(e.title)}">${esc(e.title)}</span></div>`
).join('');
const more = evts.length > 3 ? `<div class="more">+${evts.length - 3} more</div>` : '';
const cls = ['cal-cell'];
if (iso === todayIso) cls.push('today');
if (iso === state.editingDate) cls.push('editing');
cells += `<div class="${cls.join(' ')}" data-iso="${iso}">
<div class="num">${d}</div>${dots}${more}
</div>`;
}
grid.innerHTML = cells;
grid.querySelectorAll('.cal-cell[data-iso]').forEach(cell => {
cell.addEventListener('click', e => {
if (e.target.closest('.cal-form')) return; // clicks inside the form
openForm(cell.dataset.iso);
});
});
if (state.editingDate) mountForm();
}
function openForm(iso) {
state.editingDate = (state.editingDate === iso && !state.prefill) ? null : iso;
renderGrid();
}
function mountForm() {
const cell = grid.querySelector(`.cal-cell[data-iso="${state.editingDate}"]`);
if (!cell) return;
const node = formTpl.content.firstElementChild.cloneNode(true);
const dateLabel = new Date(state.editingDate + 'T00:00:00')
.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
node.querySelector('[data-formdate]').textContent = dateLabel;
if (state.prefill) {
if (state.prefill.title) node.querySelector('[name=title]').value = state.prefill.title;
if (state.prefill.channel) node.querySelector('[name=channel]').value = state.prefill.channel;
if (state.prefill.notes) node.querySelector('[name=notes]').value = state.prefill.notes;
state.prefill = null;
}
node.querySelector('[data-cancel]').addEventListener('click', () => {
state.editingDate = null; renderGrid();
});
node.addEventListener('submit', async e => {
e.preventDefault();
const fd = new FormData(node);
const title = (fd.get('title') || '').toString().trim();
if (!title) { node.querySelector('[name=title]').focus(); return; }
const body = {
date: state.editingDate,
title,
channel: fd.get('channel'),
notes: (fd.get('notes') || '').toString().trim(),
status: 'planned',
};
const btn = node.querySelector('button[type=submit]');
btn.disabled = true; btn.textContent = 'Saving…';
try {
await fetch('/api/calendar/schedule', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} catch (_) {}
state.editingDate = null;
await load();
});
cell.appendChild(node);
const t = node.querySelector('[name=title]');
if (t && !t.value) t.focus();
}
function renderMoments() {
const box = $('#cal-moments');
if (!state.dates.length) {
box.innerHTML = `<div class="cal-empty">No curated moments this month — a good window for an evergreen feature.</div>`;
return;
}
box.innerHTML = state.dates.map(m => {
const dd = new Date(m.date + 'T00:00:00')
.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
return `<div class="moment">
<div class="mt"><i class="dotc t-${m.type}"></i><span class="md">${esc(dd)}</span>
<span class="pill">${esc(m.type)}</span></div>
<b>${esc(m.title)}</b>
<p>${esc(m.suggestion)}</p>
<button class="btn ghost cal-planit"
data-date="${m.date}" data-title="${esc(m.title)}" data-notes="${esc(m.suggestion)}">Plan it</button>
</div>`;
}).join('');
box.querySelectorAll('.cal-planit').forEach(b => {
b.addEventListener('click', () => {
state.prefill = {
title: b.dataset.title,
channel: 'email',
notes: b.dataset.notes,
};
state.editingDate = b.dataset.date;
renderGrid();
const cell = grid.querySelector(`.cal-cell[data-iso="${b.dataset.date}"]`);
if (cell) cell.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
});
}
function renderSchedule() {
const box = $('#cal-sched-list');
if (!state.schedule.length) {
box.innerHTML = `<div class="cal-empty">Nothing scheduled yet. Click a day, or hit “Plan it” on a moment.</div>`;
return;
}
box.innerHTML = state.schedule.map(c => {
const dd = new Date(c.date + 'T00:00:00')
.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
return `<div class="sched-item">
<span class="sd">${esc(dd)}</span>
<div class="sx">
<div class="st">${esc(c.title)}</div>
<div class="sm">${esc(c.channel)} · ${esc(c.status)}${c.notes ? ' · ' + esc(c.notes) : ''}</div>
</div>
<button class="sched-del" data-del="${esc(c.id)}" title="Remove">✕</button>
</div>`;
}).join('');
box.querySelectorAll('.sched-del').forEach(b => {
b.addEventListener('click', async () => {
if (!confirm('Remove this planned campaign?')) return;
try {
await fetch(`/api/calendar/schedule?delete=${encodeURIComponent(b.dataset.del)}`,
{ method: 'POST' });
} catch (_) {}
await load();
});
});
}
function step(delta) {
let m = state.month + delta, y = state.year;
if (m < 1) { m = 12; y--; }
if (m > 12) { m = 1; y++; }
state.month = m; state.year = y; state.editingDate = null;
load();
}
$('#cal-prev').addEventListener('click', () => step(-1));
$('#cal-next').addEventListener('click', () => step(1));
$('#cal-today').addEventListener('click', () => {
state.year = now.getFullYear(); state.month = now.getMonth() + 1;
state.editingDate = null; load();
});
load();
},
};