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