Files
Calendarr/frontend/js/views/week.js
Scarriffle d1d1135e32 perf/fix: Kalender-Toggle ohne Ladescreen + Mehrfach-Tint als Verlauf
Ausblenden: Events werden sofort client-seitig aus dem Cache gefiltert
(calendar_id-Match), kein Netzwerkaufruf für die Ansicht nötig.
Einblenden: fetchAndRender(force, silent=true) überspringt showLoading(),
die aktuelle Ansicht bleibt sichtbar und wird nach dem Fetch aktualisiert.
Mehrere mehrtägige Events am selben Tag erzeugen jetzt einen vertikalen
Farbverlauf (linear-gradient) statt gestapelter Ebenen, bei denen nur
die letzte Farbe sichtbar war.
2026-04-08 22:14:08 +02:00

335 lines
12 KiB
JavaScript

import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js';
import { t } from '../i18n.js';
export function renderWeek(container, currentDate, events, onSlotClick, onEventClick, isSingleDay = false, weekStartDay = 'monday', hourH = 60) {
// Build the days array (7 days for week, 1 for day)
const days = [];
if (isSingleDay) {
days.push(new Date(currentDate));
} else {
const monday = weekStart(currentDate, weekStartDay);
for (let i = 0; i < 7; i++) {
const d = new Date(monday);
d.setDate(d.getDate() + i);
days.push(d);
}
}
// Separate all-day and timed events
const allDayEvs = events.filter(ev => ev.allDay);
const timedEvs = events.filter(ev => !ev.allDay);
// Multi-day timed events: timed but spanning more than one calendar day
const multiDayTimedEvs = timedEvs.filter(ev => !isSameDay(new Date(ev.start), new Date(ev.end)));
// Multi-day all-day events (exclusive end → subtract 1 day before comparing)
const multiDayAllDayEvs = allDayEvs.filter(ev => {
const s = new Date(ev.start);
const e = new Date(ev.end);
if (e > s) e.setDate(e.getDate() - 1); // exclusive → inclusive
return !isSameDay(s, e);
});
// All events that should generate a column background tint
const tintEvs = [...multiDayTimedEvs, ...multiDayAllDayEvs];
// Returns true if event overlaps any part of the given day
function spansDay(ev, day) {
const evStart = new Date(ev.start);
const evEnd = new Date(ev.end);
const dayStart = new Date(day); dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(day); dayEnd.setHours(24, 0, 0, 0);
return evStart < dayEnd && evEnd > dayStart;
}
// ── KW Badge ──────────────────────────────────────────
const kwNum = getISOWeekNumber(days[0]);
const kwBadge = !isSingleDay
? `<div class="week-kw-badge">${t('week_abbr')} ${kwNum}</div>`
: '';
// ── Header ────────────────────────────────────────────
const headerCols = days.map(day => {
const todayCls = isToday(day) ? 'today' : '';
return `<div class="week-day-header ${todayCls}" data-date="${dayKey(day)}">
<div class="day-name">${t('dow_index')[day.getDay()]}</div>
<div class="day-num">${day.getDate()}</div>
</div>`;
}).join('');
// ── All-day row (spanning bars, same logic as month view) ──
const ALLDAY_LANE_H = 22;
const allDayAndMulti = [...allDayEvs, ...multiDayTimedEvs];
const alldayLayout = layoutWeekAllDay(allDayAndMulti, days);
const maxAlldayLane = alldayLayout.length ? alldayLayout.reduce((m, it) => Math.max(m, it.lane), 0) : -1;
const alldayRowH = maxAlldayLane < 0 ? 28 : (maxAlldayLane + 1) * ALLDAY_LANE_H + 6;
const alldaySpanHtml = alldayLayout.map(({ ev, colStart, colEnd, lane }) => {
const isMultiTimed = multiDayTimedEvs.includes(ev);
const n = days.length;
const left = (colStart / n) * 100;
const width = ((colEnd - colStart + 1) / n) * 100;
const top = lane * ALLDAY_LANE_H + 2;
const color = ev.color || ev.calendarColor || '#4285f4';
const pastCls = isPast(ev) ? 'past' : '';
const multiCls = isMultiTimed ? 'multiday-timed' : '';
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('');
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 ────────────────────────────────
const timeLabels = Array.from({length: 24}, (_, h) =>
`<div class="time-label">${h === 0 ? '' : `${String(h).padStart(2,'0')}:00`}</div>`
).join('');
// ── Day columns ───────────────────────────────────────
const dayCols = days.map(day => {
const key = dayKey(day);
const dayEvs = timedEvs.filter(ev => {
const s = new Date(ev.start);
return isSameDay(s, day);
});
const positioned = layoutEvents(dayEvs);
const hourLines = Array.from({length: 24}, (_, h) =>
`<div class="hour-line" style="top:${h * hourH}px"><div class="half-line"></div></div>`
).join('');
const evHtml = positioned.map(({ ev, col, cols }) => {
const s = new Date(ev.start);
const e = new Date(ev.end);
const top = s.getHours() * hourH + s.getMinutes() * hourH / 60;
const dayEnd = new Date(s); dayEnd.setHours(24, 0, 0, 0);
const clampedEnd = e > dayEnd ? dayEnd : e;
const height = Math.max(20, (clampedEnd - s) / 60000 * hourH / 60);
const left = (col / cols) * 100;
const width = (1 / cols) * 100 - 0.5;
const color = ev.color || ev.calendarColor || '#4285f4';
const pastCls = isPast(ev) ? 'past' : '';
const startStr = fmtTime(s);
const locHtml = ev.location ? `<div class="ev-loc">${escHtml(ev.location)}</div>` : '';
return `<div class="timed-event ${pastCls}"
style="top:${top}px;height:${height}px;left:${left}%;width:${width}%;background:${color};color:#fff"
data-id="${ev.id}" data-url="${escAttr(ev.url)}" title="${escAttr(ev.title)}">
<div class="ev-time">${startStr}</div>
<div class="ev-title">${escHtml(ev.title)}</div>
${locHtml}
</div>`;
}).join('');
// Background tint for days covered by multi-day events (timed or all-day)
const dayTintEvs = tintEvs.filter(ev => spansDay(ev, day));
const tintHtml = (() => {
if (!dayTintEvs.length) return '';
const colors = dayTintEvs.map(ev => ev.color || ev.calendarColor || '#4285f4');
let bg;
if (colors.length === 1) {
bg = colors[0] + '26';
} else {
// Vertical gradient bands for multiple overlapping multi-day events
const stops = colors.flatMap((c, i) => {
const p1 = ((i / colors.length) * 100).toFixed(1);
const p2 = (((i + 1) / colors.length) * 100).toFixed(1);
return [`${c}26 ${p1}%`, `${c}26 ${p2}%`];
}).join(',');
bg = `linear-gradient(to bottom,${stops})`;
}
return `<div class="col-span-tint" style="background:${bg}"></div>`;
})();
return `<div class="week-day-col" data-date="${key}" style="height:${hourH * 24}px">
${tintHtml}
${hourLines}
${evHtml}
</div>`;
}).join('');
const viewClass = isSingleDay ? 'day-view' : 'week-view';
container.innerHTML = `<div class="${viewClass}">
<div class="week-head-sticky">
<div class="week-header-row">
<div class="week-time-gutter">${kwBadge}</div>
${headerCols}
</div>
<div class="week-allday-row">
<div class="allday-gutter">${t('allday')}</div>
${alldayCols}
</div>
</div>
<div class="week-time-area">
<div class="week-time-col">${timeLabels}</div>
<div class="week-days-col">${dayCols}</div>
</div>
</div>`;
// Scroll to ~8:00
const scrollEl = container.querySelector(`.${viewClass}`);
if (scrollEl) scrollEl.scrollTop = 8 * hourH - 20;
// Render current-time line
renderNowLine(container, days, hourH);
// Click: slot
container.querySelectorAll('.week-day-col').forEach(col => {
col.addEventListener('click', e => {
if (e.target.closest('.timed-event')) return;
const rect = col.getBoundingClientRect();
const y = e.clientY - rect.top + (scrollEl?.scrollTop || 0);
const h = Math.floor(y / hourH);
const m = Math.round(((y % hourH) / hourH * 60) / 15) * 15;
const date = new Date(col.dataset.date + 'T00:00:00');
date.setHours(h, m, 0, 0);
onSlotClick(date);
});
});
// Click: header (navigate to day)
container.querySelectorAll('.week-day-header').forEach(el => {
el.addEventListener('click', () => {
onSlotClick(new Date(el.dataset.date + 'T09:00:00'), true);
});
});
// Click: timed event
container.querySelectorAll('.timed-event').forEach(el => {
el.addEventListener('click', e => {
e.stopPropagation();
const ev = events.find(ev => ev.id === el.dataset.id && ev.url === el.dataset.url);
if (ev) onEventClick(ev, el);
});
});
// Click: all-day span
container.querySelectorAll('.allday-span').forEach(el => {
el.addEventListener('click', e => {
e.stopPropagation();
const ev = events.find(ev => ev.id === el.dataset.id && ev.url === el.dataset.url);
if (ev) onEventClick(ev, el);
});
});
}
function renderNowLine(container, days, hourH = 60) {
const now = new Date();
const todayCol = container.querySelector(`.week-day-col[data-date="${dayKey(now)}"]`);
if (!todayCol) return;
const top = now.getHours() * hourH + now.getMinutes() * hourH / 60;
const line = document.createElement('div');
line.className = 'now-line';
line.style.top = top + 'px';
line.innerHTML = '<div class="now-dot"></div>';
todayCol.appendChild(line);
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) {
if (!events.length) return [];
const sorted = events.slice().sort((a, b) => new Date(a.start) - new Date(b.start));
const columns = [];
const result = sorted.map(ev => {
const start = new Date(ev.start);
let placed = false;
for (let c = 0; c < columns.length; c++) {
const lastInCol = columns[c][columns[c].length - 1];
if (new Date(lastInCol.end) <= start) {
columns[c].push(ev);
placed = true;
ev._col = c;
break;
}
}
if (!placed) {
ev._col = columns.length;
columns.push([ev]);
}
return ev;
});
return result.map(ev => {
const start = new Date(ev.start);
const end = new Date(ev.end);
let maxCol = ev._col;
sorted.forEach(other => {
if (other === ev) return;
const os = new Date(other.start);
const oe = new Date(other.end);
if (os < end && oe > start) {
maxCol = Math.max(maxCol, other._col ?? 0);
}
});
return { ev, col: ev._col, cols: maxCol + 1 };
});
}
function dayKey(d) {
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
}
function isSameDay(a, b) {
return a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
}
function fmtTime(d) {
return d.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function escAttr(s) {
return String(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}