Skip to content

Commit a355cec

Browse files
committed
Block Layout rendering option
When enabled: - events render as blocks - when events start before or after the view period there are now badges that give an indication of when, what they are or how many more events - weekends are shaded 25% darker than weekdays - input validation endInterval - startInterval >= 1 - warning showModal message type for validation error
1 parent e2dc689 commit a355cec

File tree

4 files changed

+314
-67
lines changed

4 files changed

+314
-67
lines changed
Lines changed: 283 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,77 +1,300 @@
11
{% extends "plugin.html" %}
22

33
{% block content %}
4-
54
<link href="https://cdn.jsdelivr.net/npm/[email protected]/index.global.min.css" rel="stylesheet" />
65
<script src="https://cdn.jsdelivr.net/npm/[email protected]/index.global.min.js"></script>
76

87
<div id="calendar" class="calendar" style="
9-
--fc-page-bg-color: {{ plugin_settings.backgroundColor or white }};
10-
--fc-border-color: {{ plugin_settings.textColor }};
11-
--fc-today-bg-color: '';
12-
--fc-now-indicator-color: {{ plugin_settings.nowIndicatorColor or red }};
13-
--fc-event-border-color: {{ plugin_settings.textColor or white }};
14-
15-
--fc-small-font-size: {{ 0.85 * font_scale }}em;
16-
--fc-title-font-size: {{ 1.75 * font_scale }}em;
17-
--fc-table-font-size: {{ 1 * font_scale }}em;
18-
">
8+
--fc-page-bg-color: {{ plugin_settings.backgroundColor or 'white' }};
9+
--fc-border-color: {{ plugin_settings.textColor }};
10+
--fc-today-bg-color: '';
11+
--fc-now-indicator-color: {{ plugin_settings.nowIndicatorColor or 'red' }};
12+
--fc-event-border-color: {{ plugin_settings.textColor or 'white' }};
13+
--fc-small-font-size: {{ 0.85 * font_scale }}em;
14+
--fc-title-font-size: {{ 1.75 * font_scale }}em;
15+
--fc-table-font-size: {{ 1 * font_scale }}em;
16+
"></div>
1917

20-
</div>
21-
{% if plugin_settings.textWrap == "true" %}
2218
<style>
23-
.fc-daygrid-event { /* Ensure event text wraps within the event box */
24-
white-space: normal !important;
25-
}
19+
{% if plugin_settings.blockLayout == "true" %}
20+
.fc-daygrid-event { white-space: normal !important; }
21+
.fc-custom-weekend { background-color: color-mix(in srgb, var(--fc-page-bg-color), black 25%) !important; }
22+
{% endif %}
23+
.badge { position: absolute; z-index: 2000; pointer-events: none; background-color: var(--fc-page-bg-color); }
24+
.fc-timegrid-col { position: relative; }
25+
.fc-event { position: relative; z-index: 1; }
26+
.fc-timegrid-event { min-height: 2.75em !important; }
2627
</style>
27-
{% endif %}
2828

