${l.replace(/`
+ ).join('');
+}
+
+$('cal-caldav-settings')?.addEventListener('click', openCaldavSettings);
+$('cal-sync')?.addEventListener('click', () => triggerSync());
+
+// Auto-sync throttle
+let lastAutoSync = 0;
+export function autoSync() {
+ const now = Date.now();
+ if (now - lastAutoSync < 60000) return;
+ lastAutoSync = now;
+ triggerSync();
+}
+
+// Background sync every 2 minutes
+function showSyncNotification(stats) {
+ const s = stats || {};
+ const parts = [];
+ if (s.pulled) parts.push(`${s.pulled} received`);
+ if (s.pushed) parts.push(`${s.pushed} sent`);
+ if (s.deleted) parts.push(`${s.deleted} deleted`);
+ if (!parts.length) return;
+
+ const note = document.createElement('div');
+ note.style.cssText = 'position:fixed;bottom:60px;right:16px;z-index:200;background:var(--t-surface);border:1px solid var(--t-accent);border-radius:4px;padding:10px 14px;box-shadow:0 4px 20px rgba(0,0,0,0.4);max-width:300px;';
+ note.innerHTML = `
CalDAV: ${parts.join(', ')}
`;
+ document.body.appendChild(note);
+ setTimeout(() => { note.style.transition = 'opacity 0.3s'; note.style.opacity = '0'; setTimeout(() => note.remove(), 350); }, 4000);
+}
+
+async function backgroundSync() {
+ try {
+ const res = await fetch('api/caldav/sync', {
+ method: 'POST', headers: { 'X-CSRF-Token': getCsrf() },
+ }).then(r => r.json()).catch(() => null);
+ if (res?.ok) {
+ const s = res.stats || {};
+ if (s.pulled > 0 || s.pushed > 0 || s.deleted > 0) {
+ await window._reloadAll?.();
+ showSyncNotification(s);
+ }
+ }
+ } catch {}
+}
+
+// Start background sync after app is ready
+let bgSyncInterval = null;
+export function startBackgroundSync() {
+ if (bgSyncInterval) return;
+ backgroundSync(); // first sync immediately
+ bgSyncInterval = setInterval(backgroundSync, 120000); // then every 2 minutes
+}
diff --git a/public/js/modules/calendar.js b/public/js/modules/calendar.js
index 6d8e6ff..ad54b4a 100644
--- a/public/js/modules/calendar.js
+++ b/public/js/modules/calendar.js
@@ -3,6 +3,7 @@
import { $, esc } from './util.js';
import { state } from './state.js';
import { stripMd, getTodos } from './markdown.js';
+import { autoSync, getCalConfig, getAllCalConfigs } from './caldav.js';
const overlay = $('calendar');
const grid = $('cal-grid');
@@ -16,19 +17,126 @@ 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")
+function cardBaseDate(c) {
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 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 isJournal(card) { return /^\d{4}-\d{2}-\d{2}/.test(card.title); }
+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 getCardsForDate(ds) {
+ const results = [];
+ const targetYear = parseInt(ds.substring(0, 4));
+ for (const c of state.cards) {
+ if (cardBaseDate(c) === ds) {
+ results.push({ ...c, _recurring: false });
+ } else if (c.caldav_rrule && matchesRRule(c, ds)) {
+ const baseYear = parseInt(cardBaseDate(c).substring(0, 4));
+ const rrule = parseRRule(c.caldav_rrule);
+ const age = rrule?.FREQ === 'YEARLY' ? targetYear - baseYear : null;
+ results.push({ ...c, _recurring: true, _age: age });
+ } else if ((c.tags || '').split(',').includes('geburtstag')) {
+ // Local birthday cards repeat yearly without RRULE
+ const base = cardBaseDate(c);
+ 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);
+}
function render() {
title.textContent = `${MONTHS[viewMonth]} ${viewYear}`;
@@ -68,12 +176,41 @@ function render() {
}
const isToday = ds === todayStr;
const cards = getCardsForDate(ds);
- const journals = cards.filter(isJournal);
- const others = cards.filter(c => !isJournal(c));
+ 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;
+ html += `
`;
+ if (itemIcon) html += ``;
+ 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);
@@ -81,9 +218,14 @@ function render() {
if (todos) html += `${todos.done}/${todos.total}`;
html += `${esc(body || '(empty)')}
`;
}
+ // Other cards
for (const c of others) {
html += `
`;
- html += `${esc(c.title)}
`;
+ 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 += `