Feature: Dynamische Monatsansicht-Lanes + spanning All-Day-Balken in Wochenansicht

month.js: MAX_LANES wird jetzt aus der tatsächlichen Container-Höhe berechnet (kein hartes Limit von 3 mehr).
week.js: All-Day-Zeile verwendet jetzt dieselbe Overlay-Logik wie die Monatsansicht – Termine spannen als einzelner Balken über mehrere Tage.
This commit is contained in:
2026-04-08 14:57:57 +02:00
parent f98ff69a9b
commit 4156bc4413
3 changed files with 92 additions and 44 deletions

View File

@@ -550,22 +550,25 @@ a { color: var(--primary); text-decoration: none; }
.week-day-header.today .day-name { color: var(--today-color); } .week-day-header.today .day-name { color: var(--today-color); }
/* All-day row */ /* All-day row */
.week-allday-row { display: flex; border-bottom: 1px solid var(--border); flex-shrink: 0; min-height: 28px; } .week-allday-row { display: flex; border-bottom: 1px solid var(--border); flex-shrink: 0; }
.allday-gutter { width: 56px; flex-shrink: 0; display: flex; align-items: center; justify-content: flex-end; padding-right: 6px; font-size: 10px; color: var(--text-3); } .allday-gutter { width: 56px; flex-shrink: 0; display: flex; align-items: flex-start; justify-content: flex-end; padding: 6px 6px 0; font-size: 10px; color: var(--text-3); }
.allday-cols { display: flex; flex: 1; } .allday-cols-wrap { flex: 1; position: relative; display: flex; }
.allday-col { flex: 1; border-left: 1px solid var(--border); padding: 2px; } .allday-col-bg { flex: 1; border-left: 1px solid var(--border); }
.col-span-tint { .allday-spans-layer { position: absolute; inset: 0; pointer-events: none; }
position: absolute; inset: 0; pointer-events: none; z-index: 0; .allday-span {
} position: absolute;
.allday-event { height: 18px; line-height: 18px;
font-size: 11px; font-weight: 500; padding: 2px 6px; font-size: 11px; font-weight: 500;
border-radius: 3px; margin-bottom: 2px; cursor: pointer; padding: 0 6px; border-radius: 3px;
cursor: pointer; pointer-events: all;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
} }
.allday-event.multiday-timed { .allday-span:hover { filter: brightness(1.12); }
opacity: .88; .allday-span.past { opacity: .45; }
border-left: 3px solid rgba(255,255,255,.45); .allday-span.continues-left { border-top-left-radius: 0; border-bottom-left-radius: 0; padding-left: 3px; }
} .allday-span.continues-right { border-top-right-radius: 0; border-bottom-right-radius: 0; padding-right: 3px; }
.allday-span.multiday-timed { opacity: .88; border-left: 3px solid rgba(255,255,255,.4); }
.col-span-tint { position: absolute; inset: 0; pointer-events: none; z-index: 0; }
/* Time grid */ /* Time grid */
.week-body { display: flex; flex: 1; overflow-y: auto; position: relative; } .week-body { display: flex; flex: 1; overflow-y: auto; position: relative; }

View File

@@ -1,12 +1,16 @@
import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js'; import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js';
import { t } from '../i18n.js'; import { t } from '../i18n.js';
const LANE_H = 20; // px per lane (event height 18px + 2px gap) const LANE_H = 20; // px per lane (event height 18px + 2px gap)
const MAX_LANES = 3; // max visible lanes per row const DAY_H = 30; // day-number row height
const NUM_ROWS = 5; // rolling view: always 5 weeks
const NUM_ROWS = 5; // rolling view: always 5 weeks
export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') { export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') {
// 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) // "Primary month" = currentDate's month (used for muting other-month days)
const primaryMonth = currentDate.getMonth(); const primaryMonth = currentDate.getMonth();
const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday'); const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday');

View File

