initialer commit, Grundcode

This commit is contained in:
2026-03-26 11:20:48 +01:00
commit f029ed1544
25 changed files with 3530 additions and 0 deletions

View File

@@ -0,0 +1,94 @@
import { isPast } from '../utils.js';
const DOW = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag'];
const MON = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];
export function renderAgenda(container, currentDate, events, onEventClick) {
if (!events.length) {
container.innerHTML = `<div class="agenda-view"><div class="agenda-empty">Keine Termine im angezeigten Zeitraum</div></div>`;
return;
}
// Group events by date
const groups = {};
events.forEach(ev => {
const d = new Date(ev.start);
const key = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
if (!groups[key]) groups[key] = [];
groups[key].push(ev);
});
// Sort groups
const sortedKeys = Object.keys(groups).sort();
const html = sortedKeys.map(key => {
const date = new Date(key + 'T00:00:00');
const isToday = isTodayDate(date);
const todayCls = isToday ? 'today' : '';
const evHtml = groups[key]
.sort((a, b) => {
if (a.allDay && !b.allDay) return -1;
if (!a.allDay && b.allDay) return 1;
return new Date(a.start) - new Date(b.start);
})
.map(ev => {
const color = ev.color || ev.calendarColor || '#4285f4';
const pastCls = isPast(ev) ? 'past' : '';
let timeStr = 'Ganztägig';
if (!ev.allDay) {
const s = new Date(ev.start);
const e = new Date(ev.end);
timeStr = `${fmtTime(s)} ${fmtTime(e)}`;
}
const locHtml = ev.location
? `<span class="agenda-ev-meta"> · ${escHtml(ev.location)}</span>`
: '';
return `<div class="agenda-event ${pastCls}" data-id="${ev.id}" data-url="${escAttr(ev.url)}">
<div class="agenda-ev-color" style="background:${color}"></div>
<div class="agenda-ev-info">
<div class="agenda-ev-title">${escHtml(ev.title)}</div>
<div class="agenda-ev-meta">${timeStr}${locHtml}</div>
</div>
</div>`;
}).join('');
return `<div class="agenda-day">
<div class="agenda-date ${todayCls}">
<div class="agenda-date-num">${date.getDate()}</div>
<div class="agenda-date-label">
<span class="wd">${DOW[date.getDay()]}</span>
<span class="mo">${MON[date.getMonth()]} ${date.getFullYear()}</span>
</div>
</div>
${evHtml}
</div>`;
}).join('');
container.innerHTML = `<div class="agenda-view">${html}</div>`;
container.querySelectorAll('.agenda-event').forEach(el => {
el.addEventListener('click', () => {
const ev = events.find(ev => ev.id === el.dataset.id && ev.url === el.dataset.url);
if (ev) onEventClick(ev, el);
});
});
}
function isTodayDate(d) {
const now = new Date();
return d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.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;');
}

121
frontend/js/views/month.js Normal file
View File

