fix: Month grid lines, scroll throttle, custom dark date/time picker

- Month view: Replaced day-strip+events-area with full-height column
  divs (.month-col) so borders extend the full row height and clicking
  anywhere in a day column (including below events) navigates to day view.
  Events overlay uses pointer-events:none (pass-through) while span bars
  and +N-more labels stay pointer-events:all.
- Scroll navigation: Changed wheel handler from 80ms debounce to 500ms
  leading-edge throttle — one navigation per trackpad gesture.
- Custom date/time picker (date-picker.js): Dark calendar grid with
  prev/next navigation, today/selected highlighting, and a CSS
  scroll-snap time scroller (hours 0-23, minutes 0-59) matching the
  app's primary color. Language-aware (month names, day headers via t()).
- Event modal datetime inputs replaced with hidden inputs + .dt-display
  click targets that open the custom picker. setDtValue() helper keeps
  hidden input and display label in sync.
This commit is contained in:
Scarriffle
2026-04-07 21:44:44 +02:00
parent cd4879d573
commit 7f92e0423c
5 changed files with 491 additions and 71 deletions

View File

@@ -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);
}

View File

@@ -202,21 +202,37 @@
<div class="form-row" id="ev-time-row">
<div class="form-group half">
<label>Start</label>
<input type="datetime-local" id="ev-start" />
<input type="hidden" id="ev-start" />
<div class="dt-display" id="ev-start-display" tabindex="0" role="button">
<span class="dt-display-text"></span>
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v14a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM7 10h5v5H7z"/></svg>
</div>
</div>
<div class="form-group half">
<label>Ende</label>
<input type="datetime-local" id="ev-end" />
<input type="hidden" id="ev-end" />
<div class="dt-display" id="ev-end-display" tabindex="0" role="button">
<span class="dt-display-text"></span>
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v14a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM7 10h5v5H7z"/></svg>
</div>
</div>
</div>
<div class="form-row" id="ev-date-row" style="display:none">
<div class="form-group half">
<label>Start</label>
<input type="date" id="ev-start-date" />
<input type="hidden" id="ev-start-date" />
<div class="dt-display" id="ev-start-date-display" tabindex="0" role="button">
<span class="dt-display-text"></span>
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v14a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM7 10h5v5H7z"/></svg>
</div>
</div>
<div class="form-group half">
<label>Ende</label>
<input type="date" id="ev-end-date" />
<input type="hidden" id="ev-end-date" />
<div class="dt-display" id="ev-end-date-display" tabindex="0" role="button">
<span class="dt-display-text"></span>
<svg class="dt-display-icon" viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v14a2 2 0 002 2h14a2 2 0 002-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM7 10h5v5H7z"/></svg>
</div>
</div>
</div>
<div class="form-group">

View File

@@ -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');

271
frontend/js/date-picker.js Normal file
View File

@@ -0,0 +1,271 @@
/**
* Custom dark date/time picker
* openDatePicker(anchor, value, mode) → Promise<string|null>
* 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 => `<div class="dtp-dow">${d}</div>`).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 `<div class="${cls}" data-ts="${cell.getTime()}">${cell.getDate()}</div>`;
}).join('');
return `<div class="dtp-cal-header">
<button class="dtp-nav-btn" id="dtp-prev">&#8249;</button>
<span class="dtp-month-label">${months[viewMonth]} ${viewYear}</span>
<button class="dtp-nav-btn" id="dtp-next">&#8250;</button>
</div>
<div class="dtp-grid">
${dowHtml}
${daysHtml}
</div>`;
}
// ── Time scroll builder ───────────────────────────────
function buildTime() {
if (mode !== 'datetime') return '';
const hItems = Array.from({ length: 24 }, (_, i) =>
`<div class="dtp-ti" data-val="${i}">${String(i).padStart(2,'0')}</div>`
).join('');
const mItems = Array.from({ length: 60 }, (_, i) =>
`<div class="dtp-ti" data-val="${i}">${String(i).padStart(2,'0')}</div>`
).join('');
return `<div class="dtp-time-row">
<div class="dtp-tc-wrap">
<div class="dtp-tc" id="dtp-h">${hItems}</div>
</div>
<div class="dtp-colon">:</div>
<div class="dtp-tc-wrap">
<div class="dtp-tc" id="dtp-m">${mItems}</div>
</div>
</div>`;
}
// ── Render ────────────────────────────────────────────
function render() {
card.innerHTML =
buildCalendar() +
buildTime() +
`<div class="dtp-actions">
<button class="btn btn-ghost btn-sm" id="dtp-cancel">${t('cancel')}</button>
<button class="btn btn-primary btn-sm" id="dtp-ok">${t('save')}</button>
</div>`;
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; }
}

View File

@@ -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)}</div>`;
});
// 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 += `<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>`;
style="left:${((c / 7) * 100).toFixed(3)}%;width:${(100 / 7).toFixed(3)}%;bottom:2px">${t('more_events', { n: count })}</div>`;
});
// 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 += `<div class="month-cell ${todayCls} ${otherCls}" data-date="${key}">
colsHtml += `<div class="month-col ${todayCls} ${otherCls}" data-date="${key}">
<div class="cell-day ${numCls}">${cell.getDate()}</div>
</div>`;
});
@@ -136,8 +131,8 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
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>
${colsHtml}
<div class="month-events-overlay">${eventsHtml}</div>
</div>
</div>`;
}
@@ -147,7 +142,7 @@ export function renderMonth(container, currentDate, events, onDayClick, onEventC
<div class="month-body">${bodyHtml}</div>
</div>`;
// 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);
}