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

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