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>
566 lines
27 KiB
JavaScript
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(); });
|