initialer commit, Grundcode
This commit is contained in:
94
frontend/js/views/agenda.js
Normal file
94
frontend/js/views/agenda.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
function escAttr(s) {
|
||||
return String(s).replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
121
frontend/js/views/month.js
Normal file
121
frontend/js/views/month.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
function escAttr(s) {
|
||||
return String(s).replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
244
frontend/js/views/week.js
Normal file
244
frontend/js/views/week.js
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
function escAttr(s) {
|
||||
return String(s).replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
Reference in New Issue
Block a user