Updates:
So first, I messed around a bit and also now have a 2-week view that can be toggled from the 2-month view, and have attached it here.
Second, it looks like the Tasknotes Plugin has just today released an update (3.0.0) that provides the exact functionality described in this post! It includes a drag-and-drop calendar for notes. There are still some bugs and some features I’d like to see in there (particularly some features I was able to see here in dataviewjs), but I’m certain that as that plugin develops it will cover those!
So I think I can close this topic now that that plugin exists.
(async () => {
const vault = app.vault;
const ws = app.workspace;
const container = dv.container;
const today = new Date();
today.setHours(0, 0, 0, 0);
// Set initial view state: "month" or "biweek"
let viewMode = 'month';
// For month view navigation state:
let year = today.getFullYear();
let month = today.getMonth(); // 0-indexed
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
// ─── Shared CSS ───
if (!document.getElementById("dvCalNightStyles")) {
const st = document.createElement("style");
st.id = "dvCalNightStyles";
st.textContent = `
#dvCalWrapper { background:#000; padding:4px; border-radius:6px; }
#dvCalWrapper button {
background:#111; color:#fff; border:none; padding:3px 6px;
margin:0 1px; border-radius:4px; cursor:pointer; font-size:0.85em;
}
#dvCalWrapper button:hover { background:#222; }
#dvCalWrapper table {
width:100%; table-layout:fixed; border-collapse:collapse;
background:#000; border-radius:6px; overflow:hidden;
box-shadow:0 3px 8px rgba(0,0,0,0.6);
}
#dvCalWrapper th, #dvCalWrapper td { width:14.2857%; }
#dvCalWrapper th {
padding:6px 3px; background:#111; color:#fff;
text-transform:uppercase; font-size:0.7em; font-weight:600;
border-bottom:1px solid #222;
}
#dvCalWrapper td {
padding:6px 3px; background:#000; color:#ccc;
border-bottom:1px solid #222; transition:background .2s;
vertical-align: top;
}
#dvCalWrapper td:hover,
#dvCalWrapper td.drag-over { background:#111; }
#dvCalWrapper td.today { background:#555 !important; }
#dvCalWrapper td.today div { color:#fff !important; }
#dvCalWrapper .ev-pill {
display:block !important; padding:3px 6px; margin-bottom:3px;
border-radius:4px; white-space:normal; font-size:0.8em;
transition:background .2s;
}
#dvCalWrapper .ev-pill:hover { background:rgba(255,255,255,0.06); }
`;
document.head.appendChild(st);
}
// ─── Function: Build Month HTML (same as your original) ───
function buildMonth(y, m, eventsByDay) {
const first = new Date(y, m, 1),
offset = first.getDay(),
days = new Date(y, m + 1, 0).getDate();
let d = 1;
let html = `
<div style="
text-align:center;
font-size:1.2em;
font-weight:bold;
margin-bottom:2px;
color:#fff
">${MONTHS[m]} ${y}</div>
<table>
<thead>
<tr>${WEEKDAYS.map(w => `<th>${w}</th>`).join("")}</tr>
</thead>
<tbody>
`;
for (let wk = 0; wk < 6; wk++) {
html += "<tr>";
for (let wd = 0; wd < 7; wd++) {
if ((wk === 0 && wd < offset) || d > days) {
html += `<td style="height:70px"></td>`;
} else {
const isToday =
y === today.getFullYear() &&
m === today.getMonth() &&
d === today.getDate();
let dayNumberHtml = "";
if (isToday) {
dayNumberHtml = `<div style="font-weight:bold; color:yellow; font-size:1.1em">
${d} ★
</div>`;
} else {
dayNumberHtml = `<div style="font-weight:bold">${d}</div>`;
}
html += `<td class="${isToday ? "today" : ""}"
data-year="${y}"
data-month="${m}"
data-day="${d}"
style="height:70px"
>
${dayNumberHtml}`;
(eventsByDay[d] || []).forEach(n => {
const ach = n.achieved_status, st = n.status;
let icon = "", col = "";
if (ach === true) { icon = "🟢 "; col = "#81c784"; }
else if (st === "obsolete") { icon = "💀 "; col = "#ffb74d"; }
else if (ach === false) { icon = "🔴 "; col = "#e57373"; }
html += `<a class="internal-link ev-pill" draggable="true" data-path="${n.file.path}"
style="color:${col}"
>${icon}${n.file.name}</a>`;
});
html += "</td>";
d++;
}
}
html += "</tr>";
}
html += "</tbody></table>";
return html;
}
// ─── Render the Monthly View ───
function renderMonthView() {
// Gather events for the current month and the following month
const nextM = month < 11 ? month + 1 : 0,
nextY = month < 11 ? year : year + 1;
const ev1 = {}, ev2 = {};
dv.pages(`"=Notes/=Situations"`)
.where(p => p.scheduled)
.forEach(n => {
const dt = dv.date(n.scheduled);
if (!dt) return;
const y = dt.year, m = dt.month - 1, d = dt.day;
if (y === year && m === month) (ev1[d] ||= []).push(n);
if (y === nextY && m === nextM) (ev2[d] ||= []).push(n);
});
const wrap = document.createElement("div");
wrap.id = "dvCalWrapper";
wrap.style.cssText = "display:flex;flex-direction:column;gap:8px";
wrap.innerHTML = `
<div style="text-align:center;font-size:1.8em;font-weight:bold;color:#fff;margin-bottom:2px">
Goals Calendar - Monthly View
</div>
<div style="display:flex;justify-content:center;gap:4px;margin-bottom:2px">
<button id="prev-year">«</button>
<button id="prev-mon">‹</button>
<button id="next-mon">›</button>
<button id="next-year">»</button>
</div>
<form id="year-form" style="display:flex;justify-content:center;gap:4px;margin-bottom:2px">
<input id="year-input" type="number" min="0" value="${year}" style="width:3em;text-align:center"/>
<button type="submit">Go</button>
</form>
<div style="text-align:center;margin-bottom:4px">
<button id="toggle-view">Switch to Two-Week</button>
</div>
<div>${buildMonth(year, month, ev1)}</div>
<div>${buildMonth(nextY, nextM, ev2)}</div>
`;
container.innerHTML = "";
container.appendChild(wrap);
// Navigation event handlers
wrap.querySelector("#prev-year").onclick = () => { year--; render(); };
wrap.querySelector("#next-year").onclick = () => { year++; render(); };
wrap.querySelector("#prev-mon").onclick = () => {
month = month > 0 ? month - 1 : 11;
if (month === 11) year--;
render();
};
wrap.querySelector("#next-mon").onclick = () => {
month = month < 11 ? month + 1 : 0;
if (month === 0) year++;
render();
};
wrap.querySelector("#year-form").onsubmit = e => {
e.preventDefault();
const v = parseInt(wrap.querySelector("#year-input").value, 10);
if (!isNaN(v)) { year = v; render(); }
};
wrap.querySelector("#toggle-view").onclick = () => {
viewMode = 'biweek';
render();
};
// Attach drag & drop and context menu behavior for monthly view
wrap.querySelectorAll(".ev-pill").forEach(a => {
a.addEventListener("dragstart", e => e.dataTransfer.setData("text/plain", a.dataset.path));
a.addEventListener("dragover", e => {
e.preventDefault();
e.target.closest("td")?.classList.add("drag-over");
});
a.addEventListener("dragleave", e => {
e.target.closest("td")?.classList.remove("drag-over");
});
a.addEventListener("drop", e => {
e.preventDefault();
const td = e.target.closest("td");
td?.classList.remove("drag-over");
moveDate(a.dataset.path, td);
});
a.addEventListener("click", e => {
e.preventDefault();
ws.openLinkText(a.dataset.path, "", false);
});
a.addEventListener("contextmenu", e => {
e.preventDefault();
e.stopPropagation();
hideMenu();
const menu = document.createElement("div");
menu.id = "dvCtx";
menu.style.cssText = `
position:absolute;
background:var(--background-primary);
border:1px solid var(--background-modifier-border);
box-shadow:0 2px 8px rgba(0,0,0,0.2);
padding:4px; z-index:1000; font-size:0.9em;
`;
menu.style.left = e.pageX + "px";
menu.style.top = e.pageY + "px";
const mk = (lbl, fn) => {
const it = document.createElement("div");
it.textContent = lbl;
it.style.cssText = "padding:4px 8px; cursor:pointer";
it.onmouseenter = () => it.style.background = "var(--background-modifier-hover)";
it.onmouseleave = () => it.style.background = "";
it.onclick = () => { fn(); hideMenu(); };
return it;
};
menu.append(mk("Open", () => ws.openLinkText(a.dataset.path, "", false)));
menu.append(mk("New Pane", () => ws.openLinkText(a.dataset.path, "", true)));
menu.append(mk("Split →", () => {
const leaf = ws.splitActiveLeaf();
ws.openLinkText(a.dataset.path, "", false, leaf);
}));
document.body.appendChild(menu);
});
});
wrap.addEventListener("dragover", e => {
const td = e.target.closest("td");
if (td) {
e.preventDefault();
td.classList.add("drag-over");
}
});
wrap.addEventListener("dragleave", e => {
const td = e.target.closest("td");
td?.classList.remove("drag-over");
});
wrap.addEventListener("drop", e => {
const td = e.target.closest("td");
if (!td) return;
e.preventDefault();
td.classList.remove("drag-over");
const p = e.dataTransfer.getData("text/plain");
if (p) moveDate(p, td);
});
}
// ─── Render the Two-Week (Biweek) View ───
function renderBiweekView() {
// Calculate start of current week (using Sunday as the starting day)
let curWeekStart = new Date(today);
curWeekStart.setDate(today.getDate() - today.getDay());
let daysArr = [];
for (let i = 0; i < 14; i++) {
let d = new Date(curWeekStart);
d.setDate(curWeekStart.getDate() + i);
daysArr.push(d);
}
// Gather events that fall within the two-week window.
let eventsByDay = {};
const endDate = new Date(curWeekStart);
endDate.setDate(curWeekStart.getDate() + 13);
dv.pages(`"=Notes/=Situations"`)
.where(p => p.scheduled)
.forEach(n => {
const dt = dv.date(n.scheduled);
if (!dt) return;
let sched = new Date(dt.year, dt.month - 1, dt.day);
if (sched >= curWeekStart && sched <= endDate) {
const key = sched.toISOString().split("T")[0];
(eventsByDay[key] ||= []).push(n);
}
});
// Build the biweek calendar with a header row to indicate the week ranges.
let html = `
<div style="text-align:center;font-size:1.6em;font-weight:bold;color:#fff;margin-bottom:2px">
Goals Calendar - Two Week View
</div>
<div style="text-align:center; margin-bottom:4px">
<button id="toggle-view">Switch to Month View</button>
</div>
<table>
<thead>
<tr>${WEEKDAYS.map(d => `<th>${d}</th>`).join("")}</tr>
</thead>
<tbody>
`;
for (let week = 0; week < 2; week++) {
// Add a header row for each week with its date range.
const firstDay = daysArr[week * 7];
const lastDay = daysArr[(week * 7) + 6];
html += `<tr>
<td colspan="7" style="text-align:center;font-weight:bold;color:#fff;background:#111;padding:2px;">
${MONTHS[firstDay.getMonth()]} ${firstDay.getDate()} - ${MONTHS[lastDay.getMonth()]} ${lastDay.getDate()}
</td>
</tr>`;
html += "<tr>";
for (let i = 0; i < 7; i++) {
let dayObj = daysArr[week * 7 + i];
let key = dayObj.toISOString().split("T")[0];
let isToday = dayObj.toDateString() === today.toDateString();
let dayNumberHtml = `<div style="font-weight:bold; ${isToday ? "color:yellow;" : ""}">
${MONTHS[dayObj.getMonth()]} ${dayObj.getDate()}
</div>`;
html += `<td class="${isToday ? "today" : ""}" data-date="${key}" style="height:70px; vertical-align:top;">
${dayNumberHtml}`;
(eventsByDay[key] || []).forEach(n => {
const ach = n.achieved_status, st = n.status;
let icon = "", col = "";
if (ach === true) { icon = "🟢 "; col = "#81c784"; }
else if (st === "obsolete") { icon = "💀 "; col = "#ffb74d"; }
else if (ach === false) { icon = "🔴 "; col = "#e57373"; }
html += `<a class="internal-link ev-pill" draggable="true" data-path="${n.file.path}" style="color:${col}">
${icon}${n.file.name}
</a>`;
});
html += `</td>`;
}
html += "</tr>";
}
html += `</tbody></table>`;
const wrap = document.createElement("div");
wrap.id = "dvCalWrapper";
wrap.style.cssText = "display:flex;flex-direction:column;gap:8px";
wrap.innerHTML = html;
container.innerHTML = "";
container.appendChild(wrap);
wrap.querySelector("#toggle-view").onclick = () => {
viewMode = 'month';
render();
};
// Attach drag & drop and context menu behavior for the biweek view
wrap.querySelectorAll(".ev-pill").forEach(a => {
a.addEventListener("dragstart", e => e.dataTransfer.setData("text/plain", a.dataset.path));
a.addEventListener("dragover", e => {
e.preventDefault();
e.target.closest("td")?.classList.add("drag-over");
});
a.addEventListener("dragleave", e => {
e.target.closest("td")?.classList.remove("drag-over");
});
a.addEventListener("drop", e => {
e.preventDefault();
const td = e.target.closest("td");
td?.classList.remove("drag-over");
moveDate(a.dataset.path, td);
});
a.addEventListener("click", e => {
e.preventDefault();
ws.openLinkText(a.dataset.path, "", false);
});
a.addEventListener("contextmenu", e => {
e.preventDefault();
e.stopPropagation();
hideMenu();
const menu = document.createElement("div");
menu.id = "dvCtx";
menu.style.cssText = `
position:absolute;
background:var(--background-primary);
border:1px solid var(--background-modifier-border);
box-shadow:0 2px 8px rgba(0,0,0,0.2);
padding:4px; z-index:1000; font-size:0.9em;
`;
menu.style.left = e.pageX + "px";
menu.style.top = e.pageY + "px";
const mk = (lbl, fn) => {
const it = document.createElement("div");
it.textContent = lbl;
it.style.cssText = "padding:4px 8px; cursor:pointer";
it.onmouseenter = () => it.style.background = "var(--background-modifier-hover)";
it.onmouseleave = () => it.style.background = "";
it.onclick = () => { fn(); hideMenu(); };
return it;
};
menu.append(mk("Open", () => ws.openLinkText(a.dataset.path, "", false)));
menu.append(mk("New Pane", () => ws.openLinkText(a.dataset.path, "", true)));
menu.append(mk("Split →", () => {
const leaf = ws.splitActiveLeaf();
ws.openLinkText(a.dataset.path, "", false, leaf);
}));
document.body.appendChild(menu);
});
});
wrap.addEventListener("dragover", e => {
const td = e.target.closest("td");
if (td) {
e.preventDefault();
td.classList.add("drag-over");
}
});
wrap.addEventListener("dragleave", e => {
const td = e.target.closest("td");
td?.classList.remove("drag-over");
});
wrap.addEventListener("drop", e => {
const td = e.target.closest("td");
if (!td) return;
e.preventDefault();
td.classList.remove("drag-over");
const p = e.dataTransfer.getData("text/plain");
if (p) moveDate(p, td);
});
}
// ─── Move Date Helper (works for both views) ───
async function moveDate(path, td) {
const file = vault.getAbstractFileByPath(path);
const content = await vault.read(file);
let newDate;
// For biweek view the td has a data-date attribute; if not, use monthly view attributes.
if (td.dataset.date) {
newDate = td.dataset.date;
} else {
const dd = String(td.dataset.day).padStart(2, "0");
const mm = String(parseInt(td.dataset.month, 10) + 1).padStart(2, "0");
newDate = `${td.dataset.year}-${mm}-${dd}`;
}
const upd = content.match(/^scheduled:/m)
? content.replace(/^scheduled:\s*\d{4}-\d{2}-\d{2}/m, `scheduled: ${newDate}`)
: content.replace(/^---\n/, `---\nscheduled: ${newDate}\n`);
await vault.modify(file, upd);
render();
}
function hideMenu() {
const oldMenu = document.getElementById("dvCtx");
if (oldMenu) oldMenu.remove();
}
function render() {
if (viewMode === 'month') renderMonthView();
else renderBiweekView();
}
render();
})();