// ── Calendar view ── import { $, esc } from './util.js'; import { state } from './state.js'; import { stripMd, getTodos } from './markdown.js'; import { autoSync } from './sync.js'; import { getCalConfig, getAllCalConfigs } from './caldav.js'; import { renderCardHtml } from './board.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']; // Calendar view year/month stored in state.calYear/state.calMonth function pad(n) { return String(n).padStart(2, '0'); } function dateStr(y, m, d) { return `${y}-${pad(m + 1)}-${pad(d)}`; } function cardBaseDate(c) { const m = (c.title || '').match(/^(\d{4}-\d{2}-\d{2})/); if (m) return m[1]; return (c.created_at || '').substring(0, 10); } function parseRRule(rrule) { if (!rrule) return null; const parts = {}; for (const p of rrule.split(';')) { const [k, v] = p.split('='); parts[k] = v; } return parts; } function matchesRRule(card, targetDateStr) { const rrule = parseRRule(card.caldav_rrule); if (!rrule || !rrule.FREQ) return false; const base = cardBaseDate(card); if (!base) return false; const baseDate = new Date(base + 'T00:00:00'); const target = new Date(targetDateStr + 'T00:00:00'); // Don't match before the start date if (target < baseDate) return false; // Same date is handled by cardBaseDate match if (targetDateStr === base) return false; // COUNT / UNTIL limits const count = rrule.COUNT ? parseInt(rrule.COUNT) : null; const until = rrule.UNTIL ? new Date(rrule.UNTIL.substring(0, 4) + '-' + rrule.UNTIL.substring(4, 6) + '-' + rrule.UNTIL.substring(6, 8) + 'T23:59:59') : null; if (until && target > until) return false; const interval = parseInt(rrule.INTERVAL || '1'); const freq = rrule.FREQ; if (freq === 'DAILY') { const diff = Math.round((target - baseDate) / 86400000); if (diff % interval !== 0) return false; return !count || (diff / interval) < count; } if (freq === 'WEEKLY') { const diff = Math.round((target - baseDate) / 86400000); if (rrule.BYDAY) { const dayMap = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 }; const allowed = rrule.BYDAY.split(',').map(d => dayMap[d]); if (!allowed.includes(target.getDay())) return false; const weekDiff = Math.floor(diff / 7); return weekDiff % interval === 0 || (weekDiff + 1) % interval === 0; } if (target.getDay() !== baseDate.getDay()) return false; const weekDiff = Math.round(diff / 7); return weekDiff % interval === 0 && (!count || weekDiff < count); } if (freq === 'MONTHLY') { if (target.getDate() !== baseDate.getDate()) return false; const monthDiff = (target.getFullYear() - baseDate.getFullYear()) * 12 + target.getMonth() - baseDate.getMonth(); return monthDiff > 0 && monthDiff % interval === 0 && (!count || monthDiff / interval < count); } if (freq === 'YEARLY') { if (target.getMonth() !== baseDate.getMonth() || target.getDate() !== baseDate.getDate()) return false; const yearDiff = target.getFullYear() - baseDate.getFullYear(); return yearDiff > 0 && yearDiff % interval === 0 && (!count || yearDiff / interval < count); } return false; } function getMultiDayEnd(card) { const m = (card.body || '').match(/\*\*Bis:\*\*\s*(\d{4}-\d{2}-\d{2})/i); return m ? m[1] : null; } function getCardsForDate(ds) { const results = []; const targetYear = parseInt(ds.substring(0, 4)); for (const c of state.cards) { const base = cardBaseDate(c); if (base === ds) { results.push({ ...c, _recurring: false }); } else if (base && base < ds) { // Multi-day event: check if ds is within [base..end] const end = getMultiDayEnd(c); if (end && ds <= end) { const startMs = Date.parse(base); const curMs = Date.parse(ds); const dayNum = Math.round((curMs - startMs) / 86400000) + 1; const totalDays = Math.round((Date.parse(end) - startMs) / 86400000) + 1; results.push({ ...c, _recurring: false, _multiDay: { day: dayNum, total: totalDays, isEnd: ds === end } }); } } if (c.caldav_rrule && matchesRRule(c, ds)) { const baseYear = parseInt(base.substring(0, 4)); const rrule = parseRRule(c.caldav_rrule); const age = rrule?.FREQ === 'YEARLY' ? targetYear - baseYear : null; if (base !== ds) results.push({ ...c, _recurring: true, _age: age }); } else if ((c.tags || '').split(',').includes('geburtstag')) { if (base.substring(5) === ds.substring(5) && ds !== base) { const age = targetYear - parseInt(base.substring(0, 4)); results.push({ ...c, _recurring: true, _age: age > 0 ? age : null }); } } } return results; } function getCardCalConfig(card) { const tags = (card.tags || '').split(','); // Find a tag that matches a calendar config (skip termin/geburtstag) for (const t of tags) { if (t && t !== 'termin' && t !== 'geburtstag') { const cfg = getCalConfig(t); if (cfg) return cfg; } } return null; } function isJournal(card) { if (!/^\d{4}-\d{2}-\d{2}/.test(card.title)) return false; // Appointments (termin/geburtstag/caldav) are NOT journals const tags = (card.tags || '').split(','); if (tags.includes('termin') || tags.includes('geburtstag')) return false; if (card.caldav_uid) return false; // "YYYY-MM-DD — Something" is an appointment, pure "YYYY-MM-DD" is journal if (/^\d{4}-\d{2}-\d{2}\s*[—-]\s*.+/.test(card.title)) return false; return true; } function isAppointment(card) { const tags = (card.tags || '').split(','); return tags.includes('termin') || card.caldav_uid || /^\d{4}-\d{2}-\d{2}\s*[—-]\s*.+/.test(card.title); } let calHover = null, calHoverTimer = null; function clearCalHover() { if (calHoverTimer) { clearTimeout(calHoverTimer); calHoverTimer = null; } if (calHover) { calHover.remove(); calHover = null; } } function render() { // Format: [MM-DD] Mon Y [KW-KW] // MM-DD = today's month-day // Mon Y = displayed month + year (abbreviated) // KW-KW = ISO week range spanning the displayed month const isoWeek = (y, m, d) => { const dt = new Date(Date.UTC(y, m, d)); const dayNum = dt.getUTCDay() || 7; dt.setUTCDate(dt.getUTCDate() + 4 - dayNum); const yearStart = new Date(Date.UTC(dt.getUTCFullYear(), 0, 1)); return Math.ceil((((dt - yearStart) / 86400000) + 1) / 7); }; const pad = n => String(n).padStart(2, '0'); const nowDate = new Date(); const todayMd = `${pad(nowDate.getMonth() + 1)}-${pad(nowDate.getDate())}`; const monAbbr = MONTHS[state.calMonth].substring(0, 3); const lastDay = new Date(state.calYear, state.calMonth + 1, 0).getDate(); const wStart = isoWeek(state.calYear, state.calMonth, 1); const wEnd = isoWeek(state.calYear, state.calMonth, lastDay); title.innerHTML = `[${todayMd}] ${monAbbr} ${state.calYear} [${pad(wStart)}-${pad(wEnd)}]`; const firstDay = new Date(state.calYear, state.calMonth, 1).getDay(); // 0=Sun const startOffset = (firstDay + 6) % 7; // Mon=0 const daysInMonth = new Date(state.calYear, state.calMonth + 1, 0).getDate(); const prevMonthDays = new Date(state.calYear, state.calMonth, 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 = '
'; for (const d of DAYS) html += `
${d}
`; 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 = state.calMonth === 0 ? 11 : state.calMonth - 1; const py = state.calMonth === 0 ? state.calYear - 1 : state.calYear; ds = dateStr(py, pm, displayNum); } else if (dayNum > daysInMonth) { // Next month displayNum = dayNum - daysInMonth; const nm = state.calMonth === 11 ? 0 : state.calMonth + 1; const ny = state.calMonth === 11 ? state.calYear + 1 : state.calYear; ds = dateStr(ny, nm, displayNum); } else { ds = dateStr(state.calYear, state.calMonth, dayNum); } const isToday = ds === todayStr; const cards = getCardsForDate(ds); const isBday = c => (c._recurring && c._age != null) || (c.tags || '').split(',').includes('geburtstag'); const birthdays = cards.filter(isBday); const appointments = cards.filter(c => !isBday(c) && isAppointment(c)); const journals = cards.filter(c => !isBday(c) && !isAppointment(c) && isJournal(c)); const others = cards.filter(c => !isBday(c) && !isAppointment(c) && !isJournal(c)); html += `
`; html += `
${displayNum}
`; html += '
'; // Birthdays: red, cake icon, name only for (const c of birthdays) { const name = (c.title || '').replace(/^\d{4}-\d{2}-\d{2}\s*(?:—|-)\s*/, '') || c.title; const age = c._age != null ? c._age : (parseInt(ds.substring(0, 4)) - parseInt(cardBaseDate(c).substring(0, 4))) || null; html += `
`; html += ``; html += `${esc(name)}`; if (age) html += `${age}`; html += `
`; } // Appointments: show event name + optional time, with calendar color/icon for (const c of appointments) { const name = (c.title || '').replace(/^\d{4}-\d{2}-\d{2}\s*(?:—|-)\s*/, '') || c.title; const timeMatch = (c.body || '').match(/\*\*Zeit:\*\*\s*(\d{1,2}:\d{2})/); const timeStr = timeMatch ? timeMatch[1] : ''; const calCfg = getCardCalConfig(c); const itemColor = calCfg?.color || c.color; const itemIcon = calCfg?.icon || null; const md = c._multiDay; html += `
`; if (itemIcon) html += ``; if (md) html += `${md.day}/${md.total}`; else if (timeStr) html += `${timeStr}`; if (c._recurring && !itemIcon) html += ``; html += `${esc(name)}`; html += `
`; } // Journals 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)')}
`; } // Other cards for (const c of others) { html += `
`; if (c._recurring) html += ``; else if (c.caldav_uid) html += ``; const label = c._recurring ? (c.title || '').replace(/^\d{4}-\d{2}-\d{2}\s*(?:—|-)\s*/, '') || c.title : c.title; html += `${esc(label)}`; html += `
`; } html += '
'; html += '
'; } html += '
'; grid.innerHTML = html; // Hover card preview — clear any stale popups from previous render clearCalHover(); // Click on card item → open card (hide calendar, restore on return) grid.querySelectorAll('[data-card-id]').forEach(el => { let clickTimer = null; el.addEventListener('mouseenter', () => { clearCalHover(); calHoverTimer = setTimeout(() => { const card = state.cards.find(c => c.id == el.dataset.cardId); if (!card) return; const rect = el.getBoundingClientRect(); const textEl = el.querySelector('.truncate') || el; const textRect = textEl.getBoundingClientRect(); calHover = document.createElement('div'); Object.assign(calHover.style, { position: 'fixed', zIndex: '210', width: '280px', filter: 'drop-shadow(0 8px 24px rgba(0,0,0,0.4))', }); const left = textRect.right + 4; const useLeft = left + 290 < window.innerWidth; calHover.style.left = (useLeft ? left : textRect.left - 284) + 'px'; calHover.style.top = Math.min(rect.top - 10, window.innerHeight - 320) + 'px'; calHover.innerHTML = renderCardHtml(card); calHover.addEventListener('mouseenter', () => { if (calHoverTimer) { clearTimeout(calHoverTimer); calHoverTimer = null; } }); calHover.addEventListener('mouseleave', () => clearCalHover()); calHover.addEventListener('mouseover', e => e.stopPropagation()); document.body.appendChild(calHover); }, 400); }); el.addEventListener('mouseleave', () => { if (calHoverTimer) { clearTimeout(calHoverTimer); calHoverTimer = null; } setTimeout(() => { if (calHover && !calHover.matches(':hover')) clearCalHover(); }, 150); }); el.addEventListener('click', e => { e.stopPropagation(); clearCalHover(); if (clickTimer) return; clickTimer = setTimeout(() => { clickTimer = null; closeDayPopup(); state._returnToCalendar = true; overlay.style.visibility = 'hidden'; window._openDetail(el.dataset.cardId); }, 250); }); el.addEventListener('dblclick', e => { e.stopPropagation(); clearCalHover(); if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; } closeDayPopup(); state._returnToCalendar = true; closeCalendar(); const card = state.cards.find(c => c.id == el.dataset.cardId); if (card) window._openModal(card); }); el.addEventListener('contextmenu', e => { e.preventDefault(); e.stopPropagation(); const card = state.cards.find(c => c.id == el.dataset.cardId); if (card) window._showContextMenu(e.clientX, e.clientY, card); }); }); // 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(); state.calMonth += delta; if (state.calMonth > 11) { state.calMonth = 0; state.calYear++; } if (state.calMonth < 0) { state.calMonth = 11; state.calYear--; } 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: '360px', maxWidth: '520px', maxHeight: '320px', display: 'flex', flexDirection: 'column', overflow: 'hidden', }); // Position near the cell const x = Math.min(rect.left + rect.width / 2 - 180, window.innerWidth - 530); 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;'; const allCfg = getAllCalConfigs(); const calSlugs = Object.keys(allCfg).filter(s => s !== 'geburtstage' && s !== 'birthday'); const calOptions = `` + calSlugs.map(slug => { const cfg = allCfg[slug]; return ``; }).join(''); 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 isBday = c._recurring && c._age != null; const isJ = isJournal(c) && !c._recurring; const name = (c.title || '').replace(/^\d{4}-\d{2}-\d{2}\s*(?:—|-)\s*/, '') || c.title; const popupLabel = isBday ? name : c._recurring ? name : isJ ? (stripMd(c.body || '').substring(0, 80) || '(empty journal)') : c.title; const todos = getTodos(c.body); const dotColor = isBday ? '#fb7185' : c.color; return `
${isBday ? '' : ''} ${esc(popupLabel)} ${isBday ? `${c._age}` : ''} ${c._recurring && !isBday ? 'recurring' : ''} ${isJ ? 'journal' : ''} ${todos ? `${todos.done}/${todos.total}` : ''}
`; }).join(''); } else { list.innerHTML = ''; } dayPopup.appendChild(list); document.body.appendChild(dayPopup); // Hover effect dayPopup.querySelectorAll('.day-popup-card').forEach(el => { let clickTimer = null; el.addEventListener('mouseenter', () => { el.style.background = 'var(--t-raised)'; }); el.addEventListener('mouseleave', () => { el.style.background = ''; }); el.addEventListener('click', () => { if (clickTimer) return; clickTimer = setTimeout(() => { clickTimer = null; closeDayPopup(); state._returnToCalendar = true; overlay.style.visibility = 'hidden'; window._openDetail(el.dataset.cardId); }, 250); }); el.addEventListener('dblclick', () => { if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; } closeDayPopup(); state._returnToCalendar = true; closeCalendar(); const card = state.cards.find(c => c.id == el.dataset.cardId); if (card) window._openModal(card); }); el.addEventListener('contextmenu', e => { e.preventDefault(); e.stopPropagation(); const card = state.cards.find(c => c.id == el.dataset.cardId); if (card) window._showContextMenu(e.clientX, e.clientY, card); }); }); // New card buttons dayPopup.querySelectorAll('.day-popup-new').forEach(btn => { btn.addEventListener('click', () => { const type = btn.dataset.type; // Read select before closing popup const select = dayPopup?.querySelector('#day-popup-cal'); const selectedCal = select?.value || ''; 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 calSlug = selectedCal; const cfg = calSlug ? getCalConfig(calSlug) : null; const col = cfg?.color || '#fb923c'; const calTag = calSlug ? ` #${calSlug}` : ''; const body = `**Zeit:** \n**Ort:** \n**Teilnehmer:** \n\n---\n\n**Notizen:**\n\n\n\n#termin${calTag}`; window._openModal({ id: null, title: `${ds} — `, body, color: col, bucket_id: null }); } else if (type === 'birthday') { const body = `#geburtstag`; window._openModal({ id: null, title: `${ds} — `, body, color: '#fb7185', bucket_id: null }); } }); }); // Close on click outside or ESC setTimeout(() => { const clickHandler = e => { if (dayPopup && !dayPopup.contains(e.target)) { closeDayPopup(); cleanup(); } }; const keyHandler = e => { if (e.key === 'Escape') { closeDayPopup(); cleanup(); } }; const cleanup = () => { document.removeEventListener('click', clickHandler); document.removeEventListener('keydown', keyHandler); }; document.addEventListener('click', clickHandler); document.addEventListener('keydown', keyHandler); }, 0); } export function refreshCalendar() { if (overlay?.classList.contains('open')) render(); } export function openCalendar() { // Only jump to today on first open — preserve the user's navigation // when the calendar is re-opened (e.g. after saving a card in a // different month). if (state.calYear == null || state.calMonth == null) { const now = new Date(); state.calYear = now.getFullYear(); state.calMonth = now.getMonth(); } render(); overlay.classList.add('open'); overlay.style.visibility = ''; autoSync(); } export function closeCalendar() { closeDayPopup(); overlay.classList.remove('open'); if (document.activeElement && document.activeElement !== document.body) { document.activeElement.blur(); } } $('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(); state.calYear = now.getFullYear(); state.calMonth = now.getMonth(); render(); }); overlay?.addEventListener('click', e => { if (e.target === overlay) closeCalendar(); });