2929
<script>
30-
const events = {{ events | tojson }};
31-
32-
document.addEventListener('DOMContentLoaded', function () {
33-
const calendarEl = document.getElementById('calendar');
34-
const timeFormat = {
35-
hour: "numeric",
36-
minute: "2-digit",
37-
omitZeroMinute: {{ (time_format == "12h") | tojson }},
38-
hour12: {{ (time_format == "12h") | tojson }},
39-
meridiem: 'short'
40-
};
30+
let events = {{ events | tojson }};
4131

42-
const calendar = new FullCalendar.Calendar(calendarEl, {
43-
initialView: '{{ view }}',
44-
events: events,
45-
now: '{{ current_dt }}',
46-
timeZone: '{{ timezone }}',
47-
contentHeight: '100%',
48-
slotDuration: '01:00:00',
49-
expandRows: true,
50-
locale: "{{ plugin_settings.language or 'en' }}",
51-
firstDay: "{{ plugin_settings.weekStartDay or 0}}",
52-
slotLabelFormat: timeFormat,
53-
slotMinTime: "{{ plugin_settings.startTimeInterval or 00}}:00:00",
54-
slotMaxTime: "{{ plugin_settings.endTimeInterval or 24}}:00:00",
55-
{% if plugin_settings.textWrap == "true" %}
56-
eventDisplay: 'block',
57-
eventMinHeight: Math.round({% if view == 'timeGrid' %}1.5 *{% endif %} 38 * Math.pow({{font_scale}}, 4)),
58-
{% endif %}
59-
eventTimeFormat: timeFormat,
60-
displayEventTime: {{ (plugin_settings.displayEventTime == "true") | tojson}},
61-
weekends: {{ (plugin_settings.displayWeekends == "true") | tojson}},
62-
nowIndicator: {{ (plugin_settings.displayNowIndicator == "true") | tojson}},
63-
fixedWeekCount: false,
64-
{% if view == 'timeGrid' %}duration: {days : 7 },{% endif %}
65-
{% if view == 'dayGrid' %}duration: {weeks : "{{ plugin_settings.displayWeeks or 4}}" },{% endif %}
66-
headerToolbar: {
67-
left: '',
68-
center: "{{ 'title' if plugin_settings.displayTitle == 'true' else '' }}",
69-
right: ''
70-
},
71-
slotDuration: "01:00:00"
32+
{% if plugin_settings.blockLayout == "true" and (view == 'dayGrid' or view == 'dayGridMonth') %}
33+
// For block rendering, split events that span past midnight
34+
let splitEvents = [];
35+
events.forEach(event => {
36+
if (!event.allDay && event.end) {
37+
const startDatePart = event.start.split('T')[0];
38+
const endDatePart = event.end.split('T')[0];
39+
if (startDatePart !== endDatePart) {
40+
const offsetMatch = event.start.match(/([+-]\d{2}:?\d{2}|Z)$/);
41+
const offset = offsetMatch ? offsetMatch[0] : 'Z';
42+
const [y, m, d] = startDatePart.split('-').map(n => parseInt(n, 10));
43+
const dateObj = new Date(y, m - 1, d + 1);
44+
const nextY = dateObj.getFullYear();
45+
const nextM = String(dateObj.getMonth() + 1).padStart(2, '0');
46+
const nextD = String(dateObj.getDate()).padStart(2, '0');
47+
const nextDayDateStr = `${nextY}-${nextM}-${nextD}`;
48+
const midnightTime = `T00:00:00${offset}`;
49+
const splitPoint = `${nextDayDateStr}${midnightTime}`;
50+
// First segment
51+
splitEvents.push({ ...event, end: splitPoint });
52+
// Second segment
53+
splitEvents.push({
54+
...event,
55+
title: event.title + ' (cont.)',
56+
splitType: 'midnightContinuation',
57+
start: splitPoint
7258
});
73-
calendar.render();
59+
} else {
60+
splitEvents.push(event);
61+
}
62+
} else {
63+
splitEvents.push(event);
64+
}
65+
});
66+
events = splitEvents;
67+
{% endif %}
68+
69+
document.addEventListener('DOMContentLoaded', function () {
70+
const calendarEl = document.getElementById('calendar');
71+
const timeFormat = {
72+
hour: "numeric",
73+
minute: "2-digit",
74+
omitZeroMinute: {{ (time_format == "12h") | tojson }},
75+
hour12: {{ (time_format == "12h") | tojson }},
76+
meridiem: 'short'
77+
};
78+
79+
const calendar = new FullCalendar.Calendar(calendarEl, {
80+
initialView: '{{ view }}',
81+
events: events,
82+
now: '{{ current_dt }}',
83+
timeZone: '{{ timezone }}',
84+
contentHeight: '100%',
85+
slotDuration: '01:00:00',
86+
expandRows: true,
87+
locale: "{{ plugin_settings.language or 'en' }}",
88+
firstDay: "{{ plugin_settings.weekStartDay or 0 }}",
89+
slotLabelFormat: timeFormat,
90+
slotMinTime: "{{ plugin_settings.startTimeInterval or '00' }}:00:00",
91+
slotMaxTime: "{{ plugin_settings.endTimeInterval or '24' }}:00:00",
92+
eventTimeFormat: timeFormat,
93+
displayEventTime: {{ (plugin_settings.displayEventTime == "true") | tojson }},
94+
weekends: {{ (plugin_settings.displayWeekends == "true") | tojson }},
95+
nowIndicator: {{ (plugin_settings.displayNowIndicator == "true") | tojson }},
96+
fixedWeekCount: false,
97+
{% if view == 'timeGrid' %}duration: { days: 7 },{% endif %}
98+
{% if view == 'dayGrid' %}duration: { weeks: "{{ plugin_settings.displayWeeks or 4 }}" },{% endif %}
99+
headerToolbar: {
100+
left: '',
101+
center: "{{ 'title' if plugin_settings.displayTitle == 'true' else '' }}",
102+
right: ''
103+
},
104+
{% if plugin_settings.blockLayout == "true" %}
105+
eventDisplay: 'block', // Display events as blocks
106+
eventMinHeight: Math.round({% if view == 'timeGrid' %}1.5 * {% endif %}38 * Math.pow({{ font_scale }}, 4)), // Minimum event height for blocks
107+
dayCellClassNames: function(arg) { // Weekend's are shaded darker than weekdays
108+
var date = arg.date;
109+
var dayOfWeek = date.getUTCDay(); // Sunday - 0, Saturday - 6
110+
if (dayOfWeek == 0 || dayOfWeek === 6) {
111+
return 'fc-custom-weekend';
112+
}
113+
return '';
114+
},
115+
viewDidMount: function(info) { // Create before and after events badges indicating more events than can fit in the view
116+
if (info.view.type.includes('timeGrid')) {
117+
let minHour = parseInt("{{ plugin_settings.startTimeInterval or 0 }}");
118+
let maxHour = parseInt("{{ plugin_settings.endTimeInterval or 24 }}");
119+
120+
function generateBadgeHTML(events) {
121+
let adjective, chev_angle, cutoffHour;
122+
if (new Date(events[0].start).getHours() < minHour) {
123+
adjective = 'before';
124+
chev_angle = 270;
125+
cutoffHour = (new Date(0, 0, 0, minHour).toLocaleTimeString([], {
126+
hour: 'numeric', minute: 'numeric', hour12: {{ (time_format == "12h") | tojson }}, meridiem: 'narrow'
127+
})).replace('m', '').replace(' ', '');
128+
} else {
129+
adjective = 'after';
130+
chev_angle = 90;
131+
cutoffHour = (new Date(0, 0, 0, maxHour).toLocaleTimeString([], {
132+
hour: 'numeric', minute: 'numeric', hour12: {{ (time_format == "12h") | tojson }}, meridiem: 'narrow'
133+
})).replace('m', '').replace(' ', '');
134+
}
135+
const arrow = `<span style="transform:rotate(${chev_angle}deg);display:inline-block; vertical-align:middle; line-height:.8;font-size:2.4em;padding: 0px 4px 0px 2px;">»</span>`;
136+
switch (events.length) {
137+
case 1:
138+
const startDate = new Date(events[0].start);
139+
const timeStr = startDate.toLocaleTimeString([], { hour: 'numeric', minute: 'numeric', hour12: {{ (time_format == "12h") | tojson }}}).replace('m', '').replace(' ', '');
140+
return arrow + `<span style="background-color:${events[0].backgroundColor};color:${events[0].textColor};">${timeStr}</span> ${events[0].title}`;
141+
case 2:
142+
return arrow + events.map(e => {
143+
const sd = new Date(e.start);
144+
const ts = sd.toLocaleTimeString([], { hour: 'numeric', minute: 'numeric', hour12: {{ (time_format == "12h") | tojson }}}).replace('m', '').replace(' ', '');
145+
return `<span title="${(e.title || '').replace(/"/g, '&quot;')}" style="pointer-events: auto; cursor: help; background-color:${e.backgroundColor};color:${e.textColor};border: 0px 2px 0px 2px;">${ts}</span>`;
146+
}).join(' & ');
147+
default:
148+
const details = events.map(e => {
149+
const t = new Date(e.start).toLocaleTimeString([], { hour: 'numeric', minute: 'numeric', hour12: {{ (time_format == "12h") | tojson }}}).replace('m', '').replace(' ', '');
150+
return `${t} ${e.title || ''}`;
151+
}).join('\n').replace(/"/g, '&quot;');
152+
return arrow + `<span title="${details}" style="pointer-events: auto; cursor: help;">${events.length} more ${adjective} ${cutoffHour}</span>`;
153+
}
154+
}
155+
156+
function getVisibleColor(eventColor, backgroundColor) {
157+
const getLuminance = hex => {
158+
const h = hex.replace('#', '');
159+
const r = parseInt(h.substr(0, 2), 16);
160+
const g = parseInt(h.substr(2, 2), 16);
161+
const b = parseInt(h.substr(4, 2), 16);
162+
return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
163+
};
164+
const eventLum = getLuminance(eventColor);
165+
const bgLum = getLuminance(backgroundColor);
166+
const contrast = Math.abs(eventLum - bgLum);
167+
if (contrast < 0.15) {
168+
return bgLum > 0.5 ? '#000000' : '#ffffff';
169+
}
170+
return eventColor;
171+
}
172+
173+
function generateMultiBorderCSS(events, gapColor, lineThickness = 1, gapThickness = 1) {
174+
let position = 0;
175+
const stops = [];
176+
const colors = events.map(event => getVisibleColor(event.backgroundColor, '{{ plugin_settings.backgroundColor or "#ffffff" }}'));
177+
colors.forEach((color, idx) => {
178+
stops.push(`${color} ${position}px`);
179+
position += lineThickness;
180+
stops.push(`${color} ${position}px`);
181+
if (idx < colors.length - 1) {
182+
stops.push(`${gapColor} ${position}px`);
183+
position += gapThickness;
184+
stops.push(`${gapColor} ${position}px`);
185+
}
186+
});
187+
stops.push(`${gapColor} ${position}px`);
188+
return `background: linear-gradient(to bottom, ${stops.join(', ')}); background-position: 0 0; background-repeat: no-repeat; padding-top: ${position}px`;
189+
}
190+
191+
const toDateKey = dateStr => dateStr.split('T')[0];
192+
const eventsByDay = {};
193+
events.forEach(ev => {
194+
const day = toDateKey(ev.start);
195+
if (!eventsByDay[day]) eventsByDay[day] = [];
196+
eventsByDay[day].push(ev);
197+
});
198+
199+
// Get page background color for badges
200+
const calendarEl = document.getElementById('calendar');
201+
const pageBgColor = getComputedStyle(calendarEl).getPropertyValue('--fc-page-bg-color').trim() || '#ffffff';
202+
203+
document.querySelectorAll('td.fc-timegrid-col').forEach(dayCol => {
204+
const dayStr = dayCol.getAttribute('data-date');
205+
if (!dayStr) return;
206+
const dayEvents = eventsByDay[dayStr] || [];
207+
const beforeEvents = dayEvents.filter(e => new Date(e.start).getHours() < minHour);
208+
let afterEvents = dayEvents.filter(e => {
209+
const sd = new Date(e.start);
210+
const hour = sd.getHours() + sd.getMinutes() / 60;
211+
return hour > (maxHour - 1) + 0.09;
212+
});
213+
if (afterEvents.length > 1) {
214+
afterEvents = dayEvents.filter(e => {
215+
const sd = new Date(e.start);
216+
const hour = sd.getHours() + sd.getMinutes() / 60;
217+
return hour > maxHour-1;
218+
});
219+
}
220+
// Remove counted events from normal rendering
221+
eventsByDay[dayStr] = dayEvents.filter(e => !beforeEvents.includes(e) && !afterEvents.includes(e));
222+
const colCells = Array.from(dayCol.querySelectorAll('div.fc-timegrid-col-events'));
223+
if (colCells.length === 0) return;
224+
const firstSlot = colCells[0];
225+
226+
if (beforeEvents.length > 0 && firstSlot) {
227+
const badge = document.createElement('div');
228+
badge.className = 'badge';
229+
badge.innerHTML = generateBadgeHTML(beforeEvents);
230+
// Use pageBgColor for the gaps in the border to make them solid
231+
const border = generateMultiBorderCSS(beforeEvents, pageBgColor, 1.5, 1.5);
232+
// Apply the background color
233+
badge.style.cssText = `position:absolute; top:0; left:0; right:0; z-index:10; text-align:center; font-size:0.7em; font-weight:bold; ${border}; background-color: ${pageBgColor}; opacity: 0.85;`;
234+
firstSlot.style.position = 'relative';
235+
firstSlot.prepend(badge);
236+
237+
// Push events down to avoid overlap
238+
// FIXME: If a beforeEvents badge exists, fix events in last hour so they don't get pushed too far down
239+
// Increase timeout to ensure FullCalendar has finished positioning
240+
setTimeout(() => {
241+
// Force a minimum height if offsetHeight is 0 (which happens if not yet painted)
242+
const rectHeight = badge.getBoundingClientRect().height;
243+
const badgeHeight = rectHeight > 0 ? rectHeight : 30;
244+
245+
const eventsInCol = firstSlot.querySelectorAll('.fc-event');
246+
eventsInCol.forEach(event => {
247+
let currentTop = parseFloat(event.style.top);
248+
// If top is not set or NaN, assume 0
249+
if (isNaN(currentTop)) currentTop = 0;
250+
251+
// If the event is overlapping the badge (plus a little buffer)
252+
if (currentTop < badgeHeight) {
253+
const targetTop = badgeHeight + 2;
254+
const pushDownAmount = targetTop - currentTop;
255+
256+
// Push top down
257+
event.style.top = targetTop + 'px';
258+
259+
// Also adjust bottom to preserve height if it exists
260+
// FullCalendar often uses top and bottom to define the event's vertical position/size
261+
if (event.style.bottom) {
262+
let currentBottom = parseFloat(event.style.bottom);
263+
if (!isNaN(currentBottom)) {
264+
event.style.bottom = (currentBottom - pushDownAmount) + 'px';
265+
}
266+
}
267+
}
268+
});
269+
}, 300);
270+
}
271+
272+
if (afterEvents.length > 0) {
273+
const slots = dayCol.querySelectorAll('div');
274+
if (slots.length > 0) {
275+
const badge = document.createElement('div');
276+
badge.className = 'badge';
277+
badge.innerHTML = generateBadgeHTML(afterEvents);
278+
const border = generateMultiBorderCSS(afterEvents, pageBgColor, 1.5, 1.5);
279+
badge.style.cssText = `position:absolute; bottom:1em; width:99%; text-align:center; font-size:0.7em; font-weight:bold; padding-left:4px; padding-right:0px; ${border}; background-color: ${pageBgColor}; opacity: 0.75;`;
280+
const container = slots[0].parentElement;
281+
container.style.position = 'relative';
282+
container.appendChild(badge);
283+
}
284+
}
285+
});
286+
287+
// Refresh events after badge processing
288+
events = Object.values(eventsByDay).flat();
289+
calendar.removeAllEvents();
290+
events.forEach(ev => calendar.addEvent(ev));
291+
}
292+
},
293+
{% endif %}
294+
slotDuration: "01:00:00"
74295
});
296+
calendar.render();
297+
});
75298
</script>
76299

77-
{% endblock %}
300+
{% endblock %}

0 commit comments

Comments
 (0)