--- title: Content Calendar notetype: other tags: [] --- # Content Calendar ```dataviewjs // ─── 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(); ```