import { isToday, isPast, isSameDay, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js'; import { t } from '../i18n.js'; const LANE_H = 20; // px per lane (event height 18px + 2px gap) const DAY_H = 30; // day-number row height const NUM_ROWS = 5; // rolling view: always 5 weeks export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday', selectedDate = null) { // Dynamic lane limit: how many events fit in the actual row height const containerH = container.clientHeight || 600; const headerH = 34; // month-header DOW row const rowH = (containerH - headerH) / NUM_ROWS; const MAX_LANES = Math.max(1, Math.floor((rowH - DAY_H) / LANE_H) - 1); // "Primary month" = currentDate's month (used for muting other-month days) const primaryMonth = currentDate.getMonth(); const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday'); // Rolling grid: start at the week that contains currentDate const gridStart = weekStart(currentDate, weekStartDay); // Build NUM_ROWS × 7 cells const cells = []; const d = new Date(gridStart); for (let i = 0; i < NUM_ROWS * 7; i++) { cells.push(new Date(d)); d.setDate(d.getDate() + 1); } // Normalize each event's date range once const normed = events.map(ev => { const s = new Date(ev.start); s.setHours(0, 0, 0, 0); const e = new Date(ev.end); e.setHours(0, 0, 0, 0); if (ev.allDay && e > s) e.setDate(e.getDate() - 1); // exclusive → inclusive return { ev, ns: s, ne: e }; }); // Header const headerHtml = `
KW
` + DOW.map(d => `
${d}
`).join(''); // Build rows let bodyHtml = ''; for (let row = 0; row < NUM_ROWS; row++) { const rowCells = cells.slice(row * 7, row * 7 + 7); const rowStart = new Date(rowCells[0]); rowStart.setHours(0, 0, 0, 0); const rowEnd = new Date(rowCells[6]); rowEnd.setHours(0, 0, 0, 0); const kw = getISOWeekNumber(rowCells[0]); // Collect events overlapping this row const rowItems = []; normed.forEach(({ ev, ns, ne }) => { if (ne < rowStart || ns > rowEnd) return; const colStart = Math.max(0, daysBetween(rowStart, ns)); const colEnd = Math.min(6, daysBetween(rowStart, ne)); if (colEnd < colStart) return; rowItems.push({ ev, colStart, span: colEnd - colStart + 1, continuesLeft: ns < rowStart, continuesRight: ne > rowEnd, }); }); // Sort: all-day first, then span desc, then start time rowItems.sort((a, b) => { if (a.ev.allDay && !b.ev.allDay) return -1; if (!a.ev.allDay && b.ev.allDay) return 1; if (b.span !== a.span) return b.span - a.span; return new Date(a.ev.start) - new Date(b.ev.start); }); // Assign lanes (greedy interval packing) const lanes = []; rowItems.forEach(item => { let laneIdx = lanes.findIndex(l => item.colStart >= l.colEnd); if (laneIdx === -1) { laneIdx = lanes.length; lanes.push({ colEnd: 0 }); } item.lane = laneIdx; lanes[laneIdx].colEnd = item.colStart + item.span; }); // Track overflow per column const overflowByCol = {}; rowItems.forEach(item => { if (item.lane >= MAX_LANES) { for (let c = item.colStart; c < item.colStart + item.span; c++) { overflowByCol[c] = (overflowByCol[c] || 0) + 1; } } }); // Render event spans HTML (placed in overlay) let eventsHtml = ''; rowItems.forEach(item => { if (item.lane >= MAX_LANES) return; const { ev, colStart, span, continuesLeft, continuesRight } = item; const leftPct = (colStart / 7) * 100; const widthPct = (span / 7) * 100 - 0.4; const topPx = item.lane * LANE_H + 2; const color = ev.color || ev.calendarColor || '#4285f4'; const pastCls = isPast(ev) ? 'past' : ''; const cL = continuesLeft ? 'continues-left' : ''; const cR = continuesRight ? 'continues-right' : ''; const titleEsc = escHtml(ev.title); const labelHtml = ev.allDay ? titleEsc : `${escHtml(fmtTime(new Date(ev.start)))} ${titleEsc}`; eventsHtml += `
${labelHtml}
`; }); // "+N more" per column Object.entries(overflowByCol).forEach(([col, count]) => { const c = parseInt(col); eventsHtml += `
${t('more_events', { n: count })}
`; }); // Detect a month boundary in this row. monthChangeIdx is the index of // the first-of-month cell, or -1 if the row doesn't span a month change. let monthChangeIdx = -1; rowCells.forEach((cell, idx) => { if (cell.getDate() === 1 && idx > 0) monthChangeIdx = idx; }); // Full-height column divs (click targets + borders) const monthsShort = t('months_short'); let colsHtml = ''; rowCells.forEach((cell, idx) => { const key = dateKey(cell); const isOther = cell.getMonth() !== primaryMonth; const todayCls = isToday(cell) ? 'today' : ''; const otherCls = isOther ? 'other-month' : ''; const selDate = selectedDate || currentDate; const selectedCls = isSameDay(cell, selDate) ? 'month-selected' : ''; const numCls = isToday(cell) ? 'today' : ''; // First-of-month marker: show month abbreviation, push day number below const isFirstOfMonth = cell.getDate() === 1; const firstCls = isFirstOfMonth ? 'first-of-month' : ''; // Step-shaped boundary at month change: bottom under the previous-month // tail, vertical at the change, top across the new-month head. const dividerClasses = []; if (isFirstOfMonth && idx > 0) dividerClasses.push('month-divider-left'); if (monthChangeIdx > 0 && idx >= monthChangeIdx) dividerClasses.push('month-divider-top'); if (monthChangeIdx > 0 && idx < monthChangeIdx) dividerClasses.push('month-divider-bottom'); const dividerCls = dividerClasses.join(' '); const monthLabel = isFirstOfMonth ? `
${monthsShort[cell.getMonth()]}
` : ''; colsHtml += `
${monthLabel}
${cell.getDate()}
`; }); // If the row starts on the 1st of a new month, draw a full-width divider above the row const rowDividerCls = rowCells[0].getDate() === 1 ? 'month-divider-top' : ''; // If any cell in the row is first-of-month, push events overlay down so the day // number isn't hidden by spanning event bars const hasMonthMarker = rowCells.some(c => c.getDate() === 1); const rowMarkerCls = hasMonthMarker ? 'has-month-marker' : ''; bodyHtml += `
${kw}
${colsHtml}
${eventsHtml}
`; } container.innerHTML = `
${headerHtml}
${bodyHtml}
`; // Click handlers via event delegation on the body const body = container.querySelector('.month-body'); // Single click: select day (or handle event / more clicks) body.addEventListener('click', e => { // Span event click const spanEl = e.target.closest('.month-span-event'); if (spanEl) { e.stopPropagation(); const ev = events.find(ev => ev.id === spanEl.dataset.id && ev.url === spanEl.dataset.url); if (ev) onEventClick(ev, spanEl); return; } // "+N more" → navigate to day view const moreEl = e.target.closest('.month-more'); if (moreEl) { e.stopPropagation(); onDayClick(new Date(moreEl.dataset.date + 'T00:00:00'), 'navigate'); return; } // Column click → select day const colEl = e.target.closest('.month-col'); if (colEl) { onDayClick(new Date(colEl.dataset.date + 'T00:00:00'), 'select'); } }); // Double click: navigate to day view body.addEventListener('dblclick', e => { const colEl = e.target.closest('.month-col'); if (colEl && !e.target.closest('.month-span-event')) { onDayClick(new Date(colEl.dataset.date + 'T00:00:00'), 'navigate'); } }); // Right click: context menu body.addEventListener('contextmenu', e => { const colEl = e.target.closest('.month-col'); if (colEl) { e.preventDefault(); onDayClick(new Date(colEl.dataset.date + 'T00:00:00'), 'context', e); } }); } // ── Helpers ─────────────────────────────────────────────── function daysBetween(a, b) { return Math.round((b - a) / 86400000); } function dateKey(d) { return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; } function fmtTime(d) { return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' }); } function escHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>'); } function escAttr(s) { return String(s).replace(/"/g,'"').replace(/'/g,'''); }