iso27diy-corp/marketing/publications/content-calendar.md

13 KiB
Raw Blame History

title notetype tags
Content Calendar other

Content Calendar

// ─── CONFIG ──────────────────────────────────────────────────────────────────
const POSTS_FOLDER = "iso27diy-corp/Marketing/publications/posts";

const CHANNEL_COLORS = {
  linkedin:   "#0A66C2",
  newsletter: "#E8A838",
  blog:       "#16A34A",
};

const STATUS_ICON = {
  published: "✓",
  scheduled: "◷",
  draft:     "○",
  ready:     "●",
};

// ─── STATE ───────────────────────────────────────────────────────────────────
// weekOffset: 0 = current week, -1 = last week, etc. (min -4)
let weekOffset = 0;
let expandedKey = null; // "filename::channel" of currently open card

// ─── HELPERS ─────────────────────────────────────────────────────────────────
function getMondayOf(date) {
  const d = new Date(date);
  const day = d.getDay(); // 0=Sun
  const diff = day === 0 ? -6 : 1 - day;
  d.setDate(d.getDate() + diff);
  d.setHours(0, 0, 0, 0);
  return d;
}

function addDays(date, n) {
  const d = new Date(date);
  d.setDate(d.getDate() + n);
  return d;
}

function fmtDay(date) {
  return date.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short" });
}

function fmtMeta(val) {
  if (val === null || val === undefined) return "—";
  if (Array.isArray(val)) return val.join(", ");
  if (typeof val === "object" && val.ts) return new Date(val.ts).toISOString().replace("T", " ").slice(0, 16) + " UTC";
  return String(val);
}

// ─── DATA COLLECTION ─────────────────────────────────────────────────────────
function collectCards(pages) {
  const cards = [];   // scheduled/published cards with a date
  const loose = [];   // drafts/ready with no publish-dates

  for (const p of pages) {
    const fm = p.file.frontmatter ?? {};
    const title = fm.title ?? p.file.name;
    const status = fm.status ?? "draft";
    const channels = Array.isArray(fm.channels) ? fm.channels : (fm.channels ? [fm.channels] : []);
    const publishDates = fm["publish-dates"] ?? {};
    const publishedUrls = fm["published-urls"] ?? {};

    // Build one card per channel that has a publish-date
    let hasAnyDate = false;
    for (const ch of channels) {
      const rawDate = publishDates[ch];
      if (rawDate) {
        hasAnyDate = true;
        const d = new Date(rawDate);
        cards.push({
          key: p.file.name + "::" + ch,
          title,
          status,
          channel: ch,
          date: d,
          url: publishedUrls[ch] ?? null,
          path: p.file.path,
          fm,
        });
      }
    }

    // If no channel has a date, it's unscheduled
    if (!hasAnyDate) {
      loose.push({
        key: p.file.name + "::unscheduled",
        title,
        status,
        channels,
        path: p.file.path,
        fm,
      });
    }
  }

  return { cards, loose };
}

// ─── RENDER ──────────────────────────────────────────────────────────────────
function render() {
  dv.container.empty();

  const pages = dv.pages(`"${POSTS_FOLDER}"`).where(p => p.notetype === "publication");
  const { cards, loose } = collectCards(pages);

  const today = new Date();
  today.setHours(0, 0, 0, 0);
  const monday = addDays(getMondayOf(today), weekOffset * 7);
  const sunday = addDays(monday, 6);

  // ── Header + nav ──
  const header = dv.container.createEl("div", { cls: "pd-header" });

  const prevBtn = header.createEl("button", { text: "← Prev", cls: "pd-nav-btn" });
  prevBtn.disabled = weekOffset <= -4;
  prevBtn.onclick = () => { weekOffset--; render(); };

  const weekLabel = header.createEl("span", { cls: "pd-week-label" });
  weekLabel.textContent = fmtDay(monday) + "  " + fmtDay(sunday);

  const nextBtn = header.createEl("button", { text: "Next →", cls: "pd-nav-btn" });
  nextBtn.disabled = weekOffset >= 0;
  nextBtn.onclick = () => { weekOffset++; render(); };

  // ── Legend ──
  const legend = dv.container.createEl("div", { cls: "pd-legend" });
  for (const [ch, color] of Object.entries(CHANNEL_COLORS)) {
    const item = legend.createEl("span", { cls: "pd-legend-item" });
    const dot = item.createEl("span", { cls: "pd-dot" });
    dot.style.background = color;
    item.appendText(" " + ch);
  }
  // Status legend
  for (const [st, icon] of Object.entries(STATUS_ICON)) {
    const item = legend.createEl("span", { cls: "pd-legend-item pd-legend-status" });
    item.textContent = icon + " " + st;
  }

  // ── Grid ──
  const grid = dv.container.createEl("div", { cls: "pd-grid" });

  for (let i = 0; i < 7; i++) {
    const day = addDays(monday, i);
    const isToday = day.toDateString() === today.toDateString();

    const col = grid.createEl("div", { cls: "pd-col" + (isToday ? " pd-today" : "") });
    col.createEl("div", { cls: "pd-col-header", text: fmtDay(day) });

    const dayCards = cards.filter(c => c.date.toDateString() === day.toDateString());
    dayCards.sort((a, b) => a.date - b.date);

    for (const card of dayCards) {
      renderCard(col, card);
    }
  }

  // ── Unscheduled bucket ──
  if (loose.length > 0) {
    const bucket = dv.container.createEl("div", { cls: "pd-bucket" });
    bucket.createEl("div", { cls: "pd-bucket-header", text: "Unscheduled (" + loose.length + ")" });
    for (const card of loose) {
      renderLooseCard(bucket, card);
    }
  }

  // ── Detail panel (if a card is expanded) ──
  const allCards = [...cards, ...loose];
  const expanded = allCards.find(c => c.key === expandedKey);
  if (expanded) {
    renderDetail(dv.container, expanded);
  }

  injectStyles();
}

