Created dashboard for content scheduling

This commit is contained in:
Richard Kranendonk 2026-06-03 14:05:14 +02:00
parent 21f6d48b8a
commit 0b4734927a
6 changed files with 509 additions and 3 deletions

View file

@ -0,0 +1,441 @@
---
title: "Posts Dashboard"
notetype: other
tags: []
---
# Posts Dashboard
```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();
```

View file

@ -29,7 +29,7 @@ tags: []
Good intentions don't scale.
Information security often hinges on that one IT administrator who always asks a control question before committing a change. The power user that (MORE EXAMPLES WILL BE ADDED LATER) . And that's great — until they leave, change roles, or get overloaded.
Information security often hinges on key employees: that one IT administrator who always asks a control question before committing a change. The power user that (MORE EXAMPLES WILL BE ADDED LATER) . And that's great — until they leave, change roles, or get overloaded.
You don't need more 'awareness' in your organization. You need a process that keeps working, even when people change, tools change, and regulations change. A process that makes risks visible, assigns ownership, and allows for correction before things go wrong.