mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-08 14:12:22 +01:00
fix(calendar): render multi-day events within their day cells (#6948)
This commit is contained in:
+16
-91
@@ -400,9 +400,13 @@ function _eventsForDay(dateStr) {
|
|||||||
}
|
}
|
||||||
// Multi-day timed events: show on each day they span
|
// Multi-day timed events: show on each day they span
|
||||||
const startDate = _localDateOf(e.dtstart);
|
const startDate = _localDateOf(e.dtstart);
|
||||||
const endDate = _localDateOf(e.dtend);
|
const start = new Date(e.dtstart);
|
||||||
if (startDate !== endDate) return startDate <= dateStr && endDate >= dateStr;
|
const end = new Date(e.dtend || e.dtstart);
|
||||||
return startDate === dateStr;
|
// 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 dow = _weekStartSun ? first.getDay() : (first.getDay() + 6) % 7;
|
||||||
const gs = new Date(y, m, 1 - dow);
|
const gs = new Date(y, m, 1 - dow);
|
||||||
|
|
||||||
const multiDay = _events.filter(e => {
|
// Use the existing inline event rows for every day an event covers.
|
||||||
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.
|
|
||||||
for (let row = 0; row < 6; row++) {
|
for (let row = 0; row < 6; row++) {
|
||||||
// Count how many multi-day bars overlap any column in this row so
|
h += '<div class="cal-week-row" style="--bars:0">';
|
||||||
// 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 += `<div class="cal-week-row" style="--bars:${barsInRow}">`;
|
|
||||||
// Day cells for this row
|
// Day cells for this row
|
||||||
for (let col = 0; col < 7; col++) {
|
for (let col = 0; col < 7; col++) {
|
||||||
const i = row * 7 + col;
|
const i = row * 7 + col;
|
||||||
const cd = new Date(gs); cd.setDate(gs.getDate() + i);
|
const cd = new Date(gs); cd.setDate(gs.getDate() + i);
|
||||||
const d = _ds(cd);
|
const d = _ds(cd);
|
||||||
const isOther = cd.getMonth() !== m;
|
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 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 += `<div class="${cls}" data-date="${d}"><span class="cal-day-num">${cd.getDate()}</span>`;
|
h += `<div class="${cls}" data-date="${d}"><span class="cal-day-num">${cd.getDate()}</span>`;
|
||||||
// Single events — show up to 3 inline rows (multi-day events are
|
// Reuse the same rows for single-day and continuing events.
|
||||||
// drawn separately as an overlay below).
|
if (dayEvents.length) {
|
||||||
if (singles.length) {
|
|
||||||
const maxInline = window.innerWidth <= 768 ? 2 : 3;
|
const maxInline = window.innerWidth <= 768 ? 2 : 3;
|
||||||
const showInline = singles.slice(0, maxInline);
|
const showInline = dayEvents.slice(0, maxInline);
|
||||||
for (const ev of showInline) {
|
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' ? '<span style="color:var(--red);margin-right:2px" title="critical">!!</span>'
|
const _impMark = ev.importance === 'critical' ? '<span style="color:var(--red);margin-right:2px" title="critical">!!</span>'
|
||||||
: ev.importance === 'high' ? '<span style="color:var(--orange,#e5a33a);margin-right:2px" title="high">!</span>' : '';
|
: ev.importance === 'high' ? '<span style="color:var(--orange,#e5a33a);margin-right:2px" title="high">!</span>' : '';
|
||||||
const _typeBadge = ev.event_type ? `<span class="cal-event-type-badge" data-type="${_e(ev.event_type)}" title="${_e(ev.event_type)}"></span>` : '';
|
const _typeBadge = ev.event_type ? `<span class="cal-event-type-badge" data-type="${_e(ev.event_type)}" title="${_e(ev.event_type)}"></span>` : '';
|
||||||
@@ -1213,63 +1191,10 @@ async function _renderMonth() {
|
|||||||
<span class="cal-event-row-name">${_impMark}${_e(ev.summary)}${_eventReminderHtml(ev)}${_eventSourceHtml(ev)}</span>
|
<span class="cal-event-row-name">${_impMark}${_e(ev.summary)}${_eventReminderHtml(ev)}${_eventSourceHtml(ev)}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
if (singles.length > maxInline) h += `<div class="cal-event-more">+${singles.length - maxInline} more</div>`;
|
if (dayEvents.length > maxInline) h += `<div class="cal-event-more">+${dayEvents.length - maxInline} more</div>`;
|
||||||
}
|
}
|
||||||
h += '</div>';
|
h += '</div>';
|
||||||
}
|
}
|
||||||
// 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 += `<div class="cal-multiday" style="--col:${startColInt};--span:${span};--slot:${barSlot};--start-frac:${startFrac.toFixed(4)};--end-frac:${endFrac.toFixed(4)};background:${_calColor(md)};--cal-event-fg:${_calEventFg(md)}" draggable="true" data-uid="${_e(md.uid)}" title="${_e(md.summary)}">${_e(md.summary)}${_eventReminderHtml(md)}${_eventSourceHtml(md)}</div>`;
|
|
||||||
barSlot++;
|
|
||||||
}
|
|
||||||
h += '</div>';
|
h += '</div>';
|
||||||
}
|
}
|
||||||
h += '</div>';
|
h += '</div>';
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ def test_calendar_event_surfaces_use_computed_foreground_variable():
|
|||||||
|
|
||||||
assert "_calReadableTextColor" in utils_js
|
assert "_calReadableTextColor" in utils_js
|
||||||
assert "function _calEventFg(ev)" in calendar_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 "--cal-event-fg:${_calEventFg(ev)}" in calendar_js
|
||||||
assert "color: var(--cal-event-fg, #fff);" in style_css
|
assert "color: var(--cal-event-fg, #fff);" in style_css
|
||||||
assert "color: var(--cal-event-fg, var(--fg));" in style_css
|
assert "color: var(--cal-event-fg, var(--fg));" in style_css
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -2,7 +2,7 @@ from pathlib import Path
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from tests.helpers.stylesheets import app_css
|
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]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
@@ -172,8 +172,7 @@ def test_calendar_event_cards_show_compact_source_badges():
|
|||||||
style_src = app_css()
|
style_src = app_css()
|
||||||
|
|
||||||
assert "function _eventSourceHtml" in calendar_src
|
assert "function _eventSourceHtml" in calendar_src
|
||||||
assert "${_eventSourceHtml(ev)}" in calendar_src
|
assert "${_eventSourceHtml(ev)}" in js_function_source("_renderMonth", calendar_src)
|
||||||
assert "${_eventSourceHtml(md)}" in calendar_src
|
|
||||||
assert ".cal-event-source" in style_src
|
assert ".cal-event-source" in style_src
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user