diff --git a/static/js/calendar.js b/static/js/calendar.js index 95bdc36a2..f0d7ae663 100644 --- a/static/js/calendar.js +++ b/static/js/calendar.js @@ -400,9 +400,13 @@ function _eventsForDay(dateStr) { } // Multi-day timed events: show on each day they span const startDate = _localDateOf(e.dtstart); - const endDate = _localDateOf(e.dtend); - if (startDate !== endDate) return startDate <= dateStr && endDate >= dateStr; - return startDate === dateStr; + const start = new Date(e.dtstart); + const end = new Date(e.dtend || e.dtstart); + // DTEND is exclusive, including at local midnight and across DST. + const endDate = end > start + ? _localDateOf(new Date(end.getTime() - 1).toISOString()) + : startDate; + return startDate <= dateStr && endDate >= dateStr; }); } @@ -1158,51 +1162,25 @@ async function _renderMonth() { const dow = _weekStartSun ? first.getDay() : (first.getDay() + 6) % 7; const gs = new Date(y, m, 1 - dow); - const multiDay = _events.filter(e => { - if (!_eventVisible(e)) return false; - const startD = new Date(e.dtstart), endD = new Date(e.dtend); - return Math.round((endD - startD) / 86400000) > 1 || (!e.all_day && _localDateOf(e.dtstart) !== _localDateOf(e.dtend)); - }); - const multiUids = new Set(multiDay.map(e => e.uid)); - - // Render 6 week rows. Each row is a positioned container that holds - // 7 day cells AND any multi-day bars that span the row, drawn as an - // absolute overlay on top of the cells. This avoids the old "each - // bar lives inside its start cell and gets clipped at the cell edge" - // problem so a multi-day event reads as one continuous line across - // every day it covers. + // Use the existing inline event rows for every day an event covers. for (let row = 0; row < 6; row++) { - // Count how many multi-day bars overlap any column in this row so - // cells can reserve top padding for them — otherwise the bars - // (drawn as absolute overlays) sit on top of the day-number and - // single-event rows below. - const rowStartCd0 = new Date(gs); rowStartCd0.setDate(gs.getDate() + row * 7); - const rowEndCd0 = new Date(gs); rowEndCd0.setDate(gs.getDate() + row * 7 + 6); - const rowStart0 = _ds(rowStartCd0); - const rowEnd0 = _ds(rowEndCd0); - const barsInRow = multiDay.filter(md => { - const mdStart = _localDateOf(md.dtstart); - const mdEnd = _localDateOf(md.dtend); - return !(mdEnd < rowStart0 || mdStart > rowEnd0); - }).length; - h += `
`; + h += '
'; // Day cells for this row for (let col = 0; col < 7; col++) { const i = row * 7 + col; const cd = new Date(gs); cd.setDate(gs.getDate() + i); const d = _ds(cd); const isOther = cd.getMonth() !== m; - const singles = _eventsForDay(d).filter(e => !multiUids.has(e.uid)); + const dayEvents = _eventsForDay(d); const isWeekend = cd.getDay() === 0 || cd.getDay() === 6; - const cls = 'cal-day' + (isOther ? ' cal-other' : '') + (isWeekend ? ' cal-weekend' : '') + (!singles.length ? ' cal-empty-day' : '') + (d === today ? ' cal-today' : '') + (d === _selectedDay ? ' cal-selected' : ''); + const cls = 'cal-day' + (isOther ? ' cal-other' : '') + (isWeekend ? ' cal-weekend' : '') + (!dayEvents.length ? ' cal-empty-day' : '') + (d === today ? ' cal-today' : '') + (d === _selectedDay ? ' cal-selected' : ''); h += `
${cd.getDate()}`; - // Single events — show up to 3 inline rows (multi-day events are - // drawn separately as an overlay below). - if (singles.length) { + // Reuse the same rows for single-day and continuing events. + if (dayEvents.length) { const maxInline = window.innerWidth <= 768 ? 2 : 3; - const showInline = singles.slice(0, maxInline); + const showInline = dayEvents.slice(0, maxInline); for (const ev of showInline) { - const t = ev.all_day ? '' : _fmtTime(ev.dtstart); + const t = ev.all_day || _localDateOf(ev.dtstart) !== d ? '' : _fmtTime(ev.dtstart); const _impMark = ev.importance === 'critical' ? '!!' : ev.importance === 'high' ? '!' : ''; const _typeBadge = ev.event_type ? `` : ''; @@ -1213,63 +1191,10 @@ async function _renderMonth() { ${_impMark}${_e(ev.summary)}${_eventReminderHtml(ev)}${_eventSourceHtml(ev)}
`; } - if (singles.length > maxInline) h += `
+${singles.length - maxInline} more
`; + if (dayEvents.length > maxInline) h += `
+${dayEvents.length - maxInline} more
`; } h += '
'; } - // Multi-day overlay bars for this row. Stack each bar one slot below - // the previous so two events on the same row don't overlap. - let barSlot = 0; - for (const md of multiDay) { - const mdStart = _localDateOf(md.dtstart); - const mdEnd = _localDateOf(md.dtend); - // Compute the row's date range - const rowStartCd = new Date(gs); rowStartCd.setDate(gs.getDate() + row * 7); - const rowEndCd = new Date(gs); rowEndCd.setDate(gs.getDate() + row * 7 + 6); - const rowStart = _ds(rowStartCd); - const rowEnd = _ds(rowEndCd); - if (mdEnd < rowStart || mdStart > rowEnd) continue; // not in this row - // Column within the row where the bar starts and how many days it spans - const startCol = mdStart < rowStart ? 0 : ((new Date(mdStart + 'T00:00:00') - rowStartCd) / 86400000); - const endCol = mdEnd > rowEnd ? 6 : ((new Date(mdEnd + 'T00:00:00') - rowStartCd) / 86400000); - const startColInt = Math.round(startCol); - const endColInt = Math.round(endCol); - const span = endColInt - startColInt + 1; - // Proportional offsets for timed events that span across midnight - // (e.g. 8 PM Mon → 5 AM Tue). Without this, an overnight serve - // window visually fills the ENTIRE next day even when it only - // covers a few hours. All-day events keep the full-day shape. - // Bar visually spans from column (col+startFrac) to (col+span-1+endFrac), - // so a 8 PM→5 AM run shows ~17% of day 1 + ~21% of day 2, not 200%. - let startFrac = 0; - let endFrac = 1; - if (!md.all_day) { - try { - const sIso = md.dtstart || ''; - const eIso = md.dtend || ''; - const sDate = sIso ? new Date(sIso) : null; - const eDate = eIso ? new Date(eIso) : null; - // First-visible-day fraction (0 = midnight start). Clamp to 0 - // when the event started before this row, so the bar still - // starts at the row's left edge. - if (sDate && !isNaN(sDate) && mdStart >= rowStart) { - const midnight = new Date(sDate); midnight.setHours(0, 0, 0, 0); - startFrac = Math.max(0, Math.min(1, (sDate - midnight) / 86400000)); - } - if (eDate && !isNaN(eDate) && mdEnd <= rowEnd) { - const midnight = new Date(eDate); midnight.setHours(0, 0, 0, 0); - endFrac = Math.max(0, Math.min(1, (eDate - midnight) / 86400000)); - // CalDAV end-times are exclusive: an event ending at exactly - // 00:00 on day N really ended at end-of-day N-1, so endFrac=0 - // would visually paint a zero-width slice. Snap to a small - // visible minimum (5% of a day) so the bar still registers. - if (endFrac === 0) endFrac = 1; - } - } catch (_) { startFrac = 0; endFrac = 1; } - } - h += `
${_e(md.summary)}${_eventReminderHtml(md)}${_eventSourceHtml(md)}
`; - barSlot++; - } h += '
'; } h += ''; diff --git a/tests/test_calendar_event_contrast.py b/tests/test_calendar_event_contrast.py index ac692e451..5a568401f 100644 --- a/tests/test_calendar_event_contrast.py +++ b/tests/test_calendar_event_contrast.py @@ -71,7 +71,7 @@ def test_calendar_event_surfaces_use_computed_foreground_variable(): assert "_calReadableTextColor" in utils_js assert "function _calEventFg(ev)" in calendar_js - assert "--cal-event-fg:${_calEventFg(md)}" in calendar_js + # Month events share transparent dot rows; remaining solid tiles need contrast. assert "--cal-event-fg:${_calEventFg(ev)}" in calendar_js assert "color: var(--cal-event-fg, #fff);" in style_css assert "color: var(--cal-event-fg, var(--fg));" in style_css diff --git a/tests/test_calendar_event_day_coverage.py b/tests/test_calendar_event_day_coverage.py new file mode 100644 index 000000000..9429a5f28 --- /dev/null +++ b/tests/test_calendar_event_day_coverage.py @@ -0,0 +1,53 @@ +"""Calendar day membership uses the event's exclusive end instant.""" +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from tests.helpers.js_modules import js_function_source + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = js_function_source("_eventsForDay", (ROOT / "static/js/calendar.js").read_text()) +ZONES = ["UTC", "Europe/Istanbul", "America/New_York", "Asia/Tokyo"] +CASES = [ + ("2026-10-22T17:00:00", "2026-10-23T00:00:00", False, ["2026-10-22"]), + ("2026-10-22T17:00:00", "2026-10-23T00:01:00", False, ["2026-10-22", "2026-10-23"]), + ("2026-10-22T17:00:00", "2026-10-22T17:00:00", False, ["2026-10-22"]), + ("2026-10-15", "2026-10-17", True, ["2026-10-15", "2026-10-16"]), + ("2026-10-15", "2026-10-15", True, ["2026-10-15"]), + ("2026-10-31T23:00:00", "2026-11-01T00:00:00", False, ["2026-10-31"]), +] + + +def _coverage(zone, start, end, all_day, days): + script = """ +import { _localDateOf } from './static/js/calendar/utils.js'; +const {source,event,days}=JSON.parse(process.argv[1]); +const filter=new Function('_events','_eventVisible','_localDateOf',source+'; return _eventsForDay;')([event],()=>true,_localDateOf); +console.log(JSON.stringify(days.filter(day=>filter(day).length))); +""" + data = dict(source=SOURCE, event=dict(uid="event", dtstart=start, dtend=end, all_day=all_day), days=days) + result = subprocess.run(["node", "--input-type=module", "-e", script, json.dumps(data)], + cwd=ROOT, env={**os.environ, "TZ": zone}, capture_output=True, + text=True, check=True, timeout=15) + return json.loads(result.stdout) + + +@pytest.mark.skipif(not shutil.which("node"), reason="node not installed") +@pytest.mark.parametrize("zone", ZONES) +@pytest.mark.parametrize("start,end,all_day,expected", CASES) +def test_event_day_coverage(zone, start, end, all_day, expected): + days = [f"2026-10-{day:02d}" for day in range(14, 32)] + ["2026-11-01", "2026-11-02"] + assert _coverage(zone, start, end, all_day, days) == expected + + +@pytest.mark.skipif(not shutil.which("node"), reason="node not installed") +@pytest.mark.parametrize("zone,start,end,days,expected", [ + ("Europe/Berlin", "2026-10-24T20:00:00+02:00", "2026-10-26T00:00:00+01:00", ["2026-10-24", "2026-10-25", "2026-10-26"], ["2026-10-24", "2026-10-25"]), + ("America/New_York", "2026-10-31T20:00:00-04:00", "2026-11-02T00:00:00-05:00", ["2026-10-31", "2026-11-01", "2026-11-02"], ["2026-10-31", "2026-11-01"]), +]) +def test_midnight_end_across_dst(zone, start, end, days, expected): + assert _coverage(zone, start, end, False, days) == expected diff --git a/tests/test_calendar_visual_static.py b/tests/test_calendar_visual_static.py index d7c7ca3a6..ac2a57535 100644 --- a/tests/test_calendar_visual_static.py +++ b/tests/test_calendar_visual_static.py @@ -2,7 +2,7 @@ from pathlib import Path import re from tests.helpers.stylesheets import app_css -from tests.helpers.js_modules import email_library_source +from tests.helpers.js_modules import email_library_source, js_function_source ROOT = Path(__file__).resolve().parents[1] @@ -172,8 +172,7 @@ def test_calendar_event_cards_show_compact_source_badges(): style_src = app_css() assert "function _eventSourceHtml" in calendar_src - assert "${_eventSourceHtml(ev)}" in calendar_src - assert "${_eventSourceHtml(md)}" in calendar_src + assert "${_eventSourceHtml(ev)}" in js_function_source("_renderMonth", calendar_src) assert ".cal-event-source" in style_src