function renderCard(parent, card) {
  const isExpanded = expandedKey === card.key;
  const wrap = parent.createEl("div", { cls: "pd-card" + (isExpanded ? " pd-card-active" : "") });

  const top = wrap.createEl("div", { cls: "pd-card-top" });

  const statusIcon = top.createEl("span", { cls: "pd-status-icon" });
  statusIcon.textContent = STATUS_ICON[card.status] ?? "○";

  const titleEl = top.createEl("span", { cls: "pd-card-title" });
  titleEl.textContent = card.title;

  const dot = wrap.createEl("span", { cls: "pd-dot pd-channel-dot" });
  dot.style.background = CHANNEL_COLORS[card.channel] ?? "#888";
  dot.title = card.channel;

  wrap.onclick = () => {
    expandedKey = isExpanded ? null : card.key;
    render();
  };
}

function renderLooseCard(parent, card) {
  const isExpanded = expandedKey === card.key;
  const wrap = parent.createEl("div", { cls: "pd-loose-card" + (isExpanded ? " pd-card-active" : "") });

  const statusIcon = wrap.createEl("span", { cls: "pd-status-icon" });
  statusIcon.textContent = STATUS_ICON[card.status] ?? "○";

  const titleEl = wrap.createEl("span", { cls: "pd-card-title" });
  titleEl.textContent = card.title;

  const dots = wrap.createEl("span", { cls: "pd-dots" });
  for (const ch of (card.channels ?? [])) {
    const dot = dots.createEl("span", { cls: "pd-dot" });
    dot.style.background = CHANNEL_COLORS[ch] ?? "#888";
    dot.title = ch;
  }

  wrap.onclick = () => {
    expandedKey = isExpanded ? null : card.key;
    render();
  };
}

function renderDetail(parent, card) {
  const panel = parent.createEl("div", { cls: "pd-detail" });
  panel.createEl("div", { cls: "pd-detail-title", text: card.title });

  const fm = card.fm;
  const rows = [
    ["Status",       fm.status],
    ["Language",     fm.language],
    ["Proposition",  fm.proposition],
    ["Audience",     fm.audience],
    ["Channels",     fm.channels],
    ["Content type", fm["content-type"]],
    ["Series",       fm["series-title"] ? `${fm["series-title"]} (${fm["series-id"]}, part ${fm["series-part"]})` : null],
    ["Publish dates",fm["publish-dates"] ? Object.entries(fm["publish-dates"]).map(([k,v]) => `${k}: ${v}`).join("\n") : null],
    ["Published URLs",fm["published-urls"] ? Object.entries(fm["published-urls"]).map(([k,v]) => `${k}: ${v}`).join("\n") : null],
    ["Source notes", fm["source-notes"]],
    ["Tags",         fm.tags?.length ? fm.tags : null],
    ["ISO tags",     fm.isotags?.length ? fm.isotags : null],
    ["File",         card.path],
  ];

  const table = panel.createEl("table", { cls: "pd-detail-table" });
  for (const [label, val] of rows) {
    if (val === null || val === undefined || val === "" || (Array.isArray(val) && val.length === 0)) continue;
    const tr = table.createEl("tr");
    tr.createEl("td", { cls: "pd-detail-label", text: label });
    const td = tr.createEl("td", { cls: "pd-detail-value" });
    td.textContent = fmtMeta(val);
  }
}

