Files
hyprcrd/public/js/modules/calendar.js
T
thandClaude Opus 4.6 ccba2932c2 calendar: include Bis: in the appointment-create template
CalDavSync already reads **Bis:** out of the body on push (and writes
it on pull), but the in-app template used when the user clicks a
calendar day to create an appointment didn't offer the field —
forcing the user to type the marker from scratch for multi-day or
ranged events. Drop it in next to **Zeit:** so the round-trip is
symmetric.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:23:13 +02:00

566 lines
27 KiB
JavaScript

// ── 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 = `<span style="color:var(--t-dim)">[${todayMd}]</span> ${monAbbr} ${state.calYear} <span style="color:var(--t-dim)">[${pad(wStart)}-${pad(wEnd)}]</span>`;
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 = '<div class="cal-header">';
for (const d of DAYS) html += `<div class="cal-day-label">${d}</div>`;
html += '</div>';
html += '<div class="cal-body">';
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 += `<div class="cal-cell ${inMonth ? '' : 'cal-cell-out'} ${isToday ? 'cal-cell-today' : ''}" data-date="${ds}">`;
html += `<div class="cal-cell-num ${isToday ? 'text-t-accent' : ''}">${displayNum}</div>`;
html += '<div class="cal-cell-items">';
// 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 += `<div class="cal-item" data-card-id="${c.id}" style="border-left-color:#fb7185;color:#fb7185;">`;
html += `<i class="ph ph-cake" style="font-size:9px;flex-shrink:0;"></i>`;
html += `<span class="truncate">${esc(name)}</span>`;
if (age) html += `<span style="font-family:var(--t-font-pixel);font-size:8px;flex-shrink:0;opacity:0.7;">${age}</span>`;
html += `</div>`;
}
// 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 += `<div class="cal-item" data-card-id="${c.id}" style="border-left-color:${itemColor};">`;
if (itemIcon) html += `<i class="ph ph-${itemIcon}" style="font-size:9px;color:${itemColor};flex-shrink:0;"></i>`;
if (md) html += `<span style="font-family:var(--t-font-pixel);font-size:8px;color:${itemColor};flex-shrink:0;">${md.day}/${md.total}</span>`;
else if (timeStr) html += `<span style="font-family:var(--t-font-pixel);font-size:8px;color:var(--t-dim);flex-shrink:0;">${timeStr}</span>`;
if (c._recurring && !itemIcon) html += `<i class="ph ph-arrows-clockwise" style="font-size:8px;color:var(--t-dim);flex-shrink:0;"></i>`;
html += `<span class="truncate">${esc(name)}</span>`;
html += `</div>`;
}
// Journals
for (const c of journals) {
const body = stripMd(c.body || '').substring(0, 60);
const todos = getTodos(c.body);
html += `<div class="cal-item cal-item-journal" data-card-id="${c.id}" style="border-left-color:${c.color}">`;
if (todos) html += `<span class="cal-todo">${todos.done}/${todos.total}</span>`;
html += `<span class="truncate">${esc(body || '(empty)')}</span></div>`;
}
// Other cards
for (const c of others) {
html += `<div class="cal-item" data-card-id="${c.id}" style="border-left-color:${c.color}">`;
if (c._recurring) html += `<i class="ph ph-arrows-clockwise" style="font-size:9px;color:var(--t-dim);flex-shrink:0;"></i>`;
else if (c.caldav_uid) html += `<i class="ph ph-cloud-check" style="font-size:9px;color:var(--t-dim);flex-shrink:0;"></i>`;
const label = c._recurring ? (c.title || '').replace(/^\d{4}-\d{2}-\d{2}\s*(?:—|-)\s*/, '') || c.title : c.title;
html += `<span class="truncate">${esc(label)}</span>`;
html += `</div>`;
}
html += '</div>';
html += '</div>';
}
html += '</div>';
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 = `<option value="" style="color:var(--t-muted)">standard</option>` +
calSlugs.map(slug => {
const cfg = allCfg[slug];
return `<option value="${slug}" style="color:${cfg.color || '#fb923c'}">${slug}</option>`;
}).join('');
header.innerHTML = `
<span style="font-family:var(--t-font-pixel);font-size:11px;color:var(--t-accent);">${ds}</span>
<span style="font-family:var(--t-font-pixel);font-size:9px;color:var(--t-dim);">${cards.length} card${cards.length !== 1 ? 's' : ''}</span>
<span style="flex:1"></span>
<button class="day-popup-new" data-type="birthday" style="font-family:var(--t-font-pixel);font-size:9px;color:#fb7185;background:none;border:1px solid var(--t-border);padding:2px 8px;cursor:pointer;border-radius:2px;"><i class="ph ph-cake" style="font-size:9px;"></i> geb.</button>
<select id="day-popup-cal" style="font-family:var(--t-font-pixel);font-size:9px;color:var(--t-muted);background:var(--t-bg);border:1px solid var(--t-border);padding:2px 4px;border-radius:2px;cursor:pointer;">${calOptions}</select>
<button class="day-popup-new" data-type="appointment" style="font-family:var(--t-font-pixel);font-size:9px;color:var(--t-muted);background:none;border:1px solid var(--t-border);padding:2px 8px;cursor:pointer;border-radius:2px;">+ termin</button>
<button class="day-popup-new" data-type="journal" style="font-family:var(--t-font-pixel);font-size:9px;color:var(--t-muted);background:none;border:1px solid var(--t-border);padding:2px 8px;cursor:pointer;border-radius:2px;">+ journal</button>`;
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 `<div class="day-popup-card" data-card-id="${c.id}" style="padding:6px 12px;cursor:pointer;display:flex;align-items:center;gap:8px;transition:background 0.1s;${isBday ? 'color:#fb7185;' : ''}">
<span style="width:8px;height:8px;border-radius:50%;flex-shrink:0;background:${dotColor}"></span>
${isBday ? '<i class="ph ph-cake" style="font-size:11px;flex-shrink:0;"></i>' : ''}
<span style="font-size:11px;${isBday ? '' : 'color:var(--t-text);'}flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(popupLabel)}</span>
${isBday ? `<span style="font-family:var(--t-font-pixel);font-size:8px;opacity:0.7;">${c._age}</span>` : ''}
${c._recurring && !isBday ? '<span style="font-family:var(--t-font-pixel);font-size:8px;color:var(--t-dim);">recurring</span>' : ''}
${isJ ? '<span style="font-family:var(--t-font-pixel);font-size:8px;color:var(--t-dim);">journal</span>' : ''}
${todos ? `<span style="font-family:var(--t-font-pixel);font-size:8px;color:var(--t-accent);">${todos.done}/${todos.total}</span>` : ''}
</div>`;
}).join('');
} else {
list.innerHTML = '<div class="cursor-blink" style="padding:16px 12px;text-align:center;font-family:var(--t-font-pixel);font-size:9px;color:var(--t-dim);">no cards</div>';
}
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**Bis:** \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(); });