diff --git a/frontend/css/app.css b/frontend/css/app.css index d1e7920..d5e12aa 100644 --- a/frontend/css/app.css +++ b/frontend/css/app.css @@ -550,22 +550,25 @@ a { color: var(--primary); text-decoration: none; } .week-day-header.today .day-name { color: var(--today-color); } /* All-day row */ -.week-allday-row { display: flex; border-bottom: 1px solid var(--border); flex-shrink: 0; min-height: 28px; } -.allday-gutter { width: 56px; flex-shrink: 0; display: flex; align-items: center; justify-content: flex-end; padding-right: 6px; font-size: 10px; color: var(--text-3); } -.allday-cols { display: flex; flex: 1; } -.allday-col { flex: 1; border-left: 1px solid var(--border); padding: 2px; } -.col-span-tint { - position: absolute; inset: 0; pointer-events: none; z-index: 0; -} -.allday-event { - font-size: 11px; font-weight: 500; padding: 2px 6px; - border-radius: 3px; margin-bottom: 2px; cursor: pointer; +.week-allday-row { display: flex; border-bottom: 1px solid var(--border); flex-shrink: 0; } +.allday-gutter { width: 56px; flex-shrink: 0; display: flex; align-items: flex-start; justify-content: flex-end; padding: 6px 6px 0; font-size: 10px; color: var(--text-3); } +.allday-cols-wrap { flex: 1; position: relative; display: flex; } +.allday-col-bg { flex: 1; border-left: 1px solid var(--border); } +.allday-spans-layer { position: absolute; inset: 0; pointer-events: none; } +.allday-span { + position: absolute; + height: 18px; line-height: 18px; + font-size: 11px; font-weight: 500; + padding: 0 6px; border-radius: 3px; + cursor: pointer; pointer-events: all; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.allday-event.multiday-timed { - opacity: .88; - border-left: 3px solid rgba(255,255,255,.45); -} +.allday-span:hover { filter: brightness(1.12); } +.allday-span.past { opacity: .45; } +.allday-span.continues-left { border-top-left-radius: 0; border-bottom-left-radius: 0; padding-left: 3px; } +.allday-span.continues-right { border-top-right-radius: 0; border-bottom-right-radius: 0; padding-right: 3px; } +.allday-span.multiday-timed { opacity: .88; border-left: 3px solid rgba(255,255,255,.4); } +.col-span-tint { position: absolute; inset: 0; pointer-events: none; z-index: 0; } /* Time grid */ .week-body { display: flex; flex: 1; overflow-y: auto; position: relative; } diff --git a/frontend/js/views/month.js b/frontend/js/views/month.js index 0d4db29..201636e 100644 --- a/frontend/js/views/month.js +++ b/frontend/js/views/month.js @@ -1,12 +1,16 @@ import { isToday, isPast, dayOfWeek, weekStart, getISOWeekNumber } from '../utils.js'; import { t } from '../i18n.js'; -const LANE_H = 20; // px per lane (event height 18px + 2px gap) -const MAX_LANES = 3; // max visible lanes per row - -const NUM_ROWS = 5; // rolling view: always 5 weeks +const LANE_H = 20; // px per lane (event height 18px + 2px gap) +const DAY_H = 30; // day-number row height +const NUM_ROWS = 5; // rolling view: always 5 weeks export function renderMonth(container, currentDate, events, onDayClick, onEventClick, weekStartDay = 'monday') { + // Dynamic lane limit: how many events fit in the actual row height + const containerH = container.clientHeight || 600; + const headerH = 34; // month-header DOW row + const rowH = (containerH - headerH) / NUM_ROWS; + const MAX_LANES = Math.max(1, Math.floor((rowH - DAY_H) / LANE_H) - 1); // "Primary month" = currentDate's month (used for muting other-month days) const primaryMonth = currentDate.getMonth(); const DOW = weekStartDay === 'sunday' ? t('dow_sunday') : t('dow_monday'); diff --git a/frontend/js/views/week.js b/frontend/js/views/week.js index b62a68e..8923671 100644 --- a/frontend/js/views/week.js +++ b/frontend/js/views/week.js @@ -45,33 +45,41 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC `; }).join(''); - // ── All-day row ─────────────────────────────────────── - const alldayCols = days.map(day => { - const key = dayKey(day); - const dayEvs = allDayEvs.filter(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 d = new Date(day); d.setHours(0,0,0,0); - return d >= s && d < e || isSameDay(d, s); - }); - const allDayHtml = dayEvs.map(ev => { - const color = ev.color || ev.calendarColor || '#4285f4'; - return `
${escHtml(ev.title)}
`; - }).join(''); + // ── All-day row (spanning bars, same logic as month view) ── + const ALLDAY_LANE_H = 22; + const allDayAndMulti = [...allDayEvs, ...multiDayTimedEvs]; + const alldayLayout = layoutWeekAllDay(allDayAndMulti, days); + const maxAlldayLane = alldayLayout.length ? alldayLayout.reduce((m, it) => Math.max(m, it.lane), 0) : -1; + const alldayRowH = maxAlldayLane < 0 ? 28 : (maxAlldayLane + 1) * ALLDAY_LANE_H + 6; - // Multi-day timed events: show in all-day row for every day they span - const spanHtml = multiDayTimedEvs.filter(ev => spansDay(ev, day)).map(ev => { - const color = ev.color || ev.calendarColor || '#4285f4'; - const isStart = isSameDay(new Date(ev.start), day); - const label = isStart ? `${fmtTime(new Date(ev.start))} ${ev.title}` : ev.title; - return `
${escHtml(label)}
`; - }).join(''); - - return `
${allDayHtml}${spanHtml}
`; + const alldaySpanHtml = alldayLayout.map(({ ev, colStart, colEnd, lane }) => { + const isMultiTimed = multiDayTimedEvs.includes(ev); + const n = days.length; + const left = (colStart / n) * 100; + const width = ((colEnd - colStart + 1) / n) * 100; + const top = lane * ALLDAY_LANE_H + 2; + const color = ev.color || ev.calendarColor || '#4285f4'; + const pastCls = isPast(ev) ? 'past' : ''; + const multiCls = isMultiTimed ? 'multiday-timed' : ''; + const cL = new Date(ev.start) < new Date(days[0]) ? 'continues-left' : ''; + const cR = new Date(ev.end) > (() => { const d = new Date(days[n-1]); d.setHours(24,0,0,0); return d; })() ? 'continues-right' : ''; + const label = isMultiTimed && isSameDay(new Date(ev.start), days[colStart]) + ? `${fmtTime(new Date(ev.start))} ${ev.title}` + : ev.title; + return `
${escHtml(label)}
`; }).join(''); + const alldayBgCols = days.map(day => + `
` + ).join(''); + + const alldayCols = `
+ ${alldayBgCols} +
${alldaySpanHtml}
+
`; + // ── Time column labels ──────────────────────────────── const timeLabels = Array.from({length: 24}, (_, h) => `
${h === 0 ? '' : `${String(h).padStart(2,'0')}:00`}
` @@ -180,8 +188,8 @@ export function renderWeek(container, currentDate, events, onSlotClick, onEventC }); }); - // Click: all-day event - container.querySelectorAll('.allday-event').forEach(el => { + // Click: all-day span + container.querySelectorAll('.allday-span').forEach(el => { el.addEventListener('click', e => { e.stopPropagation(); const ev = events.find(ev => ev.id === el.dataset.id && ev.url === el.dataset.url); @@ -205,6 +213,39 @@ function renderNowLine(container, days, hourH = 60) { setTimeout(() => renderNowLine(container, days, hourH), 60000); } +function layoutWeekAllDay(evs, days) { + const items = []; + evs.forEach(ev => { + let colStart = -1, colEnd = -1; + days.forEach((day, i) => { + const ds = new Date(day); ds.setHours(0, 0, 0, 0); + const de = new Date(day); de.setHours(24, 0, 0, 0); + if (new Date(ev.start) < de && new Date(ev.end) > ds) { + if (colStart === -1) colStart = i; + colEnd = i; + } + }); + if (colStart === -1) return; + items.push({ ev, colStart, colEnd }); + }); + + // Sort: longer spans first, then by start column + items.sort((a, b) => + (b.colEnd - b.colStart) - (a.colEnd - a.colStart) || a.colStart - b.colStart + ); + + // Greedy lane assignment + const laneEnds = []; + items.forEach(item => { + let lane = laneEnds.findIndex(end => item.colStart > end); + if (lane === -1) { lane = laneEnds.length; laneEnds.push(-1); } + item.lane = lane; + laneEnds[lane] = item.colEnd; + }); + + return items; +} + function layoutEvents(events) { if (!events.length) return [];