Files
hyprcrd/public/js/modules/calendar.js
T
thandClaude Opus 4.6 1d6499e5dd extract sync orchestration into sync.js, unify on sync.tick heartbeat
caldav.js was carrying the core sync timer, autosync toggle, relay
settings and every poller hook alongside its actual CalDAV/calendar
config concerns. Split out:

- New sync.js owns the single background timer, header Sync button,
  autosync pause toggle, configurable interval, and emits `sync.tick`
  so plugins can fan out without their own setIntervals.
- collab-buckets + chat now subscribe to sync.tick instead of running
  their own pollers, so the manual Sync button refreshes them too and
  the Autosync pause actually pauses everything.
- Replace the number input for sync interval with a stepped slider
  (5s..1h snap points) and a live label, bumped to 1.9.2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:02:44 +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**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(); });