// ─── STYLES ──────────────────────────────────────────────────────────────────
function injectStyles() {
  const id = "pd-styles";
  if (document.getElementById(id)) return;
  const style = document.createElement("style");
  style.id = id;
  style.textContent = `
    .pd-header {
      display: flex;
      align-items: center;
      gap: 12px;
      margin-bottom: 10px;
    }
    .pd-week-label {
      font-weight: 600;
      font-size: 0.95em;
    }
    .pd-nav-btn {
      padding: 3px 10px;
      border-radius: 4px;
      border: 1px solid var(--interactive-normal);
      background: var(--interactive-normal);
      color: var(--text-normal);
      cursor: pointer;
      font-size: 0.85em;
    }
    .pd-nav-btn:disabled {
      opacity: 0.35;
      cursor: default;
    }
    .pd-legend {
      display: flex;
      flex-wrap: wrap;
      gap: 12px;
      margin-bottom: 14px;
      font-size: 0.8em;
      color: var(--text-muted);
    }
    .pd-legend-item {
      display: flex;
      align-items: center;
      gap: 4px;
    }
    .pd-legend-status {
      margin-left: 8px;
    }
    .pd-grid {
      display: grid;
      grid-template-columns: repeat(7, 1fr);
      gap: 6px;
      margin-bottom: 18px;
    }
    .pd-col {
      background: var(--background-secondary);
      border-radius: 6px;
      padding: 6px;
      min-height: 80px;
    }
    .pd-today {
      outline: 2px solid var(--interactive-accent);
    }
    .pd-col-header {
      font-size: 0.75em;
      font-weight: 600;
      color: var(--text-muted);
      margin-bottom: 6px;
      text-align: center;
    }
    .pd-card {
      background: var(--background-primary);
      border-radius: 4px;
      padding: 5px 7px;
      margin-bottom: 5px;
      cursor: pointer;
      font-size: 0.78em;
      border-left: 3px solid var(--interactive-accent);
      line-height: 1.35;
    }
    .pd-card:hover, .pd-loose-card:hover {
      background: var(--background-modifier-hover);
    }
    .pd-card-active {
      outline: 2px solid var(--interactive-accent);
    }
    .pd-card-top {
      display: flex;
      align-items: flex-start;
      gap: 4px;
    }
    .pd-status-icon {
      flex-shrink: 0;
      font-size: 0.9em;
      margin-top: 1px;
      color: var(--text-muted);
    }
    .pd-card-title {
      flex: 1;
      overflow: hidden;
      display: -webkit-box;
      -webkit-line-clamp: 3;
      -webkit-box-orient: vertical;
    }
    .pd-channel-dot {
      flex-shrink: 0;
      margin-top: 3px;
    }
    .pd-dot {
      display: inline-block;
      width: 8px;
      height: 8px;
      border-radius: 50%;
      flex-shrink: 0;
    }
    .pd-dots {
      display: flex;
      gap: 3px;
      margin-left: 4px;
    }
    .pd-bucket {
      background: var(--background-secondary);
      border-radius: 6px;
      padding: 10px 12px;
      margin-bottom: 16px;
    }
    .pd-bucket-header {
      font-size: 0.8em;
      font-weight: 600;
      color: var(--text-muted);
      margin-bottom: 8px;
      text-transform: uppercase;
      letter-spacing: 0.04em;
    }
    .pd-loose-card {
      display: flex;
      align-items: center;
      gap: 6px;
      background: var(--background-primary);
      border-radius: 4px;
      padding: 5px 8px;
      margin-bottom: 4px;
      cursor: pointer;
      font-size: 0.82em;
    }
    .pd-detail {
      background: var(--background-secondary);
      border-radius: 6px;
      padding: 14px 16px;
      margin-top: 6px;
    }
    .pd-detail-title {
      font-weight: 600;
      margin-bottom: 10px;
      font-size: 0.95em;
    }
    .pd-detail-table {
      border-collapse: collapse;
      font-size: 0.82em;
      width: 100%;
    }
    .pd-detail-table tr {
      border-bottom: 1px solid var(--background-modifier-border);
    }
    .pd-detail-label {
      color: var(--text-muted);
      padding: 4px 12px 4px 0;
      white-space: nowrap;
      vertical-align: top;
      font-weight: 500;
      width: 120px;
    }
    .pd-detail-value {
      padding: 4px 0;
      white-space: pre-wrap;
      word-break: break-word;
    }
  `;
  document.head.appendChild(style);
}

// ─── BOOT ────────────────────────────────────────────────────────────────────
render();