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);
}
.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 {
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;
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 {
border-right: 1px solid var(--border-light);
border-bottom: 1px solid var(--border);
position: absolute; left: 0; top: 0; bottom: 0;
width: 38px;
display: flex; align-items: flex-start; justify-content: center;
padding-top: 6px;
font-size: 13px; color: var(--text-3); font-weight: 700;
cursor: default; user-select: none;
min-height: 0;
border-right: 1px solid var(--border-light);
cursor: default; user-select: none; z-index: 1;
background: var(--bg-app);
}
.month-cell {
flex: 1;
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
padding: 4px;
overflow: hidden;
padding: 4px 4px 0;
cursor: pointer;
transition: background var(--transition);
min-height: 0;
min-width: 0;
}
/* every 8th child is the last day column (after KW cell) */
.month-cell:nth-child(8n) { border-right: none; }
.month-cell:last-child { border-right: none; }
.month-cell:hover { background: var(--bg-hover); }
.month-cell.today { background: rgba(66,133,244,.08); }
.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);
width: 26px; height: 26px;
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 {
background: var(--today-color);
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 {
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;
}
.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;
}
/* Contrast / option selectors */
/* Contrast / option selectors — segmented pill */
.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 {
display: flex; flex-direction: column; align-items: center; gap: 6px;
padding: 10px 16px; border-radius: var(--radius);
border: 1px solid var(--border); background: var(--bg-surface);
cursor: pointer; transition: border-color .15s, background .15s;
min-width: 70px;
display: flex; flex-direction: column; align-items: center; gap: 4px;
padding: 8px 14px; min-width: 64px;
background: transparent; border: none;
border-right: 1px solid var(--border);
cursor: pointer; color: var(--text-2);
transition: background var(--transition), color var(--transition);
}
.contrast-btn:hover { border-color: var(--primary); }
.contrast-btn.active { border-color: var(--primary); background: var(--primary-dim); }
.contrast-btn:last-child { border-right: none; }
.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-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 {
display: block; width: 36px; height: 0;
border-top: 2px solid; border-radius: 1px;
margin: 6px 0;
margin: 4px 0;
}
.hour-preview {
font-size: 14px; line-height: 1; color: var(--text-2);
}
.contrast-btn.active .hour-preview { color: #fff; }
/* ── Settings (legacy) ──────────────────────────────────── */
.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; }
/* ── 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 ─────────────────────────────────────────── */
@media (max-width: 768px) {
:root { --sidebar-w: 0px; }

View File

@@ -391,7 +391,7 @@
<div class="settings-page-body">
<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" 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>
</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>
<!-- Google Konten -->
<div class="settings-panel" id="settings-panel-google">
<h4 class="panel-title" data-i18n="settings_nav_google">Google 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>
<!-- Konten (CalDAV, Lokal, iCal, Google) -->
<div class="settings-panel" id="settings-panel-accounts">
<h4 class="panel-title" data-i18n="settings_nav_accounts">Konten</h4>
<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>
<!-- Benutzerverwaltung -->

View File

@@ -539,6 +539,23 @@ function bindTopbar() {
document.getElementById('btn-settings').onclick = openSettingsModal;
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 ────────────────────────────────────────
@@ -1071,8 +1088,8 @@ function openSettingsModal() {
const firstBtn = document.querySelector('.settings-nav-btn:not(.hidden)');
if (firstBtn) activateSettingsPanel(firstBtn.dataset.panel);
// Render Google accounts and hidden calendars
renderGoogleAccounts();
// Render all accounts and hidden calendars
renderAllAccounts();
renderHiddenCalendars();
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() {
const list = document.getElementById('hidden-cals-list');
const hidden = [];
@@ -1147,7 +1259,7 @@ function renderHiddenCalendars() {
return;
}
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>
<button class="btn btn-secondary btn-sm" data-restore-cal="${c.id}" data-restore-source="${c.source}">${t('show_cal')}</button>
</div>`

View File

@@ -86,6 +86,17 @@ const translations = {
settings_hidden_cals: 'Ausgeblendete Kalender',
settings_no_hidden_cals: 'Keine ausgeblendeten Kalender',
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
users_add: 'Benutzer hinzufügen', users_is_admin: 'Administrator',
@@ -269,6 +280,17 @@ const translations = {
settings_hidden_cals: 'Hidden calendars',
settings_no_hidden_cals: 'No hidden calendars',
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
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';
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') {
const year = currentDate.getFullYear();
const year = currentDate.getFullYear();
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 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 offset = dayOfWeek(firstDay, weekStartDay);
gridStart.setDate(gridStart.getDate() - offset);
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 => {
// Normalize each event's date range once
const normed = events.map(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);
}
s.setHours(0, 0, 0, 0);
const e = new Date(ev.end);
e.setHours(0, 0, 0, 0);
if (ev.allDay && e > s) e.setDate(e.getDate() - 1); // exclusive → inclusive
return { ev, ns: s, ne: e };
});
// Header: KW-Spalte + Wochentage
const headerHtml = `<div class="month-kw-header">KW</div>` +
// Header
const headerHtml =
`<div class="month-kw-header">KW</div>` +
DOW.map(d => `<div class="month-dow">${d}</div>`).join('');
// Build rows (6 weeks × 7 days)
let cellsHtml = '';
// Build rows
let bodyHtml = '';
for (let row = 0; row < 6; row++) {
// KW cell for the first day of this row
const rowFirstDay = cells[row * 7];
const kw = getISOWeekNumber(rowFirstDay);
cellsHtml += `<div class="month-kw-cell">${kw}</div>`;
const rowCells = cells.slice(row * 7, row * 7 + 7);
const rowStart = new Date(rowCells[0]); rowStart.setHours(0, 0, 0, 0);
const rowEnd = new Date(rowCells[6]); rowEnd.setHours(0, 0, 0, 0);
const kw = getISOWeekNumber(rowCells[0]);
for (let col = 0; col < 7; col++) {
const cell = cells[row * 7 + col];
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);
// Collect events overlapping this row
const rowItems = [];
normed.forEach(({ ev, ns, ne }) => {
if (ne < rowStart || ns > rowEnd) return;
const colStart = Math.max(0, daysBetween(rowStart, ns));
const colEnd = Math.min(6, daysBetween(rowStart, ne));
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;
const todayClass = isToday(cell) ? 'today' : '';
const otherClass = isOther ? 'other-month' : '';
const numClass = isToday(cell) ? 'today' : '';
// Sort: all-day first, then span desc, then start time
rowItems.sort((a, b) => {
if (a.ev.allDay && !b.ev.allDay) return -1;
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;
const visible = cellEvs.slice(0, MAX_VISIBLE);
const hiddenCount = cellEvs.length - MAX_VISIBLE;
// Assign lanes (greedy interval packing)
const lanes = []; // { colEnd }
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 => {
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('');
// Track overflow per column
const overflowByCol = {};
rowItems.forEach(item => {
if (item.lane >= MAX_LANES) {
for (let c = item.colStart; c < item.colStart + item.span; c++) {
overflowByCol[c] = (overflowByCol[c] || 0) + 1;
}
}
});
const moreHtml = hiddenCount > 0
? `<div class="month-more" data-date="${key}">${t('more_events', {n: hiddenCount})}</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>`;
});
cellsHtml += `<div class="month-cell ${todayClass} ${otherClass}" data-date="${key}">
<div class="cell-day ${numClass}">${cell.getDate()}</div>
${evHtml}${moreHtml}
// 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">
<div class="month-header">${headerHtml}</div>
<div class="month-grid">${cellsHtml}</div>
<div class="month-body">${bodyHtml}</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'));
});
// Click handlers — event delegation
const body = container.querySelector('.month-body');
body.addEventListener('click', e => {
// Span event click
const spanEl = e.target.closest('.month-span-event');
if (spanEl) {
e.stopPropagation();
const ev = events.find(ev => ev.id === spanEl.dataset.id && ev.url === spanEl.dataset.url);
if (ev) onEventClick(ev, spanEl);
return;
}
// "+N more" click → day view
const moreEl = e.target.closest('.month-more');
if (moreEl) {
e.stopPropagation();
onDayClick(new Date(moreEl.dataset.date + 'T00:00:00'));
return;
}
// 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) {
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) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function escAttr(s) {
return String(s).replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}