@@ -45,33 +45,41 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
</div>`; </div>`;
}).join(''); }).join('');
// ── All-day row ─────────────────────────────────────── // ── All-day row (spanning bars, same logic as month view) ──
const alldayCols = days.map(day => { const ALLDAY_LANE_H = 22;
const key = dayKey(day); const allDayAndMulti = [...allDayEvs, ...multiDayTimedEvs];
const dayEvs = allDayEvs.filter(ev => { const alldayLayout = layoutWeekAllDay(allDayAndMulti, days);
const s = new Date(ev.start); s.setHours(0,0,0,0); const maxAlldayLane = alldayLayout.length ? alldayLayout.reduce((m, it) => Math.max(m, it.lane), 0) : -1;
const e = new Date(ev.end); e.setHours(0,0,0,0); const alldayRowH = maxAlldayLane < 0 ? 28 : (maxAlldayLane + 1) * ALLDAY_LANE_H + 6;
const d = new Date(day); d.setHours(0,0,0,0);
return d >= s && d < e || isSameDay(d, s);
});
const allDayHtml = dayEvs.map(ev => {
const color = ev.color || ev.calendarColor || '#4285f4';
return `<div class="allday-event" style="background:${color};color:#fff"
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(ev.title)}">${escHtml(ev.title)}</div>`;
}).join('');
// Multi-day timed events: show in all-day row for every day they span const alldaySpanHtml = alldayLayout.map(({ ev, colStart, colEnd, lane }) => {
const spanHtml = multiDayTimedEvs.filter(ev => spansDay(ev, day)).map(ev => { const isMultiTimed = multiDayTimedEvs.includes(ev);
const color = ev.color || ev.calendarColor || '#4285f4'; const n = days.length;
const isStart = isSameDay(new Date(ev.start), day); const left = (colStart / n) * 100;
const label = isStart ? `${fmtTime(new Date(ev.start))} ${ev.title}` : ev.title; const width = ((colEnd - colStart + 1) / n) * 100;
return `<div class="allday-event multiday-timed" style="background:${color};color:#fff" const top = lane * ALLDAY_LANE_H + 2;
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(ev.title)}">${escHtml(label)}</div>`; const color = ev.color || ev.calendarColor || '#4285f4';
}).join(''); const pastCls = isPast(ev) ? 'past' : '';
const multiCls = isMultiTimed ? 'multiday-timed' : '';
return `<div class="allday-col" data-date="${key}">${allDayHtml}${spanHtml}</div>`; const cL = new Date(ev.start) < new Date(days[0]) ? 'continues-left' : '';
const cR = new Date(ev.end) > (() => { const d = new Date(days[n-1]); d.setHours(24,0,0,0); return d; })() ? 'continues-right' : '';
const label = isMultiTimed && isSameDay(new Date(ev.start), days[colStart])
? `${fmtTime(new Date(ev.start))} ${ev.title}`
: ev.title;
return `<div class="allday-span ${pastCls} ${multiCls} ${cL} ${cR}"
style="left:calc(${left.toFixed(2)}% + 1px);width:calc(${width.toFixed(2)}% - 2px);top:${top}px;background:${color};color:#fff"
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(ev.title)}">${escHtml(label)}</div>`;
}).join(''); }).join('');
const alldayBgCols = days.map(day =>
`<div class="allday-col-bg" data-date="${dayKey(day)}"></div>`
).join('');
const alldayCols = `<div class="allday-cols-wrap" style="height:${alldayRowH}px">
${alldayBgCols}
<div class="allday-spans-layer">${alldaySpanHtml}</div>
</div>`;
// ── Time column labels ──────────────────────────────── // ── Time column labels ────────────────────────────────
const timeLabels = Array.from({length: 24}, (_, h) => const timeLabels = Array.from({length: 24}, (_, h) =>
`<div class="time-label">${h === 0 ? '' : `${String(h).padStart(2,'0')}:00`}</div>` `<div class="time-label">${h === 0 ? '' : `${String(h).padStart(2,'0')}:00`}</div>`
@@ -180,8 +188,8 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC
}); });
}); });
// Click: all-day event // Click: all-day span
container.querySelectorAll('.allday-event').forEach(el => { container.querySelectorAll('.allday-span').forEach(el => {
el.addEventListener('click', e => { el.addEventListener('click', e => {
e.stopPropagation(); e.stopPropagation();
const ev = events.find(ev => ev.id === el.dataset.id && ev.url === el.dataset.url); const ev = events.find(ev => ev.id === el.dataset.id && ev.url === el.dataset.url);
@@ -205,6 +213,39 @@ function renderNowLine(container, days, hourH = 60) {
setTimeout(() => renderNowLine(container, days, hourH), 60000); setTimeout(() => renderNowLine(container, days, hourH), 60000);
} }
function layoutWeekAllDay(evs, days) {
const items = [];
evs.forEach(ev => {
let colStart = -1, colEnd = -1;
days.forEach((day, i) => {
const ds = new Date(day); ds.setHours(0, 0, 0, 0);
const de = new Date(day); de.setHours(24, 0, 0, 0);
if (new Date(ev.start) < de && new Date(ev.end) > ds) {
if (colStart === -1) colStart = i;
colEnd = i;
}
});
if (colStart === -1) return;
items.push({ ev, colStart, colEnd });
});
// Sort: longer spans first, then by start column
items.sort((a, b) =>
(b.colEnd - b.colStart) - (a.colEnd - a.colStart) || a.colStart - b.colStart
);
// Greedy lane assignment
const laneEnds = [];
items.forEach(item => {
let lane = laneEnds.findIndex(end => item.colStart > end);
if (lane === -1) { lane = laneEnds.length; laneEnds.push(-1); }
item.lane = lane;
laneEnds[lane] = item.colEnd;
});
return items;
}
function layoutEvents(events) { function layoutEvents(events) {
if (!events.length) return []; if (!events.length) return [];