calender stuff

This commit is contained in:
th committed 2026-04-08 14:04:23 +02:00
1 parent 8e78468743
commit e0fca064ae
15 files changed
+1716 -67

No files matched your search

+236 -21
View File
@@ -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 += `<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;
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 (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);
@@ -81,9 +218,14 @@ function render() {
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}">`;
html += `<span class="truncate">${esc(c.title)}</span></div>`;
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>';
@@ -92,13 +234,34 @@ function render() {
grid.innerHTML = html;
// Click on card item → open card
// Click on card item → open card (hide calendar, restore on return)
grid.querySelectorAll('[data-card-id]').forEach(el => {
let clickTimer = null;
el.addEventListener('click', e => {
e.stopPropagation();
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();
if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; }
closeDayPopup();
state._returnToCalendar = true;
window._openDetail(el.dataset.cardId);
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
@@ -137,12 +300,12 @@ function openDayPopup(ds, cellEl) {
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',
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 - 120, window.innerWidth - 370);
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) {
@@ -154,10 +317,20 @@ function openDayPopup(ds, cellEl) {
// 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);
@@ -167,12 +340,18 @@ function openDayPopup(ds, cellEl) {
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 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);
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;">
<span style="width:8px;height:8px;border-radius:50%;flex-shrink:0;background:${c.color}"></span>
<span style="font-size:11px;color:var(--t-text);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(label)}</span>
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>`;
@@ -186,12 +365,32 @@ function openDayPopup(ds, cellEl) {
// 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;
window._openDetail(el.dataset.cardId);
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);
});
});
@@ -199,6 +398,9 @@ function openDayPopup(ds, cellEl) {
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();
@@ -210,8 +412,15 @@ function openDayPopup(ds, cellEl) {
}
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 });
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 });
}
});
});
@@ -228,12 +437,18 @@ function openDayPopup(ds, cellEl) {
}, 0);
}
export function refreshCalendar() {
if (overlay?.classList.contains('open')) render();
}
export function openCalendar() {
const now = new Date();
viewYear = now.getFullYear();
viewMonth = now.getMonth();
render();
overlay.classList.add('open');
overlay.style.visibility = '';
autoSync();
}
export function closeCalendar() {