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, '"' ) } " 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, '"' ) ;
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