From e2f98520e2e3305b026c8e2873e0b50c5fb34a55 Mon Sep 17 00:00:00 2001 From: Guido Schmit Date: Tue, 7 Apr 2026 21:44:44 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Month=20grid=20lines,=20scroll=20throttl?= =?UTF-8?q?e,=20custom=20dark=20date/time=20picker=20-=20Month=20view:=20R?= =?UTF-8?q?eplaced=20day-strip+events-area=20with=20full-height=20column?= =?UTF-8?q?=20=20=20divs=20(.month-col)=20so=20borders=20extend=20the=20fu?= =?UTF-8?q?ll=20row=20height=20and=20clicking=20=20=20anywhere=20in=20a=20?= =?UTF-8?q?day=20column=20(including=20below=20events)=20navigates=20to=20?= =?UTF-8?q?day=20view.=20=20=20Events=20overlay=20uses=20pointer-events:no?= =?UTF-8?q?ne=20(pass-through)=20while=20span=20bars=20=20=20and=20+N-more?= =?UTF-8?q?=20labels=20stay=20pointer-events:all.=20-=20Scroll=20navigatio?= =?UTF-8?q?n:=20Changed=20wheel=20handler=20from=2080ms=20debounce=20to=20?= =?UTF-8?q?500ms=20=20=20leading-edge=20throttle=20=E2=80=94=20one=20navig?= =?UTF-8?q?ation=20per=20trackpad=20gesture.=20-=20Custom=20date/time=20pi?= =?UTF-8?q?cker=20(date-picker.js):=20Dark=20calendar=20grid=20with=20=20?= =?UTF-8?q?=20prev/next=20navigation,=20today/selected=20highlighting,=20a?= =?UTF-8?q?nd=20a=20CSS=20=20=20scroll-snap=20time=20scroller=20(hours=200?= =?UTF-8?q?-23,=20minutes=200-59)=20matching=20the=20=20=20app's=20primary?= =?UTF-8?q?=20color.=20Language-aware=20(month=20names,=20day=20headers=20?= =?UTF-8?q?via=20t()).=20-=20Event=20modal=20datetime=20inputs=20replaced?= =?UTF-8?q?=20with=20hidden=20inputs=20+=20.dt-display=20=20=20click=20tar?= =?UTF-8?q?gets=20that=20open=20the=20custom=20picker.=20setDtValue()=20he?= =?UTF-8?q?lper=20keeps=20=20=20hidden=20input=20and=20display=20label=20i?= =?UTF-8?q?n=20sync.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/css/app.css | 166 +++++++++++++++++++---- frontend/index.html | 24 +++- frontend/js/calendar.js | 55 ++++++-- frontend/js/date-picker.js | 271 +++++++++++++++++++++++++++++++++++++ frontend/js/views/month.js | 46 +++---- 5 files changed, 491 insertions(+), 71 deletions(-) create mode 100644 frontend/js/date-picker.js diff --git a/frontend/css/app.css b/frontend/css/app.css index 4f9797f..89e567b 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -469,8 +469,7 @@ a { color: var(--primary); text-decoration: none; } letter-spacing: .5px; color: var(--text-2); } .month-kw-cell { - position: absolute; left: 0; top: 0; bottom: 0; - width: 38px; + 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; @@ -478,32 +477,34 @@ a { color: var(--primary); text-decoration: none; } cursor: default; user-select: none; z-index: 1; background: var(--bg-app); } -.month-cell { - flex: 1; - border-right: 1px solid var(--border); - padding: 4px 4px 0; - cursor: pointer; - transition: background var(--transition); - min-width: 0; +/* Full-height column divs — click target + border */ +.month-col { + flex: 1; border-right: 1px solid var(--border); + cursor: pointer; transition: background var(--transition); + padding: 4px 4px 0; min-width: 0; } -.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); } +.month-col:last-child { border-right: none; } +.month-col:hover { background: var(--bg-hover); } +.month-col.today { background: rgba(66,133,244,.08); } +.month-col.other-month .cell-day { color: var(--text-3); } .cell-day { 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%; flex-shrink: 0; } -.cell-day.today { - background: var(--today-color); - color: #fff; font-weight: 700; +.cell-day.today { background: var(--today-color); color: #fff; font-weight: 700; } +/* Events overlay — pointer-events:none so clicks pass to columns */ +.month-events-overlay { + position: absolute; top: 30px; left: 0; right: 0; bottom: 0; + pointer-events: none; overflow: hidden; z-index: 2; } .month-more { position: absolute; font-size: 11px; color: var(--text-2); padding: 0 4px; cursor: pointer; font-weight: 500; + pointer-events: all; + white-space: nowrap; overflow: hidden; } .month-more:hover { color: var(--primary); } @@ -901,32 +902,24 @@ a { color: var(--primary); text-decoration: none; } display: flex; flex-direction: column; flex: 1; overflow: hidden; } .month-row { - display: flex; flex: 1; position: relative; min-height: 0; + display: flex; flex: 1; position: relative; min-height: 100px; border-bottom: 1px solid var(--border); } .month-row:last-child { border-bottom: none; } +/* row-right: flex row containing 7 full-height column divs + events overlay */ .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; + margin-left: 38px; display: flex; flex: 1; position: relative; min-width: 0; } .month-span-event { position: absolute; height: 18px; line-height: 18px; - border-radius: 3px; - padding: 0 6px; + 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; + pointer-events: all; } .month-span-event:hover { filter: brightness(1.15); } .month-span-event.past { opacity: .45; } @@ -947,3 +940,118 @@ a { color: var(--primary); text-decoration: none; } .logo-text { display: none; } .view-title { font-size: 16px; } } + +/* ── Custom Date/Time Display Field ─────────────────────── */ +.dt-display { + display: flex; align-items: center; justify-content: space-between; + background: var(--bg-app); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 12px; + color: var(--text-1); + cursor: pointer; + transition: border-color var(--transition); + user-select: none; + min-height: 42px; +} +.dt-display:hover, .dt-display:focus { border-color: var(--primary); outline: none; } +.dt-display-text { font-size: 14px; } +.dt-display-icon { color: var(--text-3); flex-shrink: 0; margin-left: 6px; } + +/* ── Date/Time Picker Card ───────────────────────────────── */ +.dtp-overlay { + position: fixed; inset: 0; z-index: 700; +} +.dtp-card { + position: fixed; z-index: 701; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + padding: 16px; + width: 272px; + user-select: none; +} +/* Calendar header */ +.dtp-cal-header { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 10px; +} +.dtp-month-label { + font-size: 14px; font-weight: 600; color: var(--text-1); +} +.dtp-nav-btn { + background: none; border: none; cursor: pointer; + color: var(--text-2); font-size: 22px; line-height: 1; + padding: 2px 6px; border-radius: var(--radius-sm); + transition: background var(--transition), color var(--transition); +} +.dtp-nav-btn:hover { background: var(--bg-hover); color: var(--text-1); } +/* Day-of-week headers */ +.dtp-grid { + display: grid; grid-template-columns: repeat(7, 1fr); + gap: 2px; margin-bottom: 4px; +} +.dtp-dow { + text-align: center; font-size: 11px; font-weight: 600; + color: var(--text-3); padding: 4px 0; text-transform: uppercase; +} +/* Day cells */ +.dtp-day { + display: flex; align-items: center; justify-content: center; + height: 34px; border-radius: 6px; + font-size: 13px; font-weight: 500; color: var(--text-1); + cursor: pointer; transition: background var(--transition); +} +.dtp-day:hover { background: var(--bg-hover); } +.dtp-day.other { color: var(--text-3); } +.dtp-day.other:hover { background: var(--bg-hover); } +.dtp-day.today { color: var(--primary); font-weight: 700; } +.dtp-day.selected { + background: var(--primary) !important; + color: #fff !important; font-weight: 700; +} +/* Time picker */ +.dtp-time-row { + display: flex; align-items: center; justify-content: center; + gap: 4px; margin: 12px 0 8px; + border-top: 1px solid var(--border-light); + padding-top: 12px; +} +.dtp-colon { + font-size: 22px; font-weight: 600; color: var(--text-2); + padding: 0 2px; line-height: 1; + align-self: center; +} +.dtp-tc-wrap { + position: relative; width: 64px; +} +.dtp-tc { + height: calc(3 * 40px); /* 120px = 3 visible items */ + overflow-y: scroll; + scroll-snap-type: y mandatory; + scrollbar-width: none; + padding: 40px 0; /* top/bottom padding so first/last can center */ + box-sizing: content-box; +} +.dtp-tc::-webkit-scrollbar { display: none; } +.dtp-ti { + height: 40px; line-height: 40px; + text-align: center; font-size: 20px; font-weight: 500; + scroll-snap-align: center; + border-radius: 8px; + cursor: pointer; + color: var(--text-2); + transition: background .1s, color .1s; +} +.dtp-ti:hover { color: var(--text-1); } +.dtp-ti.selected { + background: var(--primary); + color: #fff; +} +/* Actions */ +.dtp-actions { + display: flex; justify-content: flex-end; gap: 8px; + margin-top: 12px; padding-top: 10px; + border-top: 1px solid var(--border-light); +} diff --git a/frontend/index.html b/frontend/index.html index a489f43..480ebe7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -202,21 +202,37 @@
- + +
+ + +
- + +
+ + +
diff --git a/frontend/js/calendar.js b/frontend/js/calendar.js index 27f6fb4..4ddb9da 100644 --- a/frontend/js/calendar.js +++ b/frontend/js/calendar.js @@ -4,6 +4,7 @@ import { renderMonth } from './views/month.js'; import { renderWeek } from './views/week.js'; import { renderAgenda } from './views/agenda.js'; import { openColorPicker } from './color-picker.js'; +import { openDatePicker, formatDtDisplay } from './date-picker.js'; import { t, setLang, getLang } from './i18n.js'; // Fetch avatar image as blob URL (with auth header) @@ -540,12 +541,13 @@ 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; + // Mouse wheel / trackpad scroll navigation (500ms cooldown = 1 nav per gesture) + let _wheelLast = 0; document.getElementById('view-container').addEventListener('wheel', e => { e.preventDefault(); - if (_wheelTimer) return; - _wheelTimer = setTimeout(() => { _wheelTimer = null; }, 80); + const now = Date.now(); + if (now - _wheelLast < 500) return; + _wheelLast = now; const dir = e.deltaY > 0 ? 1 : -1; if (state.currentView === 'agenda') return; if (state.currentView === 'month') { @@ -710,6 +712,17 @@ function populateCalendarSelect(selectedId) { }); } +// ── Date field helpers ──────────────────────────────────── +function setDtValue(id, isoStr, mode) { + const input = document.getElementById(id); + if (input) input.value = isoStr || ''; + const display = document.getElementById(id + '-display'); + if (display) { + display.querySelector('.dt-display-text').textContent = + formatDtDisplay(isoStr, mode, getLang()); + } +} + function openNewEventModal(date) { state.editingEvent = null; state.selectedEventColor = ''; @@ -723,10 +736,10 @@ function openNewEventModal(date) { const start = new Date(date); const end = new Date(date); end.setHours(end.getHours() + 1); - document.getElementById('ev-start').value = toLocalDatetimeInput(start); - document.getElementById('ev-end').value = toLocalDatetimeInput(end); - document.getElementById('ev-start-date').value = toDateInput(start); - document.getElementById('ev-end-date').value = toDateInput(start); + setDtValue('ev-start', toLocalDatetimeInput(start), 'datetime'); + setDtValue('ev-end', toLocalDatetimeInput(end), 'datetime'); + setDtValue('ev-start-date', toDateInput(start), 'date'); + setDtValue('ev-end-date', toDateInput(start), 'date'); toggleAlldayFields(false); populateCalendarSelect(null); @@ -746,14 +759,14 @@ function openEditEventModal(ev) { document.getElementById('ev-allday').checked = ev.allDay; if (ev.allDay) { - document.getElementById('ev-start-date').value = ev.start.slice(0, 10); - document.getElementById('ev-end-date').value = ev.end.slice(0, 10); + setDtValue('ev-start-date', ev.start.slice(0, 10), 'date'); + setDtValue('ev-end-date', ev.end.slice(0, 10), 'date'); toggleAlldayFields(true); } else { const s = new Date(ev.start); const e = new Date(ev.end); - document.getElementById('ev-start').value = toLocalDatetimeInput(s); - document.getElementById('ev-end').value = toLocalDatetimeInput(e); + setDtValue('ev-start', toLocalDatetimeInput(s), 'datetime'); + setDtValue('ev-end', toLocalDatetimeInput(e), 'datetime'); toggleAlldayFields(false); } @@ -781,6 +794,24 @@ function bindEventModal() { toggleAlldayFields(e.target.checked); }); + // Date/time pickers + [ + { displayId: 'ev-start-display', inputId: 'ev-start', mode: 'datetime' }, + { displayId: 'ev-end-display', inputId: 'ev-end', mode: 'datetime' }, + { displayId: 'ev-start-date-display', inputId: 'ev-start-date', mode: 'date' }, + { displayId: 'ev-end-date-display', inputId: 'ev-end-date', mode: 'date' }, + ].forEach(({ displayId, inputId, mode }) => { + const disp = document.getElementById(displayId); + if (!disp) return; + const open = async () => { + const current = document.getElementById(inputId)?.value || ''; + const result = await openDatePicker(disp, current, mode); + if (result !== null) setDtValue(inputId, result, mode); + }; + disp.addEventListener('click', open); + disp.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') open(); }); + }); + // Color picker: click preview to open gradient picker const evColorPreview = document.getElementById('ev-color-preview'); const evColorHex = document.getElementById('ev-color-hex'); diff --git a/frontend/js/date-picker.js b/frontend/js/date-picker.js new file mode 100644 index 0000000..80678dd --- /dev/null +++ b/frontend/js/date-picker.js @@ -0,0 +1,271 @@ +/** + * Custom dark date/time picker + * openDatePicker(anchor, value, mode) → Promise + * anchor : DOM element to position near + * value : ISO string ("YYYY-MM-DDTHH:MM" | "YYYY-MM-DD") or "" + * mode : 'datetime' | 'date' + */ +import { t } from './i18n.js'; + +const ITEM_H = 40; // px per scroll item +const VISIBLE = 3; // visible items in time scroller + +export function openDatePicker(anchor, value, mode = 'datetime') { + return new Promise(resolve => { + // ── Parse initial value ─────────────────────────────── + let selDate = new Date(); + selDate.setHours(0, 0, 0, 0); + let selHour = selDate.getHours(); + let selMin = 0; + + if (value) { + try { + const raw = mode === 'datetime' + ? value.replace(' ', 'T') + : value + 'T00:00:00'; + const d = new Date(raw); + if (!isNaN(d)) { + selDate = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + if (mode === 'datetime') { selHour = d.getHours(); selMin = d.getMinutes(); } + } + } catch (_) {} + } + + let viewYear = selDate.getFullYear(); + let viewMonth = selDate.getMonth(); + + // ── Build DOM ───────────────────────────────────────── + const overlay = document.createElement('div'); + overlay.className = 'dtp-overlay'; + document.body.appendChild(overlay); + + const card = document.createElement('div'); + card.className = 'dtp-card'; + overlay.appendChild(card); + + function done(result) { + overlay.remove(); + resolve(result); + } + + // Click outside → cancel + overlay.addEventListener('mousedown', e => { + if (e.target === overlay) done(null); + }); + + // ── Calendar builder ────────────────────────────────── + function buildCalendar() { + const months = t('months'); + const dowKeys = t('dow_monday'); // always Monday-first in calendar + + const firstDay = new Date(viewYear, viewMonth, 1); + const gridStart = new Date(firstDay); + let dow = firstDay.getDay(); + dow = dow === 0 ? 6 : dow - 1; // 0=Mon…6=Sun + gridStart.setDate(gridStart.getDate() - dow); + + const cells = []; + const iter = new Date(gridStart); + for (let i = 0; i < 42; i++) { + cells.push(new Date(iter)); + iter.setDate(iter.getDate() + 1); + } + + const today = new Date(); today.setHours(0, 0, 0, 0); + + const dowHtml = dowKeys.map(d => `
${d}
`).join(''); + const daysHtml = cells.map(cell => { + const isOther = cell.getMonth() !== viewMonth; + const isToday = cell.getTime() === today.getTime(); + const isSelected = cell.getTime() === selDate.getTime(); + let cls = 'dtp-day'; + if (isOther) cls += ' other'; + if (isToday) cls += ' today'; + if (isSelected) cls += ' selected'; + return `
${cell.getDate()}
`; + }).join(''); + + return `
+ + ${months[viewMonth]} ${viewYear} + +
+
+ ${dowHtml} + ${daysHtml} +
`; + } + + // ── Time scroll builder ─────────────────────────────── + function buildTime() { + if (mode !== 'datetime') return ''; + const hItems = Array.from({ length: 24 }, (_, i) => + `
${String(i).padStart(2,'0')}
` + ).join(''); + const mItems = Array.from({ length: 60 }, (_, i) => + `
${String(i).padStart(2,'0')}
` + ).join(''); + return `
+
+
${hItems}
+
+
:
+
+
${mItems}
+
+
`; + } + + // ── Render ──────────────────────────────────────────── + function render() { + card.innerHTML = + buildCalendar() + + buildTime() + + `
+ + +
`; + + bindEvents(); + if (mode === 'datetime') initScrollers(); + positionCard(); + } + + // ── Event bindings ──────────────────────────────────── + function bindEvents() { + card.querySelector('#dtp-prev').onclick = () => { + viewMonth--; + if (viewMonth < 0) { viewMonth = 11; viewYear--; } + render(); + }; + card.querySelector('#dtp-next').onclick = () => { + viewMonth++; + if (viewMonth > 11) { viewMonth = 0; viewYear++; } + render(); + }; + + // Day click + card.querySelectorAll('.dtp-day').forEach(el => { + el.addEventListener('click', () => { + selDate = new Date(parseInt(el.dataset.ts)); + if (el.classList.contains('other')) { + viewYear = selDate.getFullYear(); + viewMonth = selDate.getMonth(); + } + render(); + }); + }); + + card.querySelector('#dtp-cancel').onclick = () => done(null); + card.querySelector('#dtp-ok').onclick = () => done(buildResult()); + } + + // ── Time scroll initialisation ──────────────────────── + function initScrollers() { + const hCol = card.querySelector('#dtp-h'); + const mCol = card.querySelector('#dtp-m'); + if (!hCol || !mCol) return; + + // Scroll to selected value (padding-top = ITEM_H, so scrollTop = val * ITEM_H) + hCol.scrollTop = selHour * ITEM_H; + mCol.scrollTop = selMin * ITEM_H; + highlightItems(hCol, selHour); + highlightItems(mCol, selMin); + + let hTimer, mTimer; + + hCol.addEventListener('scroll', () => { + clearTimeout(hTimer); + hTimer = setTimeout(() => { + selHour = Math.max(0, Math.min(23, Math.round(hCol.scrollTop / ITEM_H))); + hCol.scrollTop = selHour * ITEM_H; + highlightItems(hCol, selHour); + }, 80); + }); + + mCol.addEventListener('scroll', () => { + clearTimeout(mTimer); + mTimer = setTimeout(() => { + selMin = Math.max(0, Math.min(59, Math.round(mCol.scrollTop / ITEM_H))); + mCol.scrollTop = selMin * ITEM_H; + highlightItems(mCol, selMin); + }, 80); + }); + + // Click item to select + hCol.querySelectorAll('.dtp-ti').forEach((el, i) => { + el.addEventListener('click', () => { + selHour = i; + hCol.scrollTo({ top: i * ITEM_H, behavior: 'smooth' }); + highlightItems(hCol, i); + }); + }); + mCol.querySelectorAll('.dtp-ti').forEach((el, i) => { + el.addEventListener('click', () => { + selMin = i; + mCol.scrollTo({ top: i * ITEM_H, behavior: 'smooth' }); + highlightItems(mCol, i); + }); + }); + } + + function highlightItems(col, val) { + col.querySelectorAll('.dtp-ti').forEach((el, i) => { + el.classList.toggle('selected', i === val); + }); + } + + // ── Result builder ──────────────────────────────────── + function buildResult() { + const y = selDate.getFullYear(); + const mo = String(selDate.getMonth() + 1).padStart(2, '0'); + const dd = String(selDate.getDate()).padStart(2, '0'); + if (mode === 'date') return `${y}-${mo}-${dd}`; + const h = String(selHour).padStart(2, '0'); + const mi = String(selMin).padStart(2, '0'); + return `${y}-${mo}-${dd}T${h}:${mi}`; + } + + // ── Positioning ─────────────────────────────────────── + function positionCard() { + const r = anchor.getBoundingClientRect(); + const cw = card.offsetWidth || 280; + const ch = card.offsetHeight || 420; + let left = r.left; + let top = r.bottom + 6; + if (left + cw > window.innerWidth - 8) left = window.innerWidth - cw - 8; + if (top + ch > window.innerHeight - 8) top = r.top - ch - 6; + if (left < 8) left = 8; + if (top < 8) top = 8; + card.style.left = left + 'px'; + card.style.top = top + 'px'; + } + + render(); + }); +} + +/** + * Format an ISO value for display in the UI + * mode: 'datetime' | 'date' + * lang: 'de' | 'en' + */ +export function formatDtDisplay(isoStr, mode, lang = 'de') { + if (!isoStr) return '—'; + try { + const d = mode === 'datetime' + ? new Date(isoStr.replace(' ', 'T')) + : new Date(isoStr + 'T00:00:00'); + if (isNaN(d)) return isoStr; + const locale = lang === 'en' ? 'en-GB' : 'de-CH'; + if (mode === 'datetime') { + return d.toLocaleString(locale, { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', hour12: false, + }); + } + return d.toLocaleDateString(locale, { + day: '2-digit', month: '2-digit', year: 'numeric', + }); + } catch (_) { return isoStr; } +} diff --git a/frontend/js/views/month.js b/frontend/js/views/month.js index 9775537..1d9de77 100644 --- a/frontend/js/views/month.js +++ b/frontend/js/views/month.js @@ -1,8 +1,8 @@ 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 +const LANE_H = 20; // px per lane (event height 18px + 2px gap) +const MAX_LANES = 3; // max visible lanes per row export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') { const year = currentDate.getFullYear(); @@ -24,10 +24,8 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC // Normalize each event's date range once const normed = events.map(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 s = new Date(ev.start); 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 }; }); @@ -52,11 +50,10 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC 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, + span: colEnd - colStart + 1, continuesLeft: ns < rowStart, continuesRight: ne > rowEnd, }); @@ -71,7 +68,7 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC }); // Assign lanes (greedy interval packing) - const lanes = []; // { colEnd } + const lanes = []; rowItems.forEach(item => { let laneIdx = lanes.findIndex(l => item.colStart >= l.colEnd); if (laneIdx === -1) { laneIdx = lanes.length; lanes.push({ colEnd: 0 }); } @@ -89,7 +86,7 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC } }); - // Render event spans + // Render event spans HTML (placed in overlay) let eventsHtml = ''; rowItems.forEach(item => { if (item.lane >= MAX_LANES) return; @@ -110,25 +107,23 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC title="${escAttr(ev.title)}">${escHtml(label)}
`; }); - // Render "+N more" per column + // "+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 })}
`; + style="left:${((c / 7) * 100).toFixed(3)}%;width:${(100 / 7).toFixed(3)}%;bottom:2px">${t('more_events', { n: count })}`; }); - // Day cells (numbers only) - let dayCellsHtml = ''; + // Full-height column divs (click targets + borders) + let colsHtml = ''; 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 += `
+ colsHtml += `
${cell.getDate()}
`; }); @@ -136,8 +131,8 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC bodyHtml += `
${kw}
-
${dayCellsHtml}
-
${eventsHtml}
+ ${colsHtml} +
${eventsHtml}
`; } @@ -147,7 +142,7 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
${bodyHtml}
`; - // Click handlers — event delegation + // Click handlers via event delegation on the body const body = container.querySelector('.month-body'); body.addEventListener('click', e => { // Span event click @@ -158,17 +153,17 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC if (ev) onEventClick(ev, spanEl); return; } - // "+N more" click → day view + // "+N more" → navigate to 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')); + // Column click → navigate to day view + const colEl = e.target.closest('.month-col'); + if (colEl) { + onDayClick(new Date(colEl.dataset.date + 'T00:00:00')); } }); } @@ -176,7 +171,6 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC // ── Helpers ─────────────────────────────────────────────── function daysBetween(a, b) { - // Number of whole days from date a to date b (can be negative) return Math.round((b - a) / 86400000); }