← 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 =>
      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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();
  },
};