- Month view: Replaced day-strip+events-area with full-height column divs (.month-col) so borders extend the full row height and clicking anywhere in a day column (including below events) navigates to day view. Events overlay uses pointer-events:none (pass-through) while span bars and +N-more labels stay pointer-events:all. - Scroll navigation: Changed wheel handler from 80ms debounce to 500ms leading-edge throttle — one navigation per trackpad gesture. - Custom date/time picker (date-picker.js): Dark calendar grid with prev/next navigation, today/selected highlighting, and a CSS scroll-snap time scroller (hours 0-23, minutes 0-59) matching the app's primary color. Language-aware (month names, day headers via t()). - Event modal datetime inputs replaced with hidden inputs + .dt-display click targets that open the custom picker. setDtValue() helper keeps hidden input and display label in sync.
192 lines
6.8 KiB
JavaScript
192 lines
6.8 KiB
JavaScript
import { isToday, isPast, dayOfWeek, getISOWeekNumber } from '../utils.js';
|
|
import { t } from '../i18n.js';
|
|
|
|
const LANE_H = 20; // px per lane (event height 18px + 2px gap)
|
|
const MAX_LANES = 3; // max visible lanes per row
|
|
|
|
export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') {
|
|
const year = currentDate.getFullYear();
|
|
const month = currentDate.getMonth();
|
|
const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday');
|
|
|
|
const firstDay = new Date(year, month, 1);
|
|
|
|
// Build 42-cell grid
|
|
const cells = [];
|
|
const gridStart = new Date(firstDay);
|
|
const offset = dayOfWeek(firstDay, weekStartDay);
|
|
gridStart.setDate(gridStart.getDate() - offset);
|
|
const d = new Date(gridStart);
|
|
for (let i = 0; i < 42; 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 < 6; 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 label = ev.allDay
|
|
? ev.title
|
|
: `${fmtTime(new Date(ev.start))} ${ev.title}`;
|
|
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)}">${escHtml(label)}</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)
|
|
let colsHtml = '';
|
|
rowCells.forEach(cell => {
|
|
const key = dateKey(cell);
|
|
const isOther = cell.getMonth() !== month;
|
|
const todayCls = isToday(cell) ? 'today' : '';
|
|
const otherCls = isOther ? 'other-month' : '';
|
|
const numCls = isToday(cell) ? 'today' : '';
|
|
colsHtml += `<div class="month-col ${todayCls} ${otherCls}" data-date="${key}">
|
|
<div class="cell-day ${numCls}">${cell.getDate()}</div>
|
|
</div>`;
|
|
});
|
|
|
|
bodyHtml += `<div class="month-row">
|
|
<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');
|
|
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'));
|
|
return;
|
|
}
|
|
// Column click → navigate to day view
|
|
const colEl = e.target.closest('.month-col');
|
|
if (colEl) {
|
|
onDayClick(new Date(colEl.dataset.date + 'T00:00:00'));
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── 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,''');
|
|
}
|