calender stuff
This commit is contained in:
1 parent
8e78468743
commit
e0fca064ae
15 files changed
+1716
-67
No files matched your search
+236
-21
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user