255 lines
11 KiB
JavaScript
255 lines
11 KiB
JavaScript
// ── Calendar view ──
|
|
|
|
import { $, esc } from './util.js';
|
|
import { state } from './state.js';
|
|
import { stripMd, getTodos } from './markdown.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'];
|
|
|
|
let viewYear, viewMonth; // 0-indexed month
|
|
|
|
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")
|
|
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 isJournal(card) { return /^\d{4}-\d{2}-\d{2}/.test(card.title); }
|
|
|
|
function render() {
|
|
title.textContent = `${MONTHS[viewMonth]} ${viewYear}`;
|
|
|
|
const firstDay = new Date(viewYear, viewMonth, 1).getDay(); // 0=Sun
|
|
const startOffset = (firstDay + 6) % 7; // Mon=0
|
|
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
|
const prevMonthDays = new Date(viewYear, viewMonth, 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 = viewMonth === 0 ? 11 : viewMonth - 1;
|
|
const py = viewMonth === 0 ? viewYear - 1 : viewYear;
|
|
ds = dateStr(py, pm, displayNum);
|
|
} else if (dayNum > daysInMonth) {
|
|
// Next month
|
|
displayNum = dayNum - daysInMonth;
|
|
const nm = viewMonth === 11 ? 0 : viewMonth + 1;
|
|
const ny = viewMonth === 11 ? viewYear + 1 : viewYear;
|
|
ds = dateStr(ny, nm, displayNum);
|
|
} else {
|
|
ds = dateStr(viewYear, viewMonth, dayNum);
|
|
}
|
|
const isToday = ds === todayStr;
|
|
const cards = getCardsForDate(ds);
|
|
const journals = cards.filter(isJournal);
|
|
const others = cards.filter(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">';
|
|
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>`;
|
|
}
|
|
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>`;
|
|
}
|
|
html += '</div>';
|
|
html += '</div>';
|
|
}
|
|
html += '</div>';
|
|
|
|
grid.innerHTML = html;
|
|
|
|
// Click on card item → open card
|
|
grid.querySelectorAll('[data-card-id]').forEach(el => {
|
|
el.addEventListener('click', e => {
|
|
e.stopPropagation();
|
|
closeDayPopup();
|
|
state._returnToCalendar = true;
|
|
window._openDetail(el.dataset.cardId);
|
|
});
|
|
});
|
|
// 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();
|
|
viewMonth += delta;
|
|
if (viewMonth > 11) { viewMonth = 0; viewYear++; }
|
|
if (viewMonth < 0) { viewMonth = 11; viewYear--; }
|
|
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: '240px', maxWidth: '360px', 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 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;';
|
|
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="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 isJ = isJournal(c);
|
|
const label = 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>
|
|
${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 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 => {
|
|
el.addEventListener('mouseenter', () => { el.style.background = 'var(--t-raised)'; });
|
|
el.addEventListener('mouseleave', () => { el.style.background = ''; });
|
|
el.addEventListener('click', () => {
|
|
closeDayPopup();
|
|
state._returnToCalendar = true;
|
|
window._openDetail(el.dataset.cardId);
|
|
});
|
|
});
|
|
|
|
// New card buttons
|
|
dayPopup.querySelectorAll('.day-popup-new').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const type = btn.dataset.type;
|
|
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 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 });
|
|
}
|
|
});
|
|
});
|
|
|
|
// Close on click outside
|
|
setTimeout(() => {
|
|
const closeHandler = e => {
|
|
if (dayPopup && !dayPopup.contains(e.target)) {
|
|
closeDayPopup();
|
|
document.removeEventListener('click', closeHandler);
|
|
}
|
|
};
|
|
document.addEventListener('click', closeHandler);
|
|
}, 0);
|
|
}
|
|
|
|
export function openCalendar() {
|
|
const now = new Date();
|
|
viewYear = now.getFullYear();
|
|
viewMonth = now.getMonth();
|
|
render();
|
|
overlay.classList.add('open');
|
|
}
|
|
|
|
export function closeCalendar() {
|
|
closeDayPopup();
|
|
overlay.classList.remove('open');
|
|
}
|
|
|
|
$('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();
|
|
viewYear = now.getFullYear();
|
|
viewMonth = now.getMonth();
|
|
render();
|
|
});
|
|
overlay?.addEventListener('click', e => { if (e.target === overlay) closeCalendar(); });
|