feat: Spanning event bars, wheel nav, dark datetime picker, segmented settings UI

- Month view: Multi-day events render as continuous Google Calendar-style
  spanning bars across days/weeks using a greedy lane-packing algorithm.
  Timed multi-day events no longer repeat per day.
- Mouse wheel / trackpad scrolls week-by-week in month view, day/week in
  other views (debounced, prevents default page scroll).
- datetime-local/date inputs now use color-scheme:dark so the native
  browser picker opens in dark mode; calendar icon styled to match.
- Contrast/hour-height selectors redesigned as connected segmented pill
  controls instead of individual tiles.
- Hidden calendars list gains proper padding and separator lines.
- "Google Konten" settings panel renamed "Konten" and expanded to show
  CalDAV, local calendars, iCal subscriptions, and Google accounts in
  one unified panel with sync/disconnect actions.
- New i18n keys added for accounts panel in both de and en.
This commit is contained in:
Scarriffle
2026-04-07 21:20:42 +02:00
parent fb4c7a7326
commit cd4879d573
5 changed files with 418 additions and 124 deletions

View File

@@ -151,6 +151,19 @@ a { color: var(--primary); text-decoration: none; }
border-color: var(--primary); border-color: var(--primary);
} }
.form-group textarea { resize: vertical; } .form-group textarea { resize: vertical; }
/* ── Date/time input dark mode ──────────────────────────── */
.form-group input[type="datetime-local"],
.form-group input[type="date"] {
color-scheme: dark;
}
.form-group input[type="datetime-local"]::-webkit-calendar-picker-indicator,
.form-group input[type="date"]::-webkit-calendar-picker-indicator {
filter: invert(0.8);
opacity: 0.7;
cursor: pointer;
}
.form-row { .form-row {
display: flex; gap: 12px; margin-bottom: 16px; align-items: center; display: flex; gap: 12px; margin-bottom: 16px; align-items: center;
} }
@@ -455,31 +468,25 @@ a { color: var(--primary); text-decoration: none; }
font-size: 11px; font-weight: 600; text-transform: uppercase; font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: .5px; color: var(--text-2); letter-spacing: .5px; color: var(--text-2);
} }
.month-grid {
display: grid; grid-template-columns: 38px repeat(7, 1fr);
grid-template-rows: repeat(6, 1fr);
flex: 1; overflow: hidden;
}
.month-kw-cell { .month-kw-cell {
border-right: 1px solid var(--border-light); position: absolute; left: 0; top: 0; bottom: 0;
border-bottom: 1px solid var(--border); width: 38px;
display: flex; align-items: flex-start; justify-content: center; display: flex; align-items: flex-start; justify-content: center;
padding-top: 6px; padding-top: 6px;
font-size: 13px; color: var(--text-3); font-weight: 700; font-size: 13px; color: var(--text-3); font-weight: 700;
cursor: default; user-select: none; border-right: 1px solid var(--border-light);
min-height: 0; cursor: default; user-select: none; z-index: 1;
background: var(--bg-app);
} }
.month-cell { .month-cell {
flex: 1;
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border); padding: 4px 4px 0;
padding: 4px;
overflow: hidden;
cursor: pointer; cursor: pointer;
transition: background var(--transition); transition: background var(--transition);
min-height: 0; min-width: 0;
} }
/* every 8th child is the last day column (after KW cell) */ .month-cell:last-child { border-right: none; }
.month-cell:nth-child(8n) { border-right: none; }
.month-cell:hover { background: var(--bg-hover); } .month-cell:hover { background: var(--bg-hover); }
.month-cell.today { background: rgba(66,133,244,.08); } .month-cell.today { background: rgba(66,133,244,.08); }
.month-cell.other-month .cell-day { color: var(--text-3); } .month-cell.other-month .cell-day { color: var(--text-3); }
@@ -487,23 +494,15 @@ a { color: var(--primary); text-decoration: none; }
font-size: 12px; font-weight: 500; color: var(--text-2); font-size: 12px; font-weight: 500; color: var(--text-2);
width: 26px; height: 26px; width: 26px; height: 26px;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
border-radius: 50%; margin-bottom: 2px; flex-shrink: 0; border-radius: 50%; flex-shrink: 0;
} }
.cell-day.today { .cell-day.today {
background: var(--today-color); background: var(--today-color);
color: #fff; font-weight: 700; color: #fff; font-weight: 700;
} }
.month-event {
font-size: 11px; font-weight: 500;
padding: 1px 6px; border-radius: 3px;
margin-bottom: 1px; cursor: pointer;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
transition: filter var(--transition);
}
.month-event:hover { filter: brightness(1.15); }
.month-event.past { opacity: .45; }
.month-more { .month-more {
font-size: 11px; color: var(--text-2); padding: 1px 6px; position: absolute;
font-size: 11px; color: var(--text-2); padding: 0 4px;
cursor: pointer; font-weight: 500; cursor: pointer; font-weight: 500;
} }
.month-more:hover { color: var(--primary); } .month-more:hover { color: var(--primary); }
@@ -747,29 +746,38 @@ a { color: var(--primary); text-decoration: none; }
font-size: 12px; color: var(--text-3); margin: 0 0 12px; font-size: 12px; color: var(--text-3); margin: 0 0 12px;
} }
/* Contrast / option selectors */ /* Contrast / option selectors — segmented pill */
.contrast-selector { .contrast-selector {
display: flex; gap: 8px; flex-wrap: wrap; display: inline-flex;
border: 1px solid var(--border);
border-radius: 20px;
overflow: hidden;
background: var(--bg-surface);
} }
.contrast-btn { .contrast-btn {
display: flex; flex-direction: column; align-items: center; gap: 6px; display: flex; flex-direction: column; align-items: center; gap: 4px;
padding: 10px 16px; border-radius: var(--radius); padding: 8px 14px; min-width: 64px;
border: 1px solid var(--border); background: var(--bg-surface); background: transparent; border: none;
cursor: pointer; transition: border-color .15s, background .15s; border-right: 1px solid var(--border);
min-width: 70px; cursor: pointer; color: var(--text-2);
transition: background var(--transition), color var(--transition);
} }
.contrast-btn:hover { border-color: var(--primary); } .contrast-btn:last-child { border-right: none; }
.contrast-btn.active { border-color: var(--primary); background: var(--primary-dim); } .contrast-btn:hover { background: var(--bg-hover); color: var(--text-1); }
.contrast-btn.active { background: var(--primary); color: #fff; }
.contrast-btn span { font-size: 18px; font-weight: 700; line-height: 1; } .contrast-btn span { font-size: 18px; font-weight: 700; line-height: 1; }
.contrast-lbl { font-size: 11px; color: var(--text-2); white-space: nowrap; } .contrast-btn.active span { color: #fff !important; }
.contrast-lbl { font-size: 11px; white-space: nowrap; }
.contrast-btn.active .contrast-lbl { color: #fff; }
.line-preview { .line-preview {
display: block; width: 36px; height: 0; display: block; width: 36px; height: 0;
border-top: 2px solid; border-radius: 1px; border-top: 2px solid; border-radius: 1px;
margin: 6px 0; margin: 4px 0;
} }
.hour-preview { .hour-preview {
font-size: 14px; line-height: 1; color: var(--text-2); font-size: 14px; line-height: 1; color: var(--text-2);
} }
.contrast-btn.active .hour-preview { color: #fff; }
/* ── Settings (legacy) ──────────────────────────────────── */ /* ── Settings (legacy) ──────────────────────────────────── */
.settings-section { margin-bottom: 28px; } .settings-section { margin-bottom: 28px; }
@@ -861,6 +869,74 @@ a { color: var(--primary); text-decoration: none; }
.loading-view { display: flex; justify-content: center; align-items: center; height: 200px; } .loading-view { display: flex; justify-content: center; align-items: center; height: 200px; }
/* ── Accounts Panel ──────────────────────────────────────── */
.accounts-section { margin-bottom: 24px; }
.accounts-section-heading {
font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: .5px; color: var(--text-3);
padding: 0 0 8px;
border-bottom: 1px solid var(--border-light);
margin-bottom: 10px;
}
.accounts-section-empty {
font-size: 13px; color: var(--text-3); padding: 4px 0; display: block;
}
.accounts-row {
display: flex; align-items: center;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid var(--border-light);
}
.accounts-row:last-child { border-bottom: none; }
.accounts-row-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.accounts-row-name { font-size: 13px; color: var(--text-1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.accounts-row-sub { font-size: 11px; color: var(--text-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.accounts-row-actions { display: flex; gap: 6px; flex-shrink: 0; margin-left: 8px; }
.accounts-local-dot {
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; display: inline-block;
}
/* ── Month View (spanning bars) ─────────────────────────── */
.month-body {
display: flex; flex-direction: column; flex: 1; overflow: hidden;
}
.month-row {
display: flex; flex: 1; position: relative; min-height: 0;
border-bottom: 1px solid var(--border);
}
.month-row:last-child { border-bottom: none; }
.month-row-right {
margin-left: 38px; display: flex; flex-direction: column; flex: 1; min-width: 0;
}
.month-day-strip {
display: flex; flex-shrink: 0;
}
.month-events-area {
position: relative; flex: 1;
min-height: 72px; /* 3 lanes × 22px + 6px padding */
overflow: hidden;
}
.month-span-event {
position: absolute;
height: 18px; line-height: 18px;
border-radius: 3px;
padding: 0 6px;
font-size: 11px; font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
cursor: pointer; color: #fff;
transition: filter var(--transition);
box-sizing: border-box;
z-index: 2;
}
.month-span-event:hover { filter: brightness(1.15); }
.month-span-event.past { opacity: .45; }
.month-span-event.continues-left {
border-top-left-radius: 0; border-bottom-left-radius: 0; padding-left: 3px;
}
.month-span-event.continues-right {
border-top-right-radius: 0; border-bottom-right-radius: 0; padding-right: 3px;
}
/* ── Responsive ─────────────────────────────────────────── */ /* ── Responsive ─────────────────────────────────────────── */
@media (max-width: 768px) { @media (max-width: 768px) {
:root { --sidebar-w: 0px; } :root { --sidebar-w: 0px; }

View File

@@ -391,7 +391,7 @@
<div class="settings-page-body"> <div class="settings-page-body">
<nav class="settings-nav"> <nav class="settings-nav">
<button class="settings-nav-btn active" data-panel="general" data-i18n="settings_nav_appearance">Darstellung</button> <button class="settings-nav-btn active" data-panel="general" data-i18n="settings_nav_appearance">Darstellung</button>
<button class="settings-nav-btn" data-panel="google" data-i18n="settings_nav_google">Google Konten</button> <button class="settings-nav-btn" data-panel="accounts" data-i18n="settings_nav_accounts">Konten</button>
<button class="settings-nav-btn hidden" data-panel="users" id="settings-nav-users" data-i18n="settings_nav_users">Benutzerverwaltung</button> <button class="settings-nav-btn hidden" data-panel="users" id="settings-nav-users" data-i18n="settings_nav_users">Benutzerverwaltung</button>
</nav> </nav>
@@ -486,10 +486,29 @@
<div id="hidden-cals-list"><span style="font-size:13px;color:var(--text-3)" data-i18n="settings_no_hidden_cals">Keine ausgeblendeten Kalender</span></div> <div id="hidden-cals-list"><span style="font-size:13px;color:var(--text-3)" data-i18n="settings_no_hidden_cals">Keine ausgeblendeten Kalender</span></div>
</div> </div>
<!-- Google Konten --> <!-- Konten (CalDAV, Lokal, iCal, Google) -->
<div class="settings-panel" id="settings-panel-google"> <div class="settings-panel" id="settings-panel-accounts">
<h4 class="panel-title" data-i18n="settings_nav_google">Google Konten</h4> <h4 class="panel-title" data-i18n="settings_nav_accounts">Konten</h4>
<div id="google-accounts-list"><span style="font-size:13px;color:var(--text-3)" data-i18n="settings_no_google">Keine Google-Konten verbunden</span></div>
<div class="accounts-section">
<div class="accounts-section-heading" data-i18n="settings_accounts_caldav">CalDAV-Konten</div>
<div id="accounts-caldav-list"><span class="accounts-section-empty" data-i18n="settings_no_caldav_accounts">Keine CalDAV-Konten</span></div>
</div>
<div class="accounts-section">
<div class="accounts-section-heading" data-i18n="settings_accounts_local">Lokale Kalender</div>
<div id="accounts-local-list"><span class="accounts-section-empty" data-i18n="settings_no_local_cals">Keine lokalen Kalender</span></div>
</div>
<div class="accounts-section">
<div class="accounts-section-heading" data-i18n="settings_accounts_ical">iCal-Abonnements</div>
<div id="accounts-ical-list"><span class="accounts-section-empty" data-i18n="settings_no_ical_subs">Keine Abonnements</span></div>
</div>
<div class="accounts-section">
<div class="accounts-section-heading" data-i18n="settings_accounts_google">Google-Konten</div>
<div id="google-accounts-list"><span class="accounts-section-empty" data-i18n="settings_no_google_accounts">Keine Google-Konten</span></div>
</div>
</div> </div>
<!-- Benutzerverwaltung --> <!-- Benutzerverwaltung -->

View File

@@ -539,6 +539,23 @@ function bindTopbar() {
document.getElementById('btn-settings').onclick = openSettingsModal; document.getElementById('btn-settings').onclick = openSettingsModal;
document.getElementById('btn-create-event').onclick = () => openNewEventModal(new Date()); document.getElementById('btn-create-event').onclick = () => openNewEventModal(new Date());
// Mouse wheel / trackpad scroll navigation
let _wheelTimer = null;
document.getElementById('view-container').addEventListener('wheel', e => {
e.preventDefault();
if (_wheelTimer) return;
_wheelTimer = setTimeout(() => { _wheelTimer = null; }, 80);
const dir = e.deltaY > 0 ? 1 : -1;
if (state.currentView === 'agenda') return;
if (state.currentView === 'month') {
state.currentDate = new Date(state.currentDate);
state.currentDate.setDate(state.currentDate.getDate() + dir * 7);
fetchAndRender();
} else {
navigate(dir);
}
}, { passive: false });
} }
// ── Sidebar toggle ──────────────────────────────────────── // ── Sidebar toggle ────────────────────────────────────────
@@ -1071,8 +1088,8 @@ function openSettingsModal() {
const firstBtn = document.querySelector('.settings-nav-btn:not(.hidden)'); const firstBtn = document.querySelector('.settings-nav-btn:not(.hidden)');
if (firstBtn) activateSettingsPanel(firstBtn.dataset.panel); if (firstBtn) activateSettingsPanel(firstBtn.dataset.panel);
// Render Google accounts and hidden calendars // Render all accounts and hidden calendars
renderGoogleAccounts(); renderAllAccounts();
renderHiddenCalendars(); renderHiddenCalendars();
openModal('modal-settings'); openModal('modal-settings');
@@ -1129,6 +1146,101 @@ function renderGoogleAccounts() {
}); });
} }
function renderAllAccounts() {
// CalDAV section
const caldavList = document.getElementById('accounts-caldav-list');
if (caldavList) {
if (!state.accounts.length) {
caldavList.innerHTML = `<span class="accounts-section-empty">${t('settings_no_caldav_accounts')}</span>`;
} else {
caldavList.innerHTML = state.accounts.map(acc =>
`<div class="accounts-row">
<div class="accounts-row-info">
<span class="accounts-row-name">${escHtml(acc.name)}</span>
<span class="accounts-row-sub">${escHtml(acc.url || '')}</span>
</div>
<div class="accounts-row-actions">
<button class="btn btn-secondary btn-sm" data-caldav-sync="${acc.id}">${t('sync')}</button>
<button class="btn btn-ghost btn-sm" data-caldav-disconnect="${acc.id}">${t('disconnect')}</button>
</div>
</div>`
).join('');
caldavList.querySelectorAll('[data-caldav-sync]').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true; btn.textContent = '…';
try {
await api.post(`/caldav/accounts/${btn.dataset.caldavSync}/sync`);
renderCalendarList(); fetchAndRender();
showToast(t('google_synced'));
} catch (e) { showToast(e.message, true); }
finally { btn.disabled = false; btn.textContent = t('sync'); }
});
});
caldavList.querySelectorAll('[data-caldav-disconnect]').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm(t('confirm_caldav_disconnect'))) return;
try {
await api.delete(`/caldav/accounts/${btn.dataset.caldavDisconnect}`);
state.accounts = state.accounts.filter(a => a.id !== parseInt(btn.dataset.caldavDisconnect));
renderAllAccounts(); renderCalendarList(); fetchAndRender();
showToast(t('caldav_disconnected'));
} catch (e) { showToast(e.message, true); }
});
});
}
}
// Local calendars section
const localList = document.getElementById('accounts-local-list');
if (localList) {
if (!state.localCalendars.length) {
localList.innerHTML = `<span class="accounts-section-empty">${t('settings_no_local_cals')}</span>`;
} else {
localList.innerHTML = state.localCalendars.map(cal =>
`<div class="accounts-row">
<div style="display:flex;align-items:center;gap:8px;min-width:0">
<span class="accounts-local-dot" style="background:${cal.color || '#34a853'}"></span>
<span class="accounts-row-name">${escHtml(cal.name)}</span>
</div>
</div>`
).join('');
}
}
// iCal subscriptions section
const icalList = document.getElementById('accounts-ical-list');
if (icalList) {
if (!state.icalSubscriptions.length) {
icalList.innerHTML = `<span class="accounts-section-empty">${t('settings_no_ical_subs')}</span>`;
} else {
icalList.innerHTML = state.icalSubscriptions.map(sub =>
`<div class="accounts-row">
<div class="accounts-row-info">
<span class="accounts-row-name">${escHtml(sub.name)}</span>
<span class="accounts-row-sub">${escHtml(sub.url || '')}</span>
</div>
<div class="accounts-row-actions">
<button class="btn btn-ghost btn-sm" data-ical-delete="${sub.id}">${t('delete')}</button>
</div>
</div>`
).join('');
icalList.querySelectorAll('[data-ical-delete]').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm(t('confirm_remove_ical'))) return;
try {
await api.delete(`/ical/subscriptions/${btn.dataset.icalDelete}`);
state.icalSubscriptions = state.icalSubscriptions.filter(s => s.id !== parseInt(btn.dataset.icalDelete));
renderAllAccounts(); renderCalendarList(); fetchAndRender();
} catch (e) { showToast(e.message, true); }
});
});
}
}
// Google accounts section — delegate to existing function
renderGoogleAccounts();
}
function renderHiddenCalendars() { function renderHiddenCalendars() {
const list = document.getElementById('hidden-cals-list'); const list = document.getElementById('hidden-cals-list');
const hidden = []; const hidden = [];
@@ -1147,7 +1259,7 @@ function renderHiddenCalendars() {
return; return;
} }
list.innerHTML = hidden.map(c => list.innerHTML = hidden.map(c =>
`<div style="display:flex;align-items:center;justify-content:space-between;padding:4px 0"> `<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--border-light)">
<span style="font-size:13px">${escHtml(c.acc)} / ${escHtml(c.name)}</span> <span style="font-size:13px">${escHtml(c.acc)} / ${escHtml(c.name)}</span>
<button class="btn btn-secondary btn-sm" data-restore-cal="${c.id}" data-restore-source="${c.source}">${t('show_cal')}</button> <button class="btn btn-secondary btn-sm" data-restore-cal="${c.id}" data-restore-source="${c.source}">${t('show_cal')}</button>
</div>` </div>`

View File

@@ -86,6 +86,17 @@ const translations = {
settings_hidden_cals: 'Ausgeblendete Kalender', settings_hidden_cals: 'Ausgeblendete Kalender',
settings_no_hidden_cals: 'Keine ausgeblendeten Kalender', settings_no_hidden_cals: 'Keine ausgeblendeten Kalender',
settings_no_google: 'Keine Google-Konten verbunden', settings_no_google: 'Keine Google-Konten verbunden',
settings_nav_accounts: 'Konten',
settings_accounts_caldav: 'CalDAV-Konten',
settings_accounts_local: 'Lokale Kalender',
settings_accounts_ical: 'iCal-Abonnements',
settings_accounts_google: 'Google-Konten',
settings_no_caldav_accounts: 'Keine CalDAV-Konten',
settings_no_local_cals: 'Keine lokalen Kalender',
settings_no_ical_subs: 'Keine Abonnements',
settings_no_google_accounts: 'Keine Google-Konten',
confirm_caldav_disconnect: 'CalDAV-Konto wirklich trennen?',
caldav_disconnected: 'CalDAV-Konto getrennt',
// User management // User management
users_add: 'Benutzer hinzufügen', users_is_admin: 'Administrator', users_add: 'Benutzer hinzufügen', users_is_admin: 'Administrator',
@@ -269,6 +280,17 @@ const translations = {
settings_hidden_cals: 'Hidden calendars', settings_hidden_cals: 'Hidden calendars',
settings_no_hidden_cals: 'No hidden calendars', settings_no_hidden_cals: 'No hidden calendars',
settings_no_google: 'No Google accounts connected', settings_no_google: 'No Google accounts connected',
settings_nav_accounts: 'Accounts',
settings_accounts_caldav: 'CalDAV Accounts',
settings_accounts_local: 'Local Calendars',
settings_accounts_ical: 'iCal Subscriptions',
settings_accounts_google: 'Google Accounts',
settings_no_caldav_accounts: 'No CalDAV accounts',
settings_no_local_cals: 'No local calendars',
settings_no_ical_subs: 'No subscriptions',
settings_no_google_accounts: 'No Google accounts',
confirm_caldav_disconnect: 'Really disconnect CalDAV account?',
caldav_disconnected: 'CalDAV account disconnected',
// User management // User management
users_add: 'Add user', users_is_admin: 'Administrator', users_add: 'Add user', users_is_admin: 'Administrator',

View File

@@ -1,121 +1,185 @@
import { formatDate, isSameDay, isToday, isPast, dayOfWeek, getISOWeekNumber } from '../utils.js'; import { isToday, isPast, dayOfWeek, getISOWeekNumber } from '../utils.js';
import { t } from '../i18n.js'; import { t } from '../i18n.js';
const LANE_H = 20; // px per lane (height 18px + 2px gap)
const MAX_LANES = 3; // max visible event lanes per row
export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') { export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') {
const year = currentDate.getFullYear(); const year = currentDate.getFullYear();
const month = currentDate.getMonth(); const month = currentDate.getMonth();
const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday'); const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday');
const firstDay = new Date(year, month, 1); const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
// Start grid on the correct weekday // Build 42-cell grid
const cells = [];
const gridStart = new Date(firstDay); const gridStart = new Date(firstDay);
const offset = dayOfWeek(firstDay, weekStartDay); const offset = dayOfWeek(firstDay, weekStartDay);
gridStart.setDate(gridStart.getDate() - offset); gridStart.setDate(gridStart.getDate() - offset);
const cells = [];
const d = new Date(gridStart); const d = new Date(gridStart);
for (let i = 0; i < 42; i++) { for (let i = 0; i < 42; i++) {
cells.push(new Date(d)); cells.push(new Date(d));
d.setDate(d.getDate() + 1); d.setDate(d.getDate() + 1);
} }
// Build event map keyed by date string // Normalize each event's date range once
const evMap = {}; const normed = events.map(ev => {
events.forEach(ev => {
const s = new Date(ev.start); const s = new Date(ev.start);
const e = ev.allDay ? new Date(ev.end) : new Date(ev.end); s.setHours(0, 0, 0, 0);
// Spread multi-day events across cells const e = new Date(ev.end);
const cur = new Date(s); e.setHours(0, 0, 0, 0);
cur.setHours(0, 0, 0, 0); if (ev.allDay && e > s) e.setDate(e.getDate() - 1); // exclusive → inclusive
const endNorm = new Date(e); return { ev, ns: s, ne: 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: KW-Spalte + Wochentage // Header
const headerHtml = `<div class="month-kw-header">KW</div>` + const headerHtml =
`<div class="month-kw-header">KW</div>` +
DOW.map(d => `<div class="month-dow">${d}</div>`).join(''); DOW.map(d => `<div class="month-dow">${d}</div>`).join('');
// Build rows (6 weeks × 7 days) // Build rows
let cellsHtml = ''; let bodyHtml = '';
for (let row = 0; row < 6; row++) { for (let row = 0; row < 6; row++) {
// KW cell for the first day of this row const rowCells = cells.slice(row * 7, row * 7 + 7);
const rowFirstDay = cells[row * 7]; const rowStart = new Date(rowCells[0]); rowStart.setHours(0, 0, 0, 0);
const kw = getISOWeekNumber(rowFirstDay); const rowEnd = new Date(rowCells[6]); rowEnd.setHours(0, 0, 0, 0);
cellsHtml += `<div class="month-kw-cell">${kw}</div>`; const kw = getISOWeekNumber(rowCells[0]);
for (let col = 0; col < 7; col++) { // Collect events overlapping this row
const cell = cells[row * 7 + col]; const rowItems = [];
const key = dateKey(cell); normed.forEach(({ ev, ns, ne }) => {
const cellEvs = (evMap[key] || []).slice().sort((a, b) => { if (ne < rowStart || ns > rowEnd) return;
if (a.allDay && !b.allDay) return -1; const colStart = Math.max(0, daysBetween(rowStart, ns));
if (!a.allDay && b.allDay) return 1; const colEnd = Math.min(6, daysBetween(rowStart, ne));
return new Date(a.start) - new Date(b.start); if (colEnd < colStart) return;
const span = colEnd - colStart + 1;
rowItems.push({
ev,
colStart,
span,
continuesLeft: ns < rowStart,
continuesRight: ne > rowEnd,
});
}); });
const isOther = cell.getMonth() !== month; // Sort: all-day first, then span desc, then start time
const todayClass = isToday(cell) ? 'today' : ''; rowItems.sort((a, b) => {
const otherClass = isOther ? 'other-month' : ''; if (a.ev.allDay && !b.ev.allDay) return -1;
const numClass = isToday(cell) ? 'today' : ''; 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);
});
const MAX_VISIBLE = 3; // Assign lanes (greedy interval packing)
const visible = cellEvs.slice(0, MAX_VISIBLE); const lanes = []; // { colEnd }
const hiddenCount = cellEvs.length - MAX_VISIBLE; 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;
});
const evHtml = visible.map(ev => { // Track overflow per column
const color = ev.color || ev.calendarColor || '#4285f4'; const overflowByCol = {};
const pastClass = isPast(ev) ? 'past' : ''; rowItems.forEach(item => {
const title = ev.allDay ? ev.title : `${fmtTime(new Date(ev.start))} ${ev.title}`; if (item.lane >= MAX_LANES) {
return `<div class="month-event ${pastClass}" data-id="${ev.id}" data-url="${escAttr(ev.url)}" for (let c = item.colStart; c < item.colStart + item.span; c++) {
style="background:${color};color:#fff" overflowByCol[c] = (overflowByCol[c] || 0) + 1;
title="${escAttr(ev.title)}">${escHtml(title)}</div>`;
}).join('');
const moreHtml = hiddenCount > 0
? `<div class="month-more" data-date="${key}">${t('more_events', {n: hiddenCount})}</div>`
: '';
cellsHtml += `<div class="month-cell ${todayClass} ${otherClass}" data-date="${key}">
<div class="cell-day ${numClass}">${cell.getDate()}</div>
${evHtml}${moreHtml}
</div>`;
} }
} }
});
// Render event spans
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>`;
});
// Render "+N more" per column
Object.entries(overflowByCol).forEach(([col, count]) => {
const c = parseInt(col);
const leftPct = (c / 7) * 100;
const widthPct = (1 / 7) * 100;
eventsHtml += `<div class="month-more"
data-date="${dateKey(rowCells[c])}"
style="left:${leftPct.toFixed(3)}%;width:${widthPct.toFixed(3)}%;bottom:2px">${t('more_events', { n: count })}</div>`;
});
// Day cells (numbers only)
let dayCellsHtml = '';
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' : '';
dayCellsHtml += `<div class="month-cell ${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">
<div class="month-day-strip">${dayCellsHtml}</div>
<div class="month-events-area">${eventsHtml}</div>
</div>
</div>`;
}
container.innerHTML = `<div class="month-view"> container.innerHTML = `<div class="month-view">
<div class="month-header">${headerHtml}</div> <div class="month-header">${headerHtml}</div>
<div class="month-grid">${cellsHtml}</div> <div class="month-body">${bodyHtml}</div>
</div>`; </div>`;
// Events // Click handlers — event delegation
container.querySelectorAll('.month-cell').forEach(cell => { const body = container.querySelector('.month-body');
cell.addEventListener('click', e => { body.addEventListener('click', e => {
const evEl = e.target.closest('.month-event'); // Span event click
if (evEl) { const spanEl = e.target.closest('.month-span-event');
if (spanEl) {
e.stopPropagation(); e.stopPropagation();
const ev = events.find(ev => ev.id === evEl.dataset.id && ev.url === evEl.dataset.url); const ev = events.find(ev => ev.id === spanEl.dataset.id && ev.url === spanEl.dataset.url);
if (ev) onEventClick(ev, evEl); if (ev) onEventClick(ev, spanEl);
return; return;
} }
// "+N more" click → day view
const moreEl = e.target.closest('.month-more'); const moreEl = e.target.closest('.month-more');
if (moreEl) { if (moreEl) {
e.stopPropagation(); e.stopPropagation();
onDayClick(new Date(moreEl.dataset.date + 'T00:00:00')); onDayClick(new Date(moreEl.dataset.date + 'T00:00:00'));
return; return;
} }
onDayClick(new Date(cell.dataset.date + 'T00:00:00')); // Day cell click → day view
}); const cellEl = e.target.closest('.month-cell');
if (cellEl) {
onDayClick(new Date(cellEl.dataset.date + 'T00:00:00'));
}
}); });
} }
// ── Helpers ───────────────────────────────────────────────
function daysBetween(a, b) {
// Number of whole days from date a to date b (can be negative)
return Math.round((b - a) / 86400000);
}
function dateKey(d) { function dateKey(d) {
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
} }
@@ -127,6 +191,7 @@ function fmtTime(d) {
function escHtml(s) { function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
} }
function escAttr(s) { function escAttr(s) {
return String(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;'); return String(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;');
} }