diff --git a/public/js/app.js b/public/js/app.js
index 6e0a50f..c4bd1c5 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -15,6 +15,7 @@ import { showContextMenu } from './modules/context-menu.js';
import { undo, canUndo } from './modules/undo.js';
import { openGallery, closeGallery } from './modules/gallery.js';
import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js';
+import { openCalendar, closeCalendar } from './modules/calendar.js';
// ── Global API (for inline onclick handlers and cross-module calls) ──
@@ -24,6 +25,7 @@ window._openGallery = openGallery;
window._closeSearch = closeSearch;
window._closeTasks = closeTasks;
window._renderBoard = renderBoard;
+window._openCalendar = openCalendar;
window._renderTagBar = renderTagBar;
window._showContextMenu = showContextMenu;
window._ungroupCard = ungroupCard;
diff --git a/public/js/modules/calendar.js b/public/js/modules/calendar.js
new file mode 100644
index 0000000..6d8e6ff
--- /dev/null
+++ b/public/js/modules/calendar.js
@@ -0,0 +1,254 @@
+// ── Calendar view ──
+
+import { $, esc } from './util.js';
+import { state } from './state.js';
+import { stripMd, getTodos } from './markdown.js';
+
+const overlay = $('calendar');
+const grid = $('cal-grid');
+const title = $('cal-title');
+const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
+const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
+
+let viewYear, viewMonth; // 0-indexed month
+
+function pad(n) { return String(n).padStart(2, '0'); }
+
+function dateStr(y, m, d) { return `${y}-${pad(m + 1)}-${pad(d)}`; }
+
+function cardDate(c) {
+ // Title starts with date (journal "2026-04-08" or appointment "2026-04-08 — Meeting")
+ const m = (c.title || '').match(/^(\d{4}-\d{2}-\d{2})/);
+ if (m) return m[1];
+ // Fallback: created date
+ return (c.created_at || '').substring(0, 10);
+}
+
+function getCardsForDate(ds) {
+ return state.cards.filter(c => cardDate(c) === ds);
+}
+
+function isJournal(card) { return /^\d{4}-\d{2}-\d{2}/.test(card.title); }
+
+function render() {
+ title.textContent = `${MONTHS[viewMonth]} ${viewYear}`;
+
+ const firstDay = new Date(viewYear, viewMonth, 1).getDay(); // 0=Sun
+ const startOffset = (firstDay + 6) % 7; // Mon=0
+ const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
+ const prevMonthDays = new Date(viewYear, viewMonth, 0).getDate();
+ const today = new Date();
+ const todayStr = dateStr(today.getFullYear(), today.getMonth(), today.getDate());
+
+ // Always 6 rows = 42 cells for consistent layout
+ const totalCells = 42;
+
+ let html = '';
+ html += '
';
+ for (let i = 0; i < totalCells; i++) {
+ const dayNum = i - startOffset + 1;
+ const inMonth = dayNum >= 1 && dayNum <= daysInMonth;
+ let ds = '', displayNum = dayNum;
+ if (dayNum < 1) {
+ // Previous month
+ displayNum = prevMonthDays + dayNum;
+ const pm = viewMonth === 0 ? 11 : viewMonth - 1;
+ const py = viewMonth === 0 ? viewYear - 1 : viewYear;
+ ds = dateStr(py, pm, displayNum);
+ } else if (dayNum > daysInMonth) {
+ // Next month
+ displayNum = dayNum - daysInMonth;
+ const nm = viewMonth === 11 ? 0 : viewMonth + 1;
+ const ny = viewMonth === 11 ? viewYear + 1 : viewYear;
+ ds = dateStr(ny, nm, displayNum);
+ } else {
+ ds = dateStr(viewYear, viewMonth, dayNum);
+ }
+ const isToday = ds === todayStr;
+ const cards = getCardsForDate(ds);
+ const journals = cards.filter(isJournal);
+ const others = cards.filter(c => !isJournal(c));
+
+ html += `
`;
+ html += `
${displayNum}
`;
+ html += '
';
+ for (const c of journals) {
+ const body = stripMd(c.body || '').substring(0, 60);
+ const todos = getTodos(c.body);
+ html += `
`;
+ if (todos) html += `${todos.done}/${todos.total}`;
+ html += `${esc(body || '(empty)')}
`;
+ }
+ for (const c of others) {
+ html += `
`;
+ html += `${esc(c.title)}
`;
+ }
+ html += '
';
+ html += '
';
+ }
+ html += '
';
+
+ grid.innerHTML = html;
+
+ // Click on card item → open card
+ grid.querySelectorAll('[data-card-id]').forEach(el => {
+ el.addEventListener('click', e => {
+ e.stopPropagation();
+ closeDayPopup();
+ state._returnToCalendar = true;
+ window._openDetail(el.dataset.cardId);
+ });
+ });
+ // Click on cell → open day popup
+ grid.querySelectorAll('.cal-cell[data-date]').forEach(cell => {
+ cell.addEventListener('click', e => {
+ if (e.target.closest('[data-card-id]')) return;
+ e.stopPropagation();
+ openDayPopup(cell.dataset.date, cell);
+ });
+ });
+}
+
+function navigate(delta) {
+ closeDayPopup();
+ viewMonth += delta;
+ if (viewMonth > 11) { viewMonth = 0; viewYear++; }
+ if (viewMonth < 0) { viewMonth = 11; viewYear--; }
+ render();
+}
+
+// ── Day popup ──
+
+let dayPopup = null;
+
+function closeDayPopup() {
+ if (dayPopup) { dayPopup.remove(); dayPopup = null; }
+}
+
+function openDayPopup(ds, cellEl) {
+ closeDayPopup();
+ const cards = getCardsForDate(ds);
+ const rect = cellEl.getBoundingClientRect();
+
+ dayPopup = document.createElement('div');
+ Object.assign(dayPopup.style, {
+ position: 'fixed', zIndex: '200',
+ background: 'var(--t-surface)', border: '1px solid var(--t-border)',
+ boxShadow: '0 8px 32px rgba(0,0,0,0.4)', borderRadius: '4px',
+ minWidth: '240px', maxWidth: '360px', maxHeight: '320px',
+ display: 'flex', flexDirection: 'column', overflow: 'hidden',
+ });
+
+ // Position near the cell
+ const x = Math.min(rect.left + rect.width / 2 - 120, window.innerWidth - 370);
+ const y = rect.bottom + 4 > window.innerHeight - 100 ? rect.top - 4 : rect.bottom + 4;
+ dayPopup.style.left = Math.max(8, x) + 'px';
+ if (rect.bottom + 4 > window.innerHeight - 100) {
+ dayPopup.style.bottom = (window.innerHeight - rect.top + 4) + 'px';
+ } else {
+ dayPopup.style.top = y + 'px';
+ }
+
+ // Header
+ const header = document.createElement('div');
+ header.style.cssText = 'padding:8px 12px;border-bottom:1px solid var(--t-border);display:flex;align-items:center;gap:8px;flex-shrink:0;';
+ header.innerHTML = `
+
${ds}
+
${cards.length} card${cards.length !== 1 ? 's' : ''}
+
+
+ `;
+ dayPopup.appendChild(header);
+
+ // Card list
+ const list = document.createElement('div');
+ list.style.cssText = 'overflow-y:auto;flex:1;padding:4px 0;';
+ if (cards.length) {
+ list.innerHTML = cards.map(c => {
+ const isJ = isJournal(c);
+ const label = isJ ? (stripMd(c.body || '').substring(0, 80) || '(empty journal)') : c.title;
+ const todos = getTodos(c.body);
+ return ``;
+ }).join('');
+ } else {
+ list.innerHTML = '
no cards
';
+ }
+ dayPopup.appendChild(list);
+
+ document.body.appendChild(dayPopup);
+
+ // Hover effect
+ dayPopup.querySelectorAll('.day-popup-card').forEach(el => {
+ el.addEventListener('mouseenter', () => { el.style.background = 'var(--t-raised)'; });
+ el.addEventListener('mouseleave', () => { el.style.background = ''; });
+ el.addEventListener('click', () => {
+ closeDayPopup();
+ state._returnToCalendar = true;
+ window._openDetail(el.dataset.cardId);
+ });
+ });
+
+ // New card buttons
+ dayPopup.querySelectorAll('.day-popup-new').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const type = btn.dataset.type;
+ closeDayPopup();
+ state._returnToCalendar = true;
+ closeCalendar();
+ if (type === 'journal') {
+ const existing = state.cards.find(c => c.title === ds);
+ if (existing) {
+ window._openDetail(existing.id);
+ return;
+ }
+ window._openModal({ id: null, title: ds, body: '', color: '#38bdf8', bucket_id: null });
+ } else if (type === 'appointment') {
+ const body = `**Zeit:** \n**Ort:** \n**Teilnehmer:** \n\n---\n\n**Notizen:**\n\n\n\n#termin`;
+ window._openModal({ id: null, title: `${ds} — `, body, color: '#fb923c', bucket_id: null });
+ }
+ });
+ });
+
+ // Close on click outside
+ setTimeout(() => {
+ const closeHandler = e => {
+ if (dayPopup && !dayPopup.contains(e.target)) {
+ closeDayPopup();
+ document.removeEventListener('click', closeHandler);
+ }
+ };
+ document.addEventListener('click', closeHandler);
+ }, 0);
+}
+
+export function openCalendar() {
+ const now = new Date();
+ viewYear = now.getFullYear();
+ viewMonth = now.getMonth();
+ render();
+ overlay.classList.add('open');
+}
+
+export function closeCalendar() {
+ closeDayPopup();
+ overlay.classList.remove('open');
+}
+
+$('calendar-btn')?.addEventListener('click', openCalendar);
+$('cal-close')?.addEventListener('click', closeCalendar);
+$('cal-prev')?.addEventListener('click', () => navigate(-1));
+$('cal-next')?.addEventListener('click', () => navigate(1));
+$('cal-today')?.addEventListener('click', () => {
+ const now = new Date();
+ viewYear = now.getFullYear();
+ viewMonth = now.getMonth();
+ render();
+});
+overlay?.addEventListener('click', e => { if (e.target === overlay) closeCalendar(); });
diff --git a/public/js/modules/detail.js b/public/js/modules/detail.js
index 7f8ec6e..b30f1ee 100644
--- a/public/js/modules/detail.js
+++ b/public/js/modules/detail.js
@@ -388,6 +388,10 @@ export function closeDetail(options = {}) {
state.galleryOrigin = null;
if (restoreGallery && !galleryOpen) window._openGallery?.();
}
+ if (state._returnToCalendar) {
+ state._returnToCalendar = false;
+ window._openCalendar?.();
+ }
}
export function showDetailCard(id) {
diff --git a/public/js/modules/editor.js b/public/js/modules/editor.js
index 23fd992..5495d44 100644
--- a/public/js/modules/editor.js
+++ b/public/js/modules/editor.js
@@ -302,6 +302,10 @@ export function closeModal({ animate = true } = {}) {
modal.classList.remove('open');
state.editingId = null;
state.returnToGalleryAfterEdit = false;
+ if (state._returnToCalendar) {
+ state._returnToCalendar = false;
+ window._openCalendar?.();
+ }
}
function updatePreview() {