In der rolling Monatsansicht wird jetzt am Monatswechsel: - eine dickere Trennlinie gezeichnet (links bei Wechsel mitten in Zeile, oben bei Zeilenstart) - das 3-Buchstaben-Monatskürzel (z.B. JUL, AUG) groß über der "1" angezeigt Beide Farben (Linie und Kürzel) sind in den Einstellungen unter "Farben" individuell anpassbar (Default: #7090c0). Backend: neue UserSettings-Felder month_divider_color und month_label_color mit Migration. Frontend: applyTheme setzt entsprechende CSS-Variablen.
231 lines
8.8 KiB
JavaScript
231 lines
8.8 KiB
JavaScript
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 =
|
||
`<div class="month-kw-header">KW</div>` +
|
||
DOW.map(d => `<div class="month-dow">${d}</div>`).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
|
||
: `<span class="month-event-time">${escHtml(fmtTime(new Date(ev.start)))}</span> ${titleEsc}`;
|
||
eventsHtml += `<div class="month-span-event ${pastCls} ${cL} ${cR}"
|
||
data-id="${ev.id}" data-url="${escAttr(ev.url)}"
|
||
style="left:${leftPct.toFixed(3)}%;width:${widthPct.toFixed(3)}%;top:${topPx}px;background:${color}"
|
||
title="${escAttr(ev.title)}">${labelHtml}</div>`;
|
||
});
|
||
|
||
// "+N more" per column
|
||
Object.entries(overflowByCol).forEach(([col, count]) => {
|
||
const c = parseInt(col);
|
||
eventsHtml += `<div class="month-more"
|
||
data-date="${dateKey(rowCells[c])}"
|
||
style="left:${((c / 7) * 100).toFixed(3)}%;width:${(100 / 7).toFixed(3)}%;bottom:2px">${t('more_events', { n: count })}</div>`;
|
||
});
|
||
|
||
// 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' : '';
|
||
// Add divider class on the cell BEFORE a month change (for right border styling)
|
||
// and on the cell AT a month change (for left border styling) — except at row start
|
||
const dividerCls = (isFirstOfMonth && idx > 0) ? 'month-divider-left' : '';
|
||
const monthLabel = isFirstOfMonth
|
||
? `<div class="month-marker">${monthsShort[cell.getMonth()]}</div>`
|
||
: '';
|
||
colsHtml += `<div class="month-col ${todayCls} ${otherCls} ${selectedCls} ${firstCls} ${dividerCls}" data-date="${key}">
|
||
${monthLabel}
|
||
<div class="cell-day ${numCls}">${cell.getDate()}</div>
|
||
</div>`;
|
||
});
|
||
|
||
// If the row starts on the 1st of a new month, draw a divider above the row
|
||
const rowDividerCls = rowCells[0].getDate() === 1 ? 'month-divider-top' : '';
|
||
bodyHtml += `<div class="month-row ${rowDividerCls}">
|
||
<div class="month-kw-cell">${kw}</div>
|
||
<div class="month-row-right">
|
||
${colsHtml}
|
||
<div class="month-events-overlay">${eventsHtml}</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
container.innerHTML = `<div class="month-view">
|
||
<div class="month-header">${headerHtml}</div>
|
||
<div class="month-body">${bodyHtml}</div>
|
||
</div>`;
|
||
|
||
// 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,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
function escAttr(s) {
|
||
return String(s).replace(/"/g,'"').replace(/'/g,''');
|
||
}
|