From cd4879d5730029b0618dd5248a8f291ba049b24f Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 7 Apr 2026 21:20:42 +0200 Subject: [PATCH] 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. --- frontend/css/app.css | 150 +++++++++++++++++++------ frontend/index.html | 29 ++++- frontend/js/calendar.js | 118 +++++++++++++++++++- frontend/js/i18n.js | 22 ++++ frontend/js/views/month.js | 223 ++++++++++++++++++++++++------------- 5 files changed, 418 insertions(+), 124 deletions(-) diff --git a/frontend/css/app.css b/frontend/css/app.css index e664e34..4f9797f 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -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; } diff --git a/frontend/index.html b/frontend/index.html index d62a7c5..a489f43 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -391,7 +391,7 @@
@@ -486,10 +486,29 @@
Keine ausgeblendeten Kalender
- -
-

Google Konten

-
Keine Google-Konten verbunden
+ +
+

Konten

+ +
+
CalDAV-Konten
+
Keine CalDAV-Konten
+
+ +
+
Lokale Kalender
+
Keine lokalen Kalender
+
+ +
+
iCal-Abonnements
+
Keine Abonnements
+
+ +
+
Google-Konten
+
Keine Google-Konten
+
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 0c1fe33..27f6fb4 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -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 = `${t('settings_no_caldav_accounts')}`; + } else { + caldavList.innerHTML = state.accounts.map(acc => + `
+
+ ${escHtml(acc.name)} + ${escHtml(acc.url || '')} +
+
+ + +
+
` + ).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 = `${t('settings_no_local_cals')}`; + } else { + localList.innerHTML = state.localCalendars.map(cal => + `
+
+ + ${escHtml(cal.name)} +
+
` + ).join(''); + } + } + + // iCal subscriptions section + const icalList = document.getElementById('accounts-ical-list'); + if (icalList) { + if (!state.icalSubscriptions.length) { + icalList.innerHTML = `${t('settings_no_ical_subs')}`; + } else { + icalList.innerHTML = state.icalSubscriptions.map(sub => + `
+
+ ${escHtml(sub.name)} + ${escHtml(sub.url || '')} +
+
+ +
+
` + ).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 => - `
+ `
${escHtml(c.acc)} / ${escHtml(c.name)}
` diff --git a/frontend/js/i18n.js b/frontend/js/i18n.js index 4a51fa2..283b3d3 100644 --- a/frontend/js/i18n.js +++ b/frontend/js/i18n.js @@ -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', diff --git a/frontend/js/views/month.js b/frontend/js/views/month.js index 1f87125..9775537 100644 --- a/frontend/js/views/month.js +++ b/frontend/js/views/month.js @@ -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 = `
KW
` + + // Header + const headerHtml = + `
KW
` + DOW.map(d => `
${d}
`).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 += `
${kw}
`; + 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 `
${escHtml(title)}
`; - }).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 - ? `
${t('more_events', {n: hiddenCount})}
` - : ''; + // 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 += `
${escHtml(label)}
`; + }); - cellsHtml += `
-
${cell.getDate()}
- ${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 += `
${t('more_events', { n: count })}
`; + }); + + // 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 += `
+
${cell.getDate()}
`; - } + }); + + bodyHtml += `
+
${kw}
+
+
${dayCellsHtml}
+
${eventsHtml}
+
+
`; } container.innerHTML = `
${headerHtml}
-
${cellsHtml}
+
${bodyHtml}
`; - // 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,'&').replace(//g,'>'); } + function escAttr(s) { return String(s).replace(/"/g,'"').replace(/'/g,'''); }