@@ -0,0 +1,121 @@
import { formatDate, isSameDay, isToday, isPast } from '../utils.js';
const DOW = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
export function renderMonth(container, currentDate, events, onDayClick, onEventClick) {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
// Start grid on Sunday of the week containing the 1st
const gridStart = new Date(firstDay);
gridStart.setDate(gridStart.getDate() - firstDay.getDay());
const cells = [];
const d = new Date(gridStart);
for (let i = 0; i < 42; i++) {
cells.push(new Date(d));
d.setDate(d.getDate() + 1);
}
// Build event map keyed by date string
const evMap = {};
events.forEach(ev => {
const s = new Date(ev.start);
const e = ev.allDay ? new Date(ev.end) : new Date(ev.end);
// Spread multi-day events across cells
const cur = new Date(s);
cur.setHours(0, 0, 0, 0);
const endNorm = new Date(e);
endNorm.setHours(0, 0, 0, 0);
if (ev.allDay && endNorm > cur) endNorm.setDate(endNorm.getDate() - 1);
while (cur <= endNorm) {
const key = dateKey(cur);
if (!evMap[key]) evMap[key] = [];
evMap[key].push(ev);
cur.setDate(cur.getDate() + 1);
}
});
// Header
const headerHtml = DOW.map(d => `<div class="month-dow">${d}</div>`).join('');
// Cells
const cellsHtml = cells.map(cell => {
const key = dateKey(cell);
const cellEvs = (evMap[key] || []).slice().sort((a, b) => {
if (a.allDay && !b.allDay) return -1;
if (!a.allDay && b.allDay) return 1;
return new Date(a.start) - new Date(b.start);
});
const isOther = cell.getMonth() !== month;
const todayClass = isToday(cell) ? 'today' : '';
const otherClass = isOther ? 'other-month' : '';
const numClass = isToday(cell) ? 'today' : '';
const MAX_VISIBLE = 3;
const visible = cellEvs.slice(0, MAX_VISIBLE);
const hiddenCount = cellEvs.length - MAX_VISIBLE;
const evHtml = visible.map(ev => {
const color = ev.color || ev.calendarColor || '#4285f4';
const pastClass = isPast(ev) ? 'past' : '';
const title = ev.allDay ? ev.title : `${fmtTime(new Date(ev.start))} ${ev.title}`;
return `<div class="month-event ${pastClass}" data-id="${ev.id}" data-url="${escAttr(ev.url)}"
style="background:${color};color:#fff"
title="${escAttr(ev.title)}">${escHtml(title)}</div>`;
}).join('');
const moreHtml = hiddenCount > 0
? `<div class="month-more" data-date="${key}">+${hiddenCount} weitere</div>`
: '';
return `<div class="month-cell ${todayClass} ${otherClass}" data-date="${key}">
<div class="cell-day ${numClass}">${cell.getDate()}</div>
${evHtml}${moreHtml}
</div>`;
}).join('');
container.innerHTML = `<div class="month-view">
<div class="month-header">${headerHtml}</div>
<div class="month-grid">${cellsHtml}</div>
</div>`;
// Events
container.querySelectorAll('.month-cell').forEach(cell => {
cell.addEventListener('click', e => {
const evEl = e.target.closest('.month-event');
if (evEl) {
e.stopPropagation();
const ev = events.find(ev => ev.id === evEl.dataset.id && ev.url === evEl.dataset.url);
if (ev) onEventClick(ev, evEl);
return;
}
const moreEl = e.target.closest('.month-more');
if (moreEl) {
e.stopPropagation();
onDayClick(new Date(moreEl.dataset.date + 'T00:00:00'));
return;
}
onDayClick(new Date(cell.dataset.date + 'T00:00:00'));
});
});
}
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function escAttr(s) {
return String(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}

244
frontend/js/views/week.js Normal file
View File

@@ -0,0 +1,244 @@
import { isToday, isPast } from '../utils.js';
const DOW_SHORT = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
export function renderWeek(container, currentDate, events, onSlotClick, onEventClick, isSingleDay = false) {
// Build the days array (7 days for week, 1 for day)
const days = [];
if (isSingleDay) {
days.push(new Date(currentDate));
} else {
const sunday = new Date(currentDate);
sunday.setDate(sunday.getDate() - sunday.getDay());
for (let i = 0; i < 7; i++) {
const d = new Date(sunday);
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);
// ── 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">${DOW_SHORT[day.getDay()]}</div>
<div class="day-num">${day.getDate()}</div>
</div>`;
}).join('');
// ── All-day row ───────────────────────────────────────
const alldayCols = days.map(day => {
const key = dayKey(day);
const dayEvs = allDayEvs.filter(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);
const d = new Date(day); d.setHours(0,0,0,0);
return d >= s && d < e || isSameDay(d, s);
});
const inner = 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('');
return `<div class="allday-col" data-date="${key}">${inner}</div>`;
}).join('');
// ── 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 ───────────────────────────────────────
// For each day, lay out timed events
const dayCols = days.map(day => {
const key = dayKey(day);
const dayEvs = timedEvs.filter(ev => {
const s = new Date(ev.start);
return isSameDay(s, day);
});
// Compute layout columns for overlapping events
const positioned = layoutEvents(dayEvs);
const hourLines = Array.from({length: 24}, (_, h) =>
`<div class="hour-line" style="top:${h * 60}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() * 60 + s.getMinutes());
const height = Math.max(20, (e - s) / 60000);
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('');
return `<div class="week-day-col" data-date="${key}" style="height:${60*24}px">
${hourLines}
${evHtml}
</div>`;
}).join('');
const viewClass = isSingleDay ? 'day-view' : 'week-view';
container.innerHTML = `<div class="${viewClass}">
<div class="week-header-row">
<div class="week-time-gutter"></div>
${headerCols}
</div>
<div class="week-allday-row">
<div class="allday-gutter">ganztägig</div>
<div class="allday-cols">${alldayCols}</div>
</div>
<div class="week-body">
<div class="week-time-col">${timeLabels}</div>
<div class="week-days-col">${dayCols}</div>
</div>
</div>`;
// Scroll to ~8:00
const body = container.querySelector('.week-body');
if (body) body.scrollTop = 8 * 60 - 20;
// Render current-time line
renderNowLine(container, days);
// 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 + (container.querySelector('.week-body')?.scrollTop || 0);
const mins = Math.floor(y);
const h = Math.floor(mins / 60);
const m = Math.round((mins % 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 event
container.querySelectorAll('.allday-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);
});
});
}
function renderNowLine(container, days) {
const now = new Date();
const todayCol = container.querySelector(`.week-day-col[data-date="${dayKey(now)}"]`);
if (!todayCol) return;
const top = now.getHours() * 60 + now.getMinutes();
const line = document.createElement('div');
line.className = 'now-line';
line.style.top = top + 'px';
line.innerHTML = '<div class="now-dot"></div>';
todayCol.appendChild(line);
// Update every minute
setTimeout(() => renderNowLine(container, days), 60000);
}
function layoutEvents(events) {
if (!events.length) return [];
// Sort by start time
const sorted = events.slice().sort((a, b) => new Date(a.start) - new Date(b.start));
const columns = []; // each column is an array of events
const result = sorted.map(ev => {
const start = new Date(ev.start);
const end = new Date(ev.end);
// Find the first column where the event doesn't overlap
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;
});
// Calculate how many columns each event spans
return result.map(ev => {
const start = new Date(ev.start);
const end = new Date(ev.end);
// Count overlapping events
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;');
}