PageSourceSearch

https://www.coursicle.com/all/js/schedule/schedule.js?new68

js coursicle.com collected 2026-09-24 08:48:22 UTC 176,673 bytes, 4,402 lines download raw bytes

1const FREQUENCY_TYPES = {
2    "doesNotRepeat" : "Does not repeat",
3    "daily" : "Every day",
4    "weekly" : "Every week",
5    "biweekly" : "Every other week",
6    "monthly" : "Every month",
7    "yearly" : "Every year"
8}
9
10// note: duplicated with the card tile colors in all/directory/shared/css/shared.css and of course the iOS app
11const PRESET_COLORS = [
12	// Calendar presets 1-23 — single source: --cal-color-N (= $freeColors). Do NOT swap back to
13	// consolidated theme vars; these must stay byte-exact to the freemium gate. See freemiumCalendarColors.php.
14	themeColor('--cal-color-1'), themeColor('--cal-color-2'), themeColor('--cal-color-3'), themeColor('--cal-color-4'), themeColor('--cal-color-5'), themeColor('--cal-color-6'), themeColor('--cal-color-7'), themeColor('--cal-color-8'), themeColor('--cal-color-9'), themeColor('--cal-color-10'), themeColor('--cal-color-11'), themeColor('--cal-color-12'), themeColor('--cal-color-13'), themeColor('--cal-color-14'), themeColor('--cal-color-15'), themeColor('--cal-color-16'), themeColor('--cal-color-17'), themeColor('--cal-color-18'), themeColor('--cal-color-19'), themeColor('--cal-color-20'),
15	themeColor('--cal-color-21'), themeColor('--cal-color-22'), themeColor('--cal-color-23'), themeColor('--el-color-51'), themeColor('--el-color-52'), themeColor('--orange'), themeColor('--el-color-54'), themeColor('--el-color-55'), themeColor('--el-color-56'), themeColor('--el-color-57'), themeColor('--el-color-119'), themeColor('--el-color-59'), themeColor('--el-color-60'), themeColor('--el-color-32'), themeColor('--el-color-105'), themeColor('--el-color-62'), themeColor('--el-color-106'), themeColor('--el-color-64'), themeColor('--el-color-124'), themeColor('--el-color-66'), 
16	// Palette C 
17	themeColor('--el-color-67'), themeColor('--el-color-68'), themeColor('--el-color-69'), themeColor('--el-color-70'), themeColor('--el-color-117'), themeColor('--el-color-72'), themeColor('--el-color-73'), themeColor('--el-color-74'), themeColor('--el-color-75'), themeColor('--el-color-76'), themeColor('--el-color-77'), themeColor('--el-color-133'), themeColor('--el-color-79'), themeColor('--el-color-55'), themeColor('--el-color-81'), themeColor('--el-color-82'), themeColor('--el-color-114'), themeColor('--el-color-13'), themeColor('--el-color-85'), themeColor('--el-color-86'),  
18	// Palette D 
19	themeColor('--red-8'), themeColor('--el-color-15'), themeColor('--el-color-21'), themeColor('--el-color-74'), themeColor('--el-color-81'), themeColor('--el-color-108'), themeColor('--el-color-60'), themeColor('--el-color-130'), themeColor('--el-color-69'), themeColor('--el-color-114'), themeColor('--el-color-64'), themeColor('--el-color-98'), themeColor('--el-color-77'), themeColor('--el-color-100'), themeColor('--el-color-101'), themeColor('--el-color-102'), themeColor('--el-color-103'), themeColor('--el-color-104'), themeColor('--el-color-105'), themeColor('--el-color-106'), 
20	// Palette E 
21	themeColor('--el-color-107'), themeColor('--el-color-108'), themeColor('--el-color-109'), themeColor('--el-color-29'), themeColor('--el-color-111'), themeColor('--el-color-112'), themeColor('--el-color-109'), themeColor('--el-color-114'), themeColor('--el-color-115'), themeColor('--orange'), themeColor('--el-color-117'), themeColor('--el-color-118'), themeColor('--el-color-119'), themeColor('--el-color-16'), themeColor('--el-color-100'), themeColor('--el-color-122'), themeColor('--el-color-123'), themeColor('--el-color-124'), themeColor('--el-color-125'), themeColor('--el-color-126'),  
22	// Palette F 
23	themeColor('--el-color-28'), themeColor('--el-color-128'), themeColor('--el-color-29'), themeColor('--el-color-130'), themeColor('--el-color-106'), themeColor('--el-color-102'), themeColor('--el-color-133'), themeColor('--el-color-134'), themeColor('--el-color-135'), themeColor('--orange'), themeColor('--el-color-137'), themeColor('--el-color-21'), themeColor('--red-8'), themeColor('--el-color-140'), themeColor('--el-color-141'), themeColor('--el-color-142'), themeColor('--el-color-13'), themeColor('--el-color-144'), themeColor('--el-color-145'), themeColor('--el-color-146'), 
24].slice(0, 23)//We are only using the first 23 colors now. Keeping the palettes just in case. 
25
26const DEFAULT_DAY_VIEW = (!isPWA ? "5day" : "3day")
27
28const COLUMN_MIN_WIDTH = 30
29
30window.standardColorsForEvents = PRESET_COLORS
31
32/**
33 * Get the appropriate colors for the current theme's color picker.
34 * Returns classic preset colors (23) for classic theme, or theme class colors (8) for themed modes.
35 */
36window.getColorsForCurrentTheme = function getColorsForCurrentTheme(
36) {
37	const currentTheme = window.coursicleCurrentTheme || localStorage.getItem('customTheme') || 'classic'
38
39	// Classic theme uses the 23 preset colors
40	if (currentTheme === 'classic') {
41		return window.standardColorsForEvents || []
42	}
43
44	// For themed modes, use the theme's 8 class colors
45	let classColors = null
46
47	// Try localStorage cache first
48	const cachedClassColors = JSON.parse(localStorage.getItem('themeClassColors') || '{}')
49	if (cachedClassColors[currentTheme]) {
50		classColors = window.darkMode ? cachedClassColors[currentTheme].dark : cachedClassColors[currentTheme].light
51	}
52
53	// Fall back to globalViewData if not in cache
54	if (!classColors?.length) {
55		const availableThemes = window.globalViewData?.theme?.availableThemes
56		if (availableThemes) {
57			const themeConfig = availableThemes.find(t => t.id === currentTheme)
58			classColors = window.darkMode ? themeConfig?.classColorsDark : themeConfig?.classColorsLight
59		}
60	}
61
62	// Fall back to classic colors if theme colors not found
63	if (!classColors?.length) {
64		return window.standardColorsForEvents || []
65	}
66
67	return classColors
68}
69
70// default is 5 day for web, 3 day PWA
71if (!store.get('selectedDayViewCount')){
72	store.set('selectedDayViewCount', DEFAULT_DAY_VIEW)
73}
74
75// TODO @nathan: move this to the top of the file since it's the first thing that executes
76// the init, note at the bottom of this listener we paint the calendar immediately if we have classes in local storage
77document.addEventListener('DOMContentLoaded', function () {
78
79    // Event blocks and the time marker all sit 6px in from both sides of a day column. That space
80    // is there so the marker's pulsing dot, which sticks out past its left edge, doesn't get cut
81    // off by the column. Blocks subtract it in renderCalItem() and correctCalItemWidths(); the
82    // marker reads it as a CSS variable. Set this high up because the marker is created below.
83    window.timeMarkerPaddingRequirement = 12
84    document.documentElement.style.setProperty('--time-marker-inset', (window.timeMarkerPaddingRequirement / 2) + 'px')
85
86    const calendarContainingAllFullHeightDayColumns = document.getElementById('calendarContainingAllFullHeightDayColumns');
87    const columnContainingHourLabels = document.getElementById('columnContainingHourLabels');
88    const viewportWithinWhichCalendarScrolls = document.getElementById('viewportWithinWhichCalendarScrolls');
89
90
91    // Load initial set of days (7 to the left of today, 7 to the right, 15 total)
92    function loadInitialDays() {
93        for (let i = -15; i <= 15; i++) {
94            let date = new Date();
95            /* @nathan I imagine we should just adopt this to what we already have, namely data-month, data-date, etc. */
96            date.setDate(date.getDate() + i);
97            calendarContainingAllFullHeightDayColumns.appendChild(createDayColumn(date));
98        }
99    }
100
101    // Function to jump to a specific date
102	//vertical scroll target included so that jump to hour and jump to day are simulaneous in one animation.
103    window.jumpToDate = function(targetDate, justify='center', animation = false, verticalScrollTarget = null) {
104    	
105        const dayWidth = document.querySelector('.dayColumn').clientWidth + 10; // Width of a day element plus margin
106		const leftDay = $('.dayColumn').eq(window.firstVisible)
107		const leftDate = new Date(leftDay.attr('data-isodate'))
108		leftDate.setHours(0, 0, 0, 0)		
109        targetDate.setHours(0, 0, 0, 0); // Set to midnight for accurate comparison
110
111        // Calculate the difference in days
112        let differenceInDays = Math.round((targetDate - leftDate) / (1000 * 60 * 60 * 24));
113
114        // determine the direction
115        if(differenceInDays < 0){
116        	direction = "left";
117        }else{
118        	direction = "right";
119        }
120
121        // note: Joe found that there were some off by one issues here. Like you would create one less than
122        // the number of day columns necessary to get the target day column and so you wouldn't scroll. 
123        // reproduction case was going to a school with only fall 2024 classes, adding one, then trying to create 
124        // a custom spring 2025 class for it. It wouldn't scroll because it hadn't made enough days. So doing it in 
125        // a loop rather than trying to precisely calculate the number of days to add.
126        var targetdayColumn = null;
127        while(!targetdayColumn){
128        	targetdayColumn = Array.from(calendarContainingAllFullHeightDayColumns.children).find((dayColumn) => {
129	            return new Date(dayColumn.getAttribute('data-isodate')).toDateString() === targetDate.toDateString();
130	        });
131
132	        // we definitely only want to add more columns if we didn't find the target day, otherwise we'll add 10
133	        // regardless and it lags the shit out of the site because the day columns will baloon from 90 to 200+
134	        // because of each time you hover
135	        if(!targetdayColumn){
136	        	loadMoreDays(direction, true); // skipPaint: don't paint intervening columns
137	        }
138        }
139
140        // .index() gets the position of the class within its siblings
141		let positionOfTargetDay = $(targetdayColumn).index()
142		let totalDays = $('.dayColumn').length
143
144		if(totalDays - positionOfTargetDay <= 10 || positionOfTargetDay <= 10){
145			loadMoreDays(direction, true) // skipPaint: buffer columns don't need immediate paint
146		}
147        
148        // Scroll the target day element to the center
149        const offsetLeft = targetdayColumn.offsetLeft;
150		let hourLabelWidth = $('.hourLabel').first().get(0).getBoundingClientRect().width
151		let dayColumnWidth = $('.dayColumn').get(0).getBoundingClientRect().width
152		let scheduleViewDays = getScheduleDayNumber()
153		var scrollLeftOffset
154		switch (justify){
155			case "left":
156				scrollLeftOffset = offsetLeft - hourLabelWidth
157				break
158			case "center":
159				let centerViewWidth = (window.isPWA) ? window.innerWidth : $('#centerView').get(0).getBoundingClientRect().width
160				scrollLeftOffset = offsetLeft - (centerViewWidth + hourLabelWidth - dayColumnWidth) / 2
161				break
162			case "right":
163				//If we are in, e.g., 5-day view, we want the day to be scrolled with 5-1=4 days in front of it.
164				//We also offset by the hourlabel width.
165				scrollLeftOffset = offsetLeft - dayColumnWidth * (scheduleViewDays- 1) - hourLabelWidth
166				break
167		}
168
169		$('#viewportWithinWhichCalendarScrolls').stop()
170		$('#viewportWithinWhichCalendarScrolls').animate({
171			scrollLeft: scrollLeftOffset,
172			// Conditionally add the vertical scroll only if verticalScrollTarget is not null
173			...(verticalScrollTarget !== null ? { scrollTop: verticalScrollTarget } : {})
174		}, (animation) ? 200 : 0, function() {
175			paintVisibleUnpaintedColumns()
176			setupColumnPaintObserver()
177		})
178        
179    }
180
181    // Set up the initial days (today and 7 before and 7 after)
182    loadInitialDays();
183
184    // Create hour labels (just the list of hours on the left)
185    addHourLabelsToLeftColumn(columnContainingHourLabels);
186
187	window.todaysDate = new Date()
188				
189	if ($('#timeMarker').length) {
190		$('#timeMarker').remove()
191	}
192	//unfortunately have to hardcode the collective heights of the border widths (24 * 0.5px) because pwa kept rendering 0.333px.
193	window.timeAxisHeight = $('#columnContainingHourLabels').get(0).scrollHeight +12
194	resizeAllColumnHeights();
195	// createDayColumn() no longer sizes each column as it builds it, so the width has to be applied
196	// here. It must come before jumpSoThatTimeMarkerIsInTopLeft(): jumpToDate() derives scrollLeft
197	// from .dayColumn's clientWidth, so resizing after it leaves the calendar scrolled to the wrong
198	// day.
199	resizeAllColumnWidths()
200	cacheRenderedColumnWidth()
201	updatePixelPositionCalItemOffset()
202	updatePixelsPerMinute()
203	jumpSoThatTimeMarkerIsInTopLeft()
204	updateTimeMarker()
205	window.firstVisible = findFirstVisibleDayIndex()
206	fitAllCalItemContent()
207
208    // make it so that as you scroll, more days are added
209    viewportWithinWhichCalendarScrolls.addEventListener('scroll', addMoreDaysOnScroll);
210
211    /* @nathan 
212    	I imagine some vertical offsets will need to be made here given right now the 
213    	hour lines aren't centered vertically with the hour labels */
214    // make it so that the hour labels always line up with the 
215	viewportWithinWhichCalendarScrolls.addEventListener('scroll', function() {
216		columnContainingHourLabels.scrollTop = viewportWithinWhichCalendarScrolls.scrollTop;
217
218		// Push today/semester button down when scrolled near the bottom (PWA only)
219		if (window.isPWA) {
220			const maxOffset = this.scrollHeight - this.clientHeight;
221			const distanceFromBottom = maxOffset - this.scrollTop;
222			const fadeThreshold = 120;
223
224			if (distanceFromBottom < fadeThreshold) {
225				const extraOffset = (fadeThreshold - distanceFromBottom) * 2;
226				$('#returnToTodayBtn, #jumpToNextSemesterBtn').css('transform', 'translateX(-50%) translateY(' + extraOffset + 'px)');
227			} else {
228				$('#returnToTodayBtn, #jumpToNextSemesterBtn').css('transform', 'translateX(-50%)');
229			}
230		}
231	})
232
233	// note 9/5/24: previously to make this bidirectional, we had the following line, but on Safari it seemed to interact funny with the line above
234	// and would cause intense stuttering on vertical scroll, not to mention like 60% CPU utilization instead of the expected 3%. Simple solution: get rid of this
235	// and we just made it so you could no longer scroll the hour labels, which most people aren't going to try anyway. Did this by changing overflow-y to hidden on them.
236	// columnContainingHourLabels.addEventListener('scroll', function() {viewportWithinWhichCalendarScrolls.scrollTop = columnContainingHourLabels.scrollTop;})
237
238	// initialize the calendar container with the days we're viewing
239	$('#fullCalendarWrapperContainingHoursAndDayColumns').addClass('view' + store.get('selectedDayViewCount'));
240
241	// render anything we have in the local storage immediately
242	// if there's nothing, that's fine. We'll repaint once the uuid comes back.
243	window.repaintCalendar(true)
244
245	const container = document.getElementById('viewportWithinWhichCalendarScrolls');
246    let isDragging = false;
247    let startX, startY, scrollLeft, scrollTop;
248
249    container.addEventListener('mousedown', (e) => {
250		
251		//on safari, dragging offscreen scrolls the entire container in such a way that the days are out of view.
252		//prevent that behavior for everyone just in case.
253		e.preventDefault()
254
255		preventTextSelection()
256
257		// prevent dragging when the user is mouse downed on a cal item. We don't want this because 
258		// when the user is clicking events, sometimes they move their hand a little while they do it, especially 
259		// when using the calendar fast, and it'll shift the calendar a bit which is unsettling. Just try adding 5 classes
260		// and then rapidly removing them without this code and you'll see the shift.
261		if($(e.target).closest('.calItem').length > 0){
262    		return;
263    	}
264
265        isDragging = true;
266        container.style.scrollSnapType = 'none';
267        startX = e.pageX - container.offsetLeft;
268        startY = e.pageY - container.offsetTop;
269        scrollLeft = container.scrollLeft;
270        scrollTop = container.scrollTop;
271        // container.style.cursor = 'grabbing'; // Change cursor to indicate dragging
272    });
273
274
275	function ungraspCalendar(){
276		allowTextSelection()
277        isDragging = false;
278        container.style.scrollSnapType = '';
279        // container.style.cursor = 'grab'; // Reset cursor
280	}
281
282
283
284	//SAFARI has no scrollend listener 12/17/2024. We handle semester credits, semester text, and tba class updates here on all web-adjacent platforms.
285	let scrolledSemester = null
286	let timeoutInProgress = null
287
288	$(container).on('scroll', function(){
289		
290		//force a gap of 120ms on constant scroll
291		if(timeoutInProgress){
292			return
293		}
294
295		timeoutInProgress = true
296		updateSemesterInfoTimeout = setTimeout(function(){
297
298			//first pageload issue
299			if(!window.getUsersSchedulesAndEvents()){
300				timeoutInProgress = false
301				return
302			}
303			updateCreditsAndSemesterTextInHeader()
304			if(window.currentSemesterInViewport != scrolledSemester){
305				refreshTBAClasses()
306				scrolledSemester = window.currentSemesterInViewport
307			}
308			timeoutInProgress = false
309		}, 120)
310	})
311
312    container.addEventListener('mouseup', () => {
313		ungraspCalendar()
314    });
315
316	container.addEventListener('contextmenu', function(e){
317		ungraspCalendar()
318	})
319
320    container.addEventListener('mouseleave', () => {
321        isDragging = false;
322        // container.style.cursor = 'grab'; // Reset cursor
323    });
324
325
326
327
328
329    container.addEventListener('mousemove', (e) => {
330        if (!isDragging) return;
331
332        const x = e.pageX - container.offsetLeft;
333        const y = e.pageY - container.offsetTop;
334
335        const walkX = (x - startX) * 1.4; // Scroll speed
336        const walkY = (y - startY) * 1.4; // Scroll speed
337
338        container.scrollLeft = scrollLeft - walkX;
339        container.scrollTop = scrollTop - walkY;
340    });
341
342	
343	
344	window.lastScrollX = $("#viewportWithinWhichCalendarScrolls").scrollLeft()
345	$("#viewportWithinWhichCalendarScrolls").on("scroll touchmove", function(e){
346		//only do if there is some horizontal scroll.
347		let currentScrollX = $(this).scrollLeft()
348		if(currentScrollX != lastScrollX){
349			lastScrollX = currentScrollX
350			showTodayButtonIfNeeded(e);
351		}
352	})
353
354	window.lastScrollTop = 0; // Variable to store the last vertical scroll position
355	window.lastScrollLeft = 0; // Variable to store the last horizontal scroll position
356	
357
358	let touchStartTime;
359    let touchEndTime;
360    let longTouchDuration = 150; // Duration for long tap in milliseconds
361    let longTouchTimeout;
362
363
364	//hold on the calendar to create an event.
365	// but don't allow this if in sharing mode
366	if(window.webAppRole != "viewer"){
367
368		function generateNewEventFromCalendarHold(e){
369			let target = $(e.target)
370			//if this is the hour block that is after 12am (added for spacing), do nothing.
371			const $children = target.closest('.dayColumn').find('.hourLineWithinDayColumn');
372        	if (target.closest('.hourLineWithinDayColumn').is($children.last())){
373				return
374			}
375			if(window.hasNonEditPermissionOnCurrentlySelectedSchedule){
376				alert("You don't have permission to add events to this schedule.");
377				return
378			}
379			if (!target.closest('.calItem').length) {
380				renderCreateCustomGeneralEventModal()
381			
382
383				//here we will override the default start and trigger a change so the end time changes as well. We need to trigger the change in order to create endTimePicker and sync duration
384				$("#createCustomEventStartTime").val(getNearestPreceedingHourOfCalendarHoldEvent(e)).trigger('change')
385
386				//here we get and set the current date.
387				let currentDate = target.closest('.dayColumn').attr('data-isodate')
388				document.querySelector("#createCustomEventDate")._flatpickr.setDate(currentDate)
389			}
390		}
391
392		 $(document).on('touchstart dblclick', '.hourLineWithinDayColumn',  function(e) {
393			touchStartTime = new Date().getTime();
394			// Set a timeout to detect a long tap
395			if(window.isPWA || window.isiPad){
396				e.preventDefault()
397				longTouchTimeout = setTimeout(function() {
398					generateNewEventFromCalendarHold(e)
399				}, longTouchDuration);
400				return
401			}
402			generateNewEventFromCalendarHold(e)
403		});
404
405		//when you click on the calendar and there is an event detail open, close that.
406		$(document).on('click', '.hourLineWithinDayColumn', function() {
407			if($('#infoColumn').is(':visible')){
408				$('#infoColumn .closeIN').click()
409			}
410		})
411	
412		$(document).on('touchend touchmove mouseup', '.dayColumn', function(e) {
413			touchEndTime = new Date().getTime();
414	
415			// If touch duration is less than longTouchDuration, clear the timeout
416			if ((touchEndTime - touchStartTime) < longTouchDuration) {
417				clearTimeout(longTouchTimeout);
418			}
419		});
420
421	}
422
423	window.semesterAndCreditsTippy = tippy('#semesterAndCreditsText', {
424		allowHTML: true,
425		placement: 'bottom',
426		trigger: 'manual',
427		interactive: true,
428		theme: 'semesterAndCredits',
429		zIndex: 10007
430	});
431
432	$('#semesterAndCreditsText').on('click', function(){
433		//if the menu is already open, allow it to close.
434		if($('.tippy-box[data-theme~="semesterAndCredits"]').length > 0){
435			return
436		}
437		innerHtml = renderHTMLForSemesterAndCreditTippyListView()
438		if(!innerHtml){
439			return
440		}
441		document.querySelector('#semesterAndCreditsText')._tippy.setContent(innerHtml)
442		document.querySelector('#semesterAndCreditsText')._tippy.show()
443	})
444
445	// rip harry
446	// Me and nathan can't seem to find an explanation for this but we will one day figure this out.
447	// We will master our craft. If you don't like your life change the way you think.
448	// rip harry
449
450	window.shareScheduleLinkCopiedTippy = tippy('#webViewShareScheduleIcon', {
451		allowHTML: true,
452		content: "<span id='linkCopiedText'>Link copied!</span>",
453		trigger: 'manual',
454		placement: 'left',
455		theme: 'shareScheduleLinkCopied',
456		maxWidth: '150px',
457		arrow: true,		
458		onShow(instance){
459			setTimeout(() => {
460				instance.hide()
461			}, 1500)
462		}
463	})
464
465	window.shareScheduleOptionsTippy = tippy('#webViewShareScheduleBtn', {
466		allowHTML: true,
467		content: `
468			<div id='shareClassesButton' class="shareScheduleTippyButton tippyButton">
469				<a onclick="createSharableLink('classes')">Share Classes</a>
470            </div>
471			<div id='shareEverythingButton' class="shareScheduleTippyButton tippyButton">
472				<a onclick="createSharableLink('everything')">Share Classes & Events</a>
473            </div>
474		`,
475		trigger: 'manual',
476		interactive: true,
477		placement: 'left',
478		theme: 'shareSchedule',
479		zIndex: 10007
480	});
481
482		tippy.delegate('#leftRail', {
483			target: ['.classSearchProfessorRating', '.professorRatingNumberContainer'],
484			content: textForProfessorRatingTooltip,
485			placement: 'top',
486			maxWidth: 125,
487			onCreate(instance) {
488				// Add a custom class to the tooltip element
489				instance.popper.classList.add('darkTippy');
490			}
491		});
492	
493	const disabledPermissionsDictionary = {
494		'webViewAddEventBtn': "You don't have permission to add events to this schedule.", 
495		'webViewShareScheduleText': "You don't have permission to share this schedule.", // unfortunately have to attach it to the child
496		'webViewShareScheduleIcon': "You don't have permission to share this schedule.", // unfortunately have to attach it to the child
497		'addTaskButtonWebView' : "You can't add a task while viewing someone else's schedule.",
498		"addClassButton" : "You don't have permission to add classes to this schedule."
499	}
500	//Tooltips for event and class detail views
501	tippy.delegate('body', {
502		target: '.addClassButton, .webViewHeaderElement, .webViewHeaderElement *', // match children too
503		placement: 'bottom',
504		arrow: true,
505		theme: 'permissionNoticeTippy',
506		allowHTML: true,
507		onShow(instance){
508			// if they have permission to share, never show this
509			if ($('body').hasClass('viewingScheduleWithOwnerPermission') || $('body').hasClass('viewingScheduleWithWritePermission')){
510				return false;
511			}
512			if(instance.reference.classList.contains("addClassButton")){
513				return true;
514			}
515			// only on certain header elements do we show this
516			if (!Object.keys(disabledPermissionsDictionary).includes(instance.reference.id)) {
517				return false;
518			}
519			return true;
520		},
521		content: (reference) => {
522			const id = reference.id;
523			if (disabledPermissionsDictionary[id]) {
524				return `<span class='detailViewTippyText'>${disabledPermissionsDictionary[id]}</span>`;
525			}
526
527			// fallback: check for matching class name in the dictionary
528			for (let key in disabledPermissionsDictionary) {
529				if (reference.classList.contains(key)) {
530					return `<span class='detailViewTippyText'>${disabledPermissionsDictionary[key]}</span>`;
531				}
532			}
533
534			return ''; // No match
535		},
536		onCreate(instance) {
537			instance.popper.classList.add('darkTippy');
538		}
539	});
540
541	// it's more intuitive to have the info/exclamation mark icon be clickable in the web note, so going to do that 
542	wrapWebViewBannerIconInLink();
543
544	// The PHP render above is only as fresh as the page load. The PWA re-fetches the banner every
545	// time it comes to the foreground (all/pwa/js/init.js), so a desktop tab left open overnight
546	// was the one surface that could keep showing a banner the server had since retired (or miss
547	// one it had since started, like a winback offer). Same trigger sync.js uses for user data.
548	// Desktop only. isMobile rather than isPWA because isPWA is just "is Android" (all/index.php:307),
549	// whereas the header this renders into is gated on !$isMobile, which also covers iPhone browsers
550	// and the iOS app's web view. On any of those the fetch (and its offer-pricing DB work) would
551	// have nowhere to land; the PWA refreshes its own banner (#calendarBannerNote) in init.js.
552	if(typeof ifvisible !== 'undefined' && window.webAppRole != "viewer" && !window.isMobile){
553		ifvisible.on("wakeup", getLatestWebViewBannerFromServer);
554	}
555	//if they are viewing a shared schedule, put them at the current registration semester
556	if(window.webAppRole == "viewer"){
557		// if this is a user who is just viewing data, we need to provide them that local storage now
558		// we have to do it here because all of the stuff above here inits things about the view and shit
559		setUsersSchedulesAndEvents(window.theSchedulesAndEventsTheViewerIsViewing);
560		//put some classes in view.
561		let semesters = gatherSemestersForTeleportSemesterSelector()
562		let newSemester = semesters[semesters.length - 1]
563		jumpToSemesterStart(newSemester)
564	}
565	//if they are on their own schedule but no classes outside of registration semester, scroll there
566	else if(userHasCourseSavedInRegistrationSemester(true)){
567		jumpToSemesterStart(window.registrationSemester)
568	}
569
570	showTodayButtonIfNeeded()
571});
572
573
574// For some reason, scrolling vertically on Safari seems just slightly lower framerate than Chrome, maybe could easily be fixed
575// by just CSS smooth scrolling or something, but like our veritcally scrolling on the coursicleCalendar bracnch is perfect so idk
576// why this one is bad
577// 9-11-24 Miguel moved this and a bunch of functions out of DOMContentLoaded to be able to use it in 'jumpDays' for left/right calendar arrow buttons
578// It has some drift because I am not adding enough for the explicitDistanceToCheck in it's
579// implementation in jumpDays
580function addMoreDaysOnScroll(force = false) {
581	const scrollLeft = viewportWithinWhichCalendarScrolls.scrollLeft;
582	const scrollWidth = viewportWithinWhichCalendarScrolls.scrollWidth;
583	const clientWidth = viewportWithinWhichCalendarScrolls.clientWidth;
584	let dayNumber = getScheduleDayNumber()
585	let calWidth = (dayNumber * window.columnWidth)
586
587	if (window.jumpDayAnimating & !force) {
588		return
589	}
590
591	// If the user scrolls near the leftmost part, load more days to the left
592	if (scrollLeft < (force ? calWidth : 100)) { // Load more days if close to the left edge
593		loadMoreDays('left');
594		if (force) {
595			window.jumpAnimationEndingPositionTarget += (2 * calWidth);
596		}
597	}
598
599	// If the user scrolls near the rightmost part, load more days to the right
600	if (scrollLeft + clientWidth >= scrollWidth - (force ? calWidth : 100)) { // Close to the right edge
601		loadMoreDays('right');
602		if (force) {
603			window.jumpAnimationEndingPositionTarget -= calWidth;
604		}
605	}
606}
607
608function loadMoreDaysDateCompileHelper(date){
609	let columnDate = date.getDate()
610	let columnMonth = date.getMonth()
611	let columnYear = 1900 + date.getYear()
612	//on first load, this happens before getUsersSchedulesAndEvents can return anything
613	//I think it's an unwanted scroll fire caused by some DOM manipulation
614	if(window.getUsersSchedulesAndEvents()){
615		let col = document.querySelector(`.dayColumn[data-month="${columnMonth}"][data-year="${columnYear}"][data-date="${columnDate}"]`)
616		if (col) {
617			col.dataset.painted = 'false'
618			if (window.columnPaintObserver) {
619				window.columnPaintObserver.observe(col)
620			} else {
621				addEventBlocksToDayColumn($(col), getEventIDsForCurrentlySelectedSchedule(), false)
622				col.dataset.painted = 'true'
623			}
624		}
625	}
626}
627
628// Load more days in the given direction (left or right)
629// skipPaint: when true, creates columns without painting them (used during jumps)
630function loadMoreDays(direction, skipPaint = false) {
631	const daysToAdd = 10; // Number of days to add each time
632	if (direction === 'left') {
633		var firstDay
634		for (let i = 1; i <= daysToAdd; i++) {
635			firstDay = calendarContainingAllFullHeightDayColumns.querySelector('.dayColumn:first-child');
636			if(firstDay){
637				let date = new Date(firstDay.getAttribute('data-ISOdate')); // Retrieve date from data attribute
638				date.setDate(date.getDate() - 1);
639				let newCol = createDayColumn(new Date(date))
640				newCol.dataset.painted = 'false'
641				calendarContainingAllFullHeightDayColumns.insertBefore(newCol, firstDay);
642				if (!skipPaint) loadMoreDaysDateCompileHelper(date)
643			}
644		}
645		//
646		// Adjust the scroll position after adding days to the left
647		viewportWithinWhichCalendarScrolls.scrollLeft += firstDay.clientWidth * daysToAdd;
648	} else if (direction === 'right') {
649		for (let i = 1; i <= daysToAdd; i++) {
650			let lastDay = calendarContainingAllFullHeightDayColumns.querySelector('.dayColumn:last-child');
651			if(lastDay){
652				let date = new Date(lastDay.getAttribute('data-ISOdate')); // Retrieve date from data attribute
653				date.setDate(date.getDate() + 1);
654				let newCol = createDayColumn(new Date(date))
655				newCol.dataset.painted = 'false'
656				calendarContainingAllFullHeightDayColumns.appendChild(newCol);
657				if (!skipPaint) loadMoreDaysDateCompileHelper(date)
658			}
659		}
660		//window.jumpAnimationEndingPositionTarget -= (viewportWithinWhichCalendarScrolls.clientWidth * daysToAdd);
661	}
662	resizeAllColumnHeights()
663}
664
665/* @nathan this can probably be removed since we already have it? */
666function formatCalendarDayText(date) {
667	const month = date.getMonth() + 1;
668	const day = date.getDate()
669
670	const formattedDate = `${month}/${day}`
671	return formattedDate;
672}
673
674function formatDayOfWeekText(date) {
675	const options = {weekday: 'short'}
676	return date.toLocaleString('en-US', options)
677}
678
679// this actually creates the little lines within each day column. They're not
680// actually long lines, just a bunch of short ones that are perfectly aligned so that
681// they look like one long line.
682function createHourLinesInsideDayColumn(dayColumn) {
683	for (let hour = 0; hour < 25; hour++) {
684		const hourLineWithinDayColumn = document.createElement('div');
685		hourLineWithinDayColumn.className = 'hourLineWithinDayColumn';
686		dayColumn.appendChild(hourLineWithinDayColumn);
687	}
688}
689
690// this is what creates the labels within the hour column, adds them
691// stacks them on top of each other one by one
692/* @nathan maybe we should do this in PHP? Just to keep the Javascript simpler */
693function addHourLabelsToLeftColumn(columnContainingHourLabels) {
694	for (let hour = 0; hour < 25; hour++) {
695		const label = document.createElement('div');
696		label.className = 'hourLabel';
697		label.setAttribute('data-hour', hour)
698		label.textContent = (hour % 12 === 0 ? 12 : hour % 12) + (hour < 12 ? 'am' : 'pm'); // Format time
699		if(hour == 24){
700			label.textContent = '12am'
701		}
702		columnContainingHourLabels.appendChild(label);
703	}
704}
705
706// this is what actually creates the day columns, adds the lines in them, paints the day, and adds in any metadata
707// this is called when the page first loads (7 to the left of today, 7 to the right, 15 total) as well as as you scroll
708// doing this in vanilla javascript because it's always faster than jQuery, although it's likely this would be plenty fast
709// in jquery too.
710function createDayColumn(date) {
711	// Create the main day column container
712	const dayColumn = document.createElement('div');
713	dayColumn.className = 'dayColumn';
714	
715	// Create the inner div for the date text
716	const dateTextContainer = document.createElement('div');
717	dateTextContainer.className = 'columnDateTextContainer';
718
719	dayOfWeekText = document.createElement('div')
720	dayOfWeekText.classList.add('dayOfWeekText')
721	dayOfWeekText.textContent = formatDayOfWeekText(date)
722
723	calendarDateText = document.createElement('div')
724	calendarDateText.classList.add('calendarDateText')
725	calendarDateText.textContent = formatCalendarDayText(date)
726	
727
728	dateTextContainer.appendChild(dayOfWeekText)
729	dateTextContainer.appendChild(calendarDateText)
730
731	
732	// Append the inner div to the main day column
733	dayColumn.appendChild(dateTextContainer);
734
735	// Create hour lines inside the day column
736	createHourLinesInsideDayColumn(dayColumn);
737	
738	// Set the data attributes
739	dayColumn.setAttribute('data-month', date.getMonth());
740	dayColumn.setAttribute('data-date', date.getDate()); 
741	dayColumn.setAttribute('data-year', 1900 + date.getYear());
742
743	// Width comes from --day-column-width (see resizeAllColumnWidths). This used to call
744	// calculateProperColumnWidth() twice per column built: ten forced layouts for a shared number.
745
746	// Log the date and its ISO string
747	//console.log(date, date.toISOString());
748	dayColumn.setAttribute('data-ISOdate', date.toISOString());
749
750	return dayColumn;
751}
752
753// this will actually repaint everything calendar related
754window.repaintCalendar = function(instant = false, scroll=true){
755    
756    // should repaint the entire schedule, but only once we actually have 
757    if(window.getUsersSchedulesAndEvents() === undefined){
758    	return;
759    }
760	
761	// permission related functions
762	// get the schedule that is being repainted here
763	let selectedSchedule = getActiveScheduleObject()
764	// if the infoColumn is open and the permission of the open event is different than the permission of the new schedule, close the left rail
765	// we originally were trying to see if the id of the event was in the schedule, but it was a bit unreliable for some reason and this makes it so 
766	// we only get the jumpiness when we really need it (i.e. when they're switching to a shared schedule)
767	window.permissionOnCurrentlySelectedSchedule = selectedSchedule.permission;
768	window.hasNonEditPermissionOnCurrentlySelectedSchedule = (!["owner", "write"].includes(window.permissionOnCurrentlySelectedSchedule))
769	let newPermissionClass = `viewingScheduleWith${capitalizeFirstLetter(selectedSchedule.permission)}Permission`;
770	if($("#infoColumn").is(":visible") && !$("#infoColumn").hasClass(newPermissionClass)){
771		returnToLastLeftRailState()
772	}
773	let targetElements = ["body", "#infoColumn"];
774	for(let element of targetElements){
775		$(element).removeClass("viewingScheduleWithOwnerPermission viewingScheduleWithWritePermission viewingScheduleWithCommentPermission viewingScheduleWithReadPermission");
776		$(element).addClass(newPermissionClass);
777	}
778
779	// the New Course form's scan shortcut is decided by the schedule's permission, so a repaint that
780	// changes which schedule is selected (or that lands the very first schedules for a new user, whose
781	// storage was empty when the form rendered) has to re-decide it on any form already on screen
782	if($("#createCustomEventModal").attr("data-context") == "createCustomClass"){
783		$('#createClassFromSyllabusBtn').toggle(window.canUseSyllabusOnCurrentSchedule())
784	}
785
786	// and the same for the course search empty states, whose offer sentence is built from that same
787	// permission (renderAddCustomClassOffer in searchClasses.js). Both of them can be on screen across
788	// a schedule switch, so the sentence gets re-asked here rather than only when a search re-runs.
789	if(typeof window.refreshAddCustomClassOffer == 'function'){
790		window.refreshAddCustomClassOffer()
791	}
792
793	$('.calItem, .allDayEvent').remove()
794	$('.columnDateTextContainer').removeClass('noColumnDateTextContainerPadding')
795	// sort eventIDs in schedulesAndEvents to make sure that events get painted the same every time between previews, setEvents, and server responses.
796	// note: this is ordering the selected schedule's events array _in place_, but that shouldn't cause an issue.
797	sortActiveScheduleEvents()
798	// Mark all columns as needing repaint, then only paint visible ones + buffer.
799	// The IntersectionObserver will paint the rest as they scroll into view.
800	let eventIDs = getActiveScheduleEventIDs()
801	$('.dayColumn').each(function(){
802		this.dataset.painted = 'false'
803	})
804	let columns = document.querySelectorAll('.dayColumn')
805	let visibleRange = getVisibleColumnRange(columns)
806	for (let i = visibleRange.start; i <= visibleRange.end; i++) {
807		addEventBlocksToDayColumn($(columns[i]), eventIDs)
808		columns[i].dataset.painted = 'true'
809	}
810
811	refreshCreditTally()
812	refreshTBAClasses()
813	refreshScheduleDropdown();
814	timeMarkerOnCalItem();
815	updateTimeMarker();
816	updateCreditsAndSemesterTextInHeader()
817	refreshAllDayEventList()
818	setupColumnPaintObserver()
819}
820
821// Returns the index range of visible columns + a buffer on each side
822function getVisibleColumnRange(columns) {
823	let scrollContainer = document.getElementById('viewportWithinWhichCalendarScrolls')
824	let scrollLeft = scrollContainer.scrollLeft
825	let viewportWidth = scrollContainer.clientWidth
826	let buffer = 3 // extra columns on each side
827
828	let start = columns.length, end = 0
829	for (let i = 0; i < columns.length; i++) {
830		let colLeft = columns[i].offsetLeft
831		let colRight = colLeft + columns[i].clientWidth
832		// Column is in or near the viewport
833		if (colRight >= scrollLeft && colLeft <= scrollLeft + viewportWidth) {
834			if (i < start) start = i
835			if (i > end) end = i
836		}
837	}
838	// Apply buffer
839	start = Math.max(0, start - buffer)
840	end = Math.min(columns.length - 1, end + buffer)
841	return { start, end }
842}
843
844// IntersectionObserver that paints columns as they scroll into view
845window.columnPaintObserver = null
846function setupColumnPaintObserver() {
847	if (window.columnPaintObserver) window.columnPaintObserver.disconnect()
848
849	let root = document.getElementById('viewportWithinWhichCalendarScrolls')
850	window.columnPaintObserver = new IntersectionObserver(function(entries) {
851		entries.forEach(function(entry) {
852			if (entry.isIntersecting && entry.target.dataset.painted === 'false') {
853				entry.target.dataset.painted = 'true'
854				addEventBlocksToDayColumn($(entry.target), getActiveScheduleEventIDs())
855			}
856		})
857	}, { root: root, rootMargin: '0px 300px 0px 300px' }) // 300px horizontal buffer
858
859	document.querySelectorAll('.dayColumn').forEach(function(col) {
860		window.columnPaintObserver.observe(col)
861	})
862}
863
864// Paint any visible columns that haven't been painted yet (safety net for jumps)
865function paintVisibleUnpaintedColumns() {
866	let columns = document.querySelectorAll('.dayColumn')
867	let range = getVisibleColumnRange(columns)
868	let eventIDs = getActiveScheduleEventIDs()
869	for (let i = range.start; i <= range.end; i++) {
870		if (columns[i].dataset.painted === 'false') {
871			addEventBlocksToDayColumn($(columns[i]), eventIDs)
872			columns[i].dataset.painted = 'true'
873		}
874	}
875}
876
877function sortActiveScheduleEvents() {
878	let events = getActiveScheduleEventIDs()
879	events.sort((a, b) => {
880		//Ensure that eventIDs with value 0 are always returned at the end of the list. 
881		//Since the server will return an ID that is greater than all of the previous IDs, this ensures order is preserved
882		if(a == 0) {
883			return 1
884		} else if (b == 0) {
885			return -1
886		}
887		return a - b
888	})	
889}
890
891//Handling clicking when a user wants to share a schedule. If the user has no saved events, just generate a link. otherwise, display tippy to let them choose
892//if they want to share their schedule or their schedule and events.
893$("#webViewShareScheduleBtn").on("click", shareButtonTippyAndLinkGeneration)
894
895//functionalizing because we're turning on the button again after resetting the share state
896function shareButtonTippyAndLinkGeneration() {
897	if(hasNonEditPermissionOnCurrentlySelectedSchedule){return;}
898	$('.tippy-box').show()
899	if (userHasNonClassEvents()) {		
900		document.querySelector('#webViewShareScheduleBtn')._tippy.show()
901	} else {
902		createSharableLink('classes')
903	}
904}
905function userHasNonClassEvents() {
906
907	let hasEvents = false;
908
909	getActiveScheduleEventIDs().forEach(activeScheduleEventID => {		
910		if (getEventByID(activeScheduleEventID).type == 'general') {
911			hasEvents = true 
912		}
913	})	
914	return hasEvents;
915}
916
917async function createSharableLink(context) {
918	if(getCurrentlySelectedScheduleID() == 0){
919		alert('Please add a class or event before sharing your schedule.')
920		if(window.isPWA){
921			hideSlidableModal()
922		}
923		return
924	}
925	$('#webViewShareScheduleBtn').off('click')
926	$('.tippy-box').hide()
927	
928	if(!window.isPWA){
929		// update the ui
930		updateShareShareScheduleText("generating");
931	}
932	
933	if($('#webViewShareScheduleText').css('display') == 'none') {
934		$("#webViewShareScheduleIcon").hide();
935		$('#shareScheduleLoadingSpinner').css('display', 'block')
936	}
937     // let's make it seem like we're really doing something
938    await customDelay(700);
939	
940	if($('#webViewShareScheduleText').css('display') == 'none') {
941		$('#shareScheduleLoadingSpinner').css('display', 'none')
942		$("#webViewShareScheduleIcon").show();
943	}
944    // kick off the network request to get their share link
945    var data = {type:"schedule", subtype: context, scheduleID: getCurrentlySelectedScheduleID()};
946
947    // but first check if we've already generated it, no need to make another network call
948    if(getUserLink(data) != null){
949    	updateShareShareScheduleText("generated", data);
950    	return;
951    }
952
953    let params = {
954        "uuid" : store.get("uuid"),
955        "data" : JSON.stringify(data)
956    };
957    $.ajax({
958		url: "/shared/link/generate.php",
959		type: 'POST',
960		dataType: 'json',
961		data: params,
962		success: function(response){
963            if(response.error == true){
964                return
965            }
966			if(window.isPWA){
967				//navigator share stuff
968				pwaShare('View my ' + window.schoolDisplayName + ' class schedule', response.url)
969				hideSlidableModal()
970			}else{
971				setUserLink(response.url, data);
972            	updateShareShareScheduleText("generated", data);
973			}
974		}
975	});
976}
977
978function updateShareShareScheduleText(state, data = null){
979	//If we have a small window, we are only showing the share icon. In this case, lets keep showing the icon on generation and show a tippy
980	//saying that we've copied their share link to their clipboard.
981	let smallWindowBoolean = $('#webViewShareScheduleText').css('display') == 'none'	
982
983	if(state == "generating"){
984		$("#webViewShareScheduleText").text("Generating link...");
985		$("#webViewShareScheduleText").addClass("generating");
986		if (!smallWindowBoolean) {
987			$("#webViewShareScheduleIcon").hide();
988		}
989	}else if(state == "generated"){
990		$("#webViewShareScheduleText").removeClass("generating");
991		$("#webViewShareScheduleText").html(`
992			<a id="shareScheduleLink" target="_blank" href="https://${getUserLink(data)}">${getUserLink(data).slice(getUserLink(data).split(".")[0].length + 1)}</a>
993		`)
994		if (smallWindowBoolean) {			
995		
996			navigator.clipboard.writeText(`https://${getUserLink(data)}`)
997			document.querySelector('#webViewShareScheduleIcon')._tippy.show()
998			resetScheduleLinkAfterTimeout(0)
999		} else {
1000			resetScheduleLinkAfterTimeout(30)
1001			$("#webViewShareScheduleIcon").hide();
1002		}
1003	}
1004}
1005
1006//Used to refresh schedule link to non-clicked status
1007function resetScheduleLinkAfterTimeout(seconds){	
1008	setTimeout(function() {
1009		$("#webViewShareScheduleIcon").show();
1010		$("#webViewShareScheduleBtn").on("click", shareButtonTippyAndLinkGeneration)
1011		$("#webViewShareScheduleText").html(`Share <span id="scheduleWordInShareText">Schedule</span>`)
1012	}, seconds * 1000)
1013}
1014
1015$('.shareButtonIN').on("click", function(){
1016	let school = $(this).attr('data-school')
1017	let courseWithSection = $('.headerIN').find('#detailViewTitle').text()
1018	let query = courseWithSection.split(" ").join("+")
1019	let subdomain = getUrlSubdomain()
1020	let shareUrl = `https://${subdomain}.coursicle.com/${school}/?search=${query}&type=course`
1021	if (navigator.share) {
1022        navigator.share({
1023          title: 'Coursicle',
1024          text: 'Plan your schedule & get into classes',
1025		  url: shareUrl
1026        })
1027    }else if (navigator.userAgent && navigator.userAgent.indexOf("Mac") != -1) {
1028        //if mac send an sms
1029		window.open("sms:?&body=" + encodeURIComponent("Plan your schedule & get into classes " + shareUrl))
1030	}else{
1031        //all other cases: send an email
1032		window.open("mailto:?subject=Coursicle&body=" + encodeURIComponent("Plan your schedule & get into classes "+ shareUrl))
1033	}
1034})
1035
1036$('#darkModeToggleSwitch').on('click', function(e) {
1037	e.preventDefault();
1038	let newMode = window.darkMode ? 'light' : 'dark';
1039	setAppearance(newMode);
1040	localStorage.setItem('theme', newMode);
1041	syncAppearanceToServer();
1042})
1043
1044
1045
1046function roundToNearestNMinutes(num, resolution) {
1047    if (num < resolution / 2) return 0;
1048    return Math.round(num / resolution) * resolution;
1049}
1050
1051
1052function getNearestPreceedingHourOfCalendarHoldEvent(e) {
1053
1054	let closestHourBlock = $(e.target).closest('.hourLineWithinDayColumn')
1055
1056    // Get the index within the column of the clicked hour block
1057	let closestHourBlockIndex = closestHourBlock
1058    .siblings('.hourLineWithinDayColumn')
1059    .addBack() // Include the closest element itself
1060    .index($(e.target).closest('.hourLineWithinDayColumn'));  
1061
1062	let closestHourBlockRect = closestHourBlock.get(0).getBoundingClientRect()
1063	let zeroMinuteLine = closestHourBlockRect.top
1064	
1065	var yPosition
1066	if(e.type == "touchstart"){
1067		const touch = e.originalEvent.touches[0]; // Get the first touch point
1068        yPosition = touch.clientY;
1069	}else if(e.type == "dblclick"){
1070        yPosition = e.clientY;
1071	}
1072
1073	let closestMinute = (yPosition - zeroMinuteLine) / window.pixelsPerMinuteForCalItems
1074
1075	//pad with zeros so we get '00' instead of '0'
1076    let closestNMinuteInterval = String(roundToNearestNMinutes(closestMinute, (isPWA ? 30 : 15))).padStart(2,'0')
1077
1078	/*
1079	this is kind of messy. Remember that the reference point is the currently clicked .hourLineWithinDayColumn.
1080	so, if you click...
1081
1082		<--- here, you get 60.
1083	4pm
1084	   <-----here, you get 0.
1085
1086	This gets rectified in the special cases.
1087
1088	*/
1089
1090
1091	//special cases
1092	//case I: see above
1093	//case II: If the user clicks very nearly 12am, make it 11:45pm.
1094
1095	if(closestNMinuteInterval == 60 && closestHourBlockIndex < 23){
1096		closestHourBlockIndex += 1
1097		closestNMinuteInterval = '00'
1098	}else if(closestNMinuteInterval == 60){
1099		if(!isPWA){
1100			closestNMinuteInterval = '45'
1101		}else{
1102			closestNMinuteInterval = '30'
1103		}
1104	}	
1105
1106    // Convert the index to a formatted time string (e.g., "1:00 PM")
1107    const indexToTimeString = (index) => {
1108        if (index < 0 || index > 23) return ''; // Return an empty string for invalid indices
1109        
1110        const hour = index % 12 || 12; // Convert 24-hour format to 12-hour format
1111        const period = index < 12 ? 'am' : 'pm'; // Determine AM or PM
1112        
1113        return `${hour}:${closestNMinuteInterval}${period}`;
1114    };
1115
1116    // Get the formatted time string
1117    const timeString = indexToTimeString(closestHourBlockIndex);
1118
1119    return timeString;
1120}
1121
1122
1123
1124// use these for all the search modal stuff when these filters are currently applied
1125
1126function preventTextSelection() {
1127	document.body.style.userSelect = 'none';
1128	document.body.style.webkitUserSelect = 'none'; // Chrome, Safari
1129	document.body.style.mozUserSelect = 'none'; // Firefox
1130	document.body.style.msUserSelect = 'none'; // Internet Explorer/Edge
1131}
1132
1133function allowTextSelection() {
1134	document.body.style.userSelect = '';
1135	document.body.style.webkitUserSelect = '';
1136	document.body.style.mozUserSelect = '';
1137	document.body.style.msUserSelect = '';
1138}
1139
1140function refreshScheduleDropdown(){
1141	let name = getActiveScheduleObject().name + '' //ensure that the name is a string
1142	$('#webViewTeleportScheduleBtnTextContainer').text(name.length < 20 ? name : name.slice(0,17) + '...')
1143}
1144//
1145// Calendar Click Listeners
1146//
1147
1148// Launch add class modal (pwa only)
1149$(document).on('click', '#addBtn', function(){
1150	if(hasNonEditPermissionOnCurrentlySelectedSchedule){
1151		alert("You don't have permission to add events to this schedule.");
1152		return;
1153	}
1154	$('#syllabusAddButton, #syllabusAddTitle').toggleClass('syllabusOptionUnavailable', !window.canUseSyllabusOnCurrentSchedule())
1155
1156	$('#blurryBackground').stop()
1157	$('#blurryBackground').fadeToggle(250)
1158	$(".addingButton, .addBtnTitle").toggleClass('translated')
1159	
1160})
1161
1162// anything tapped outside the add menu puts it away — except the Syllabus option, which hands
1163// straight off to the file picker and lets the picker's own menu (photo library, camera, files) sit
1164// on top of the add menu instead of replacing it. That one closes late, once a file is actually
1165// chosen (see the picker's change handler in syllabusScan.js), so backing out of the picker leaves
1166// the options standing rather than dropping the user back on a bare calendar. Arming the picker
1167// clicks the hidden file input, and that click bubbles through here too, so it gets the same pass.
1168// Scrolls are held to a stricter rule than taps: the file picker's sheet coming up over the webview
1169// moves the page under it, and that scroll is the app's own doing rather than the user asking for
1170// anything. A tap can't arrive while the sheet is up — it would land on the sheet — so taps stay
1171// dismissing as they were, which is also what recovers the menu if the picker was backed out of.
1172$(document).on('click scroll', function(e){
1173	if($('.addingButton').hasClass('translated') && $(e.target).closest('#addBtn, #syllabusAddButton, #syllabusAddTitle, #syllabusScanFileInput').length == 0){
1174		if(e.type == 'scroll' && window.syllabusScanPendingPick){return}
1175		e.stopImmediatePropagation()
1176		closeAddMenu()
1177	}
1178})
1179
1180// the add menu's dismissal on its own, since the syllabus flow closes it from outside this file
1181window.closeAddMenu = function(){
1182	if(!$('.addingButton').hasClass('translated')){return}
1183	$('#blurryBackground').stop()
1184	$('#blurryBackground').fadeOut(250)
1185	$(".addingButton, .addBtnTitle").removeClass('translated')
1186}
1187
1188// no class is in view out here, so the scan works out which class the syllabus is for once it's read
1189$(document).on('click', '#syllabusAddButton', function() {
1190    window.startSyllabusScanWithoutClass()
1191})
1192
1193$(document).on('click', '#webViewAddEventBtn, #addEventButton', function() {
1194	if(hasNonEditPermissionOnCurrentlySelectedSchedule){return;}
1195	// this got annoying
1196	// if add event view is visible, close left rail.
1197	// if($('#leftRail').find(`[data-context="createCustomEvent"]`).length > 0){
1198	// 	returnToLastLeftRailState()
1199	// 	return
1200	// }
1201	renderCreateCustomGeneralEventModal()
1202})
1203
1204$(document).on('click', '#addTaskButton', function() {
1205	resetState()
1206	renderSlidableModal($('#createTaskModal'), true)
1207	$('#createTaskBtn').text('Add task')
1208    hideCreateTaskDropdown("Tag")
1209	$('#taskTagSearch').addClass('hide')
1210
1211    hideCreateTaskDropdown("Due")
1212
1213    populateCreateTaskTagDropdown(null)
1214    populateCreateTaskDueDropdown()
1215})
1216
1217// Launch calendar/schedule settings modal
1218$(document).on('click', '#calSettingsBtn, #webViewTeleportSchedulesBtn', async function() {
1219	$('#calSchedulesListContainer').empty()
1220	generateNewTeleportSemesterSelector()
1221	updateSchedulesList()
1222	preselectDayView()
1223	refreshCreditTally()
1224	if(window.isPWA){
1225		renderSlidableModal('#scheduleNavModal', false)
1226		$("#webViewTeleportIcon").addClass('iconPointsUp')
1227	}else{
1228		if($('#webViewScheduleTeleportContainer').find('#scheduleNavModal').length == 0){
1229			$('#scheduleNavModal').appendTo('#webViewScheduleTeleportContainer')
1230		} 
1231
1232		// checking if the container is visible and just toggling its visibility
1233
1234		if($('#webViewScheduleTeleportContainer').is(':hidden')) {
1235			$('#webViewTeleportIcon').addClass('teleportIconPointsUp')
1236			$('#webViewScheduleTeleportContainer').show()
1237		} else {
1238			// removing and hiding the modal when the person clicks the button again.
1239			$('#webViewTeleportIcon').removeClass('teleportIconPointsUp')
1240			$('#webViewScheduleTeleportContainer').hide()
1241		}
1242
1243	}
1244	
1245	colorTeleportSemesterNavArrows()
1246})
1247
1248
1249//this isn't really a modal anymore so we have to do this one-off dismissal listener
1250$(document).on('click', function(e) {
1251	if($(e.target).closest('.tippy-content').length > 0){
1252		return
1253	}
1254	if(!window.isPWA){
1255		let teleportContainer = $('#webViewScheduleTeleportContainer')
1256		let teleportBtn = $('#webViewTeleportSchedulesBtn')
1257		let filtersContainer = $('#classSearchFilterModal')
1258		let filtersBtn = $('#classSearchInputFilterIcon')
1259		if (!teleportContainer.get(0).contains(e.target) && !teleportBtn.get(0).contains(e.target)) {
1260			teleportContainer.hide();
1261			$('#webViewTeleportIcon').removeClass('teleportIconPointsUp')
1262		}
1263		if (!filtersContainer.get(0).contains(e.target) && !filtersBtn.get(0).contains(e.target)) {
1264			$("#classSearchInputFilterIcon").removeClass("active");
1265			filtersContainer.hide();
1266		}
1267	}
1268	
1269});
1270
1271// Launch classDetail modal from calendar block click
1272$(document).on('click','.calItem', function(e){
1273	if($(this).hasClass('calItemTask')) return
1274
1275	if(window.permissionOnCurrentlySelectedSchedule == "owner"){hideTaskImportScreen()};
1276
1277	let eventID = $(this).data('id')
1278	window.lastClickedEvent = eventID
1279
1280	if($(e.target).is('.calItemDeleteX')){
1281		return;
1282	}
1283	let leftRailChild = $('#leftRail').children(0)
1284	
1285	// 11/5/24: deciding not to do this
1286	// //If the current left rail is showing the event associated with the calItem, close the left rail and clear the tasks. 
1287	// if(leftRailChild.attr('id') == 'infoColumn' && leftRailChild.attr('data-id') == eventID) {
1288	// 	returnToLastLeftRailState()
1289	// 	resetTasksFromRightRail()
1290	// 	return;
1291	// }
1292
1293    openEventDetailViewForEvent(eventID)
1294});
1295
1296$(document).on('click', '.calItemTask', function(e) {
1297	if ($(e.target).is('.calItemDeleteX')) return
1298	if ($(e.target).closest('.calendarTaskCheckbox').length) return
1299	let taskId = $(this).attr('data-id')
1300
1301	// If the allDayEvent list is showing and this task isn't in it, dismiss it and show class search
1302	if (!window.isPWA && $('#leftRail').find('#allDayEventContainer').length) {
1303		if (!$(`.allDayItem[data-id="${taskId}"][data-item-type="task"]`).length) {
1304			performActionForVerticalNavItemClick('verticalNavCoursesButton')
1305		}
1306	}
1307
1308	openTaskDetailView(taskId)
1309})
1310
1311$(document).on('click', '.calendarTaskCheckbox', function(e) {
1312	e.stopPropagation()
1313	if (displayWallIfNeededAndReturnBlockingStatus('completedTasks')) return
1314
1315	// Get taskId based on context (calItem vs allDay)
1316	let taskId
1317	if ($(this).hasClass('allDayTaskCheckbox')) {
1318		taskId = $(this).closest('.allDayEvent').attr('data-task-ids')
1319	} else {
1320		taskId = $(this).closest('.calItemTask').attr('data-id')
1321	}
1322	if (!taskId) return
1323	let tasks = getTasksData()
1324	let task = tasks.tasks[taskId]
1325	if (!task) return
1326
1327	const isCompleting = task.completed === null
1328	if (isCompleting) {
1329		markTaskAsComplete(task)
1330	} else {
1331		markTaskAsIncomplete(task)
1332	}
1333	setTasksData(tasks)
1334	$(document).trigger('taskToggledFromCalendar', { taskId, isCompleting })
1335	window.repaintCalendar()
1336	if (window.lastClickedTask?.id == taskId) {
1337		window.lastClickedTask = task
1338		refreshTaskDetailCompletion(task)
1339	}
1340	clearTimeout(window.calItemTaskCompletionTimeout)
1341	window.calItemTaskCompletionTimeout = setTimeout(() => {
1342		window.sendUsersTasksToServer()
1343	}, 1000)
1344})
1345
1346function openEventDetailViewForEvent(eventID){
1347
1348	window.inTaskDetailView = false
1349	var event = getEventByID(eventID)
1350
1351	renderDetailView(eventID, event["type"]);
1352
1353	if(window.isPWA){
1354		renderSlidableModal($('#infoColumn'), true);
1355	}else{
1356		openInLeftRailView($('#infoColumn'))
1357	}
1358
1359	// we don't want to filter the tasks on pwa because they can't see 
1360	// the task view or easily unfilter it. 
1361	if (!window.isPWA && window.permissionOnCurrentlySelectedSchedule == "owner"){
1362		filterTasksForEventId(eventID)
1363	}
1364}
1365
1366// Function to remove the Tippy instance when needed
1367function removeEvtDeleteConfirmationTippy() {
1368    if (window.evtDeleteTippyInstance) {
1369        window.evtDeleteTippyInstance.destroy();
1370        window.evtDeleteTippyInstance = null;
1371    }
1372}
1373
1374function resetCalItemsAfterDeleteConfirmation() {
1375	$('.calItem').removeClass('calItemDeleteXShown').css('box-shadow', 'none').addClass('calItemDeleteXUnshown');
1376	$('.calItemName').css('max-width', '')
1377}
1378
1379// two confirmations can be open on a calItem: the plain Delete/Cancel one below, and the syllabus one
1380// a scanned class gets. The latter wears the profile menu's markup, so it's only counted inside the
1381// calendar's own popper — the profile menu's unlink confirmation uses that same class.
1382function calItemDeleteConfirmationIsOpen() {
1383	return $('.evtDeleteConfirmationOptions, #deleteCalItemTippy .unlinkConfirmationOptions').length > 0
1384}
1385
1386//Much of the following code is stolen from schedule maker.
1387//when the user moves to delete an event by clicking the x button on the calitem
1388function showEvtDeleteConfirmationTippy(target, eventID) {
1389    // a scanned class is confirmed with the syllabus prompt instead, since deleting it takes the
1390    // scan's events and tasks with it
1391    let evtBeingDeleted = getEventByID(parseInt(eventID))
1392    if (evtBeingDeleted && evtBeingDeleted['hasLinkedSyllabus']) {
1393        promptForSyllabusChoice(target, eventID, {
1394            deleteClass: true,
1395            placement: "right-start",
1396            popperID: 'deleteCalItemTippy',
1397            onHide: resetCalItemsAfterDeleteConfirmation
1398        })
1399        return
1400    }
1401
1402    // Create the tippy instance
1403    window.evtDeleteTippyInstance = tippy(target, {
1404        content() {
1405            return `<div class="evtDeleteConfirmationOptions"><div class="deleteEvtConfirm" data-id=${eventID}>Delete</div><div class="deleteEvtDeny">Cancel</div></div>`;
1406        },
1407        allowHTML: true,
1408        theme: 'deleteCalItemConfirmation',
1409        trigger: 'manual',
1410        interactive: true,
1411        hideOnClick: true,
1412        placement: "right-start",
1413        arrow: false,
1414		onCreate(instance) {
1415			instance.popper.id = 'deleteCalItemTippy';
1416		  },
1417        // Handle hiding and check if the user clicked the target to dismiss the tooltip
1418        onHide() {
1419			//when the tippy is hidden, so too should the x button be hidden.
1420            resetCalItemsAfterDeleteConfirmation()
1421        }
1422    });
1423
1424    // Show the tooltip
1425    window.evtDeleteTippyInstance.show();
1426}
1427
1428function showTaskCalItemDeleteConfirmationTippy(target, taskId) {
1429    let instance = tippy(target, {
1430        content() {
1431            return `<div class="evtDeleteConfirmationOptions"><div class="deleteTaskCalItemConfirm" data-taskid="${taskId}">Delete</div><div class="deleteEvtDeny">Cancel</div></div>`;
1432        },
1433        allowHTML: true,
1434        theme: 'deleteCalItemConfirmation',
1435        trigger: 'manual',
1436        interactive: true,
1437        hideOnClick: true,
1438        placement: "right-start",
1439        arrow: false,
1440        onCreate(instance) {
1441            instance.popper.id = 'deleteCalItemTippy';
1442        },
1443        onHide() {
1444            resetCalItemsAfterDeleteConfirmation()
1445        }
1446    });
1447    instance.show();
1448}
1449
1450$(document).on("click", ".deleteTaskCalItemConfirm", function() {
1451    let taskId = $(this).attr('data-taskid')
1452    $(`.calItem[data-id="${taskId}"]`).fadeOut(150)
1453    tippy.hideAll()
1454    setTimeout(function() {
1455        deleteThisTask(parseInt(taskId))
1456        window.repaintCalendar()
1457    }, 150)
1458})
1459
1460//when you click the buttons, get rid of the tippy.
1461//As I understand it this doesn't destroy the actual instance, which may prove necessary.
1462$(document).on("click",".deleteEvtConfirm, .deleteTaskCalItemConfirm, .deleteEvtDeny",function() {
1463	tippy.hideAll()
1464});
1465
1466$(document).on("click",".deleteEvtConfirm",function() {
1467	if($(this).attr('data-id') == 'previewKlass'){
1468		//there is no actual event to remove if its just a preview.
1469		//so instead just take it off the calendar.
1470		$('.calItem[data-id="previewKlass"]').fadeOut(150);
1471	}else{
1472		relevantId = $(this).attr('data-id')
1473		calItemFadeAndDelete(relevantId)
1474	}
1475})
1476
1477$(document).on("click",".calItemDeleteX",function() {
1478
1479	//if there is a tippy, keep it simple and just dismiss the old tippy
1480	//and launch nothing on for the new one. This prevents undesired results like
1481	//double tippy, or double hover, etc.
1482
1483	if(calItemDeleteConfirmationIsOpen()){
1484		return
1485	}
1486
1487	let calItem = $(this).closest('.calItem')
1488	let id = calItem.attr('data-id')
1489	if (calItem.hasClass('calItemTask')) {
1490		showTaskCalItemDeleteConfirmationTippy(calItem[0], id)
1491	} else {
1492		showEvtDeleteConfirmationTippy(calItem[0], id)
1493	}
1494});
1495
1496//when you hover over cal items we want to apply a shadow
1497$(document).on('mouseenter', '.calItem', function() {
1498	let blockColor = ($(this).css('background-color'))
1499	//get id attached to the cal item
1500	if(isPWA){return} // no show on pwa
1501	relevantId = $(this).attr('data-id')
1502	allBlocksForThisId = $(`.calItem[data-id=${relevantId}]`)
1503
1504	//only do the shadow for light mode, and not for completed tasks.
1505	if(!window.isPWA && !window.darkMode && !$(this).hasClass('taskCompleted')){
1506		allBlocksForThisId.css('box-shadow', '0 0 3px rgba(30, 30, 30, .75)')
1507	}
1508	allBlocksForThisId.addClass('calItemDeleteXShown').removeClass('calItemDeleteXUnshown');
1509	requestAnimationFrame(() => {
1510		allBlocksForThisId.each(function() {
1511			const nameEl = $(this).find('.calItemName')[0]
1512			const xEl = $(this).find('.calItemDeleteX')[0]
1513			if (!nameEl || !xEl) return
1514			const checkboxEl = $(this).find('.calendarTaskCheckbox')[0]
1515			if (checkboxEl) {
1516				const checkboxRect = checkboxEl.getBoundingClientRect()
1517				const xRect = xEl.getBoundingClientRect()
1518				if (xRect.left < checkboxRect.right) {
1519					xEl.style.display = 'none'
1520					return
1521				}
1522			}
1523			const maxWidth = xEl.getBoundingClientRect().left - nameEl.getBoundingClientRect().left - 4
1524			nameEl.style.maxWidth = maxWidth + 'px'
1525		})
1526	})
1527}).on('mouseleave', '.calItem', function() {
1528	if(!calItemDeleteConfirmationIsOpen()){
1529		resetCalItemsAfterDeleteConfirmation()
1530		$('.calItemDeleteX').css('display', '')
1531	}
1532	allBlocksForThisId = $()
1533	if(window.darkMode){
1534		$('.calItem').css('box-shadow', 'none')
1535	}
1536});
1537
1538function dayToNumber(day) {
1539	let days = {
1540		"Su": 0,
1541		"M": 1,
1542		"Tu": 2,
1543		"W": 3,
1544		"Th": 4,
1545		"F": 5,
1546		"Sa": 6
1547	}
1548	return days[day];
1549}
1550
1551function areEventsOnSameDay(event1, event2) {
1552    return event1.getFullYear() === event2.getFullYear() &&
1553           event1.getMonth() === event2.getMonth() &&
1554           event1.getDate() === event2.getDate();
1555}
1556
1557function dateDiffInDays(a, b) {
1558	const utc1 = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());
1559	const utc2 = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());
1560	return Math.floor((utc2 - utc1) / (24 * 60 * 60 * 1000)); // One day in milliseconds
1561}
1562
1563function isRepetitionDay(startDate, arbitraryDate, repeatType, repeatGap) {
1564    // Clone startDate and arbitraryDate to ignore time of day
1565    var start = new Date(startDate)
1566	start.setHours(0,0,0)
1567    var arbitrary = new Date(arbitraryDate)
1568	arbitrary.setHours(0,0,0)
1569	const daysDiff = dateDiffInDays(start, arbitrary)
1570	repeatGap = repeatGap ? repeatGap : 0
1571
1572    switch (repeatType) {
1573        case 'daily':
1574            const dailyIncrement = repeatGap + 1
1575            return daysDiff % dailyIncrement === 0;
1576
1577        case 'weekly':            
1578            const weeklyIncrement = (repeatGap + 1) * 7
1579            return daysDiff % weeklyIncrement === 0;
1580
1581        case 'biweekly':
1582            const biweeklyIncrement = (repeatGap + 1) * 14;
1583            return daysDiff % biweeklyIncrement === 0;
1584
1585        case 'monthly':           
1586            const monthsDiff = (arbitrary.getFullYear() - start.getFullYear()) * 12 + (arbitrary.getMonth() - start.getMonth());
1587
1588            return monthsDiff > 0 && monthsDiff % (repeatGap +1) == 0 && start.getDate() == arbitrary.getDate()
1589
1590        case 'yearly':
1591            // Calculate years difference
1592            const yearsDiff = arbitrary.getFullYear() - start.getFullYear();
1593            isYearRepeat = yearsDiff % (repeatGap + 1) === 0 && start.getMonth() == arbitrary.getMonth() && start.getDate() == arbitrary.getDate() ;
1594
1595			return isYearRepeat
1596
1597	    case null:
1598		    return false;
1599
1600        default:
1601			//if we've reached here the class is likely TBA.
1602            return false; // Invalid repeatType
1603    }
1604}
1605
1606
1607
1608function getEventTimeRanges(dateObj, daysOccurring) {
1609    const dayOfWeek = dateObj.getDay(); // 0 (Sunday) to 6 (Saturday)
1610    const dayAbbreviations = ['Su', 'M', 'Tu', 'W', 'Th', 'F', 'Sa'];
1611    const dayAbbreviation = dayAbbreviations[dayOfWeek];
1612    
1613    // Access the array of time ranges for the corresponding day abbreviation
1614    const timeRanges = daysOccurring[dayAbbreviation] || [];
1615    
1616    return timeRanges;
1617}
1618
1619function findNonEmptyWeekdays(daysOccurring) {
1620    // Define weekdays in order
1621    const weekdays = ['Su', 'M', 'Tu', 'W', 'Th', 'F', 'Sa'];
1622
1623	// Function to check if a given day has events
1624    function hasEvents(day) {
1625        return daysOccurring[day] && daysOccurring[day].length > 0;
1626    }
1627
1628    // Array to store non-empty weekdays
1629    const nonEmptyWeekdays = [];
1630
1631    // Iterate through weekdays and check if they have events
1632    weekdays.forEach(day => {
1633        if (hasEvents(day)) {
1634            nonEmptyWeekdays.push(day);
1635        }
1636    });
1637
1638    return nonEmptyWeekdays; // Return array of non-empty weekdays
1639}
1640
1641
1642const weekdayRanking = {
1643	"M" : 1,
1644	"Tu": 2,
1645	"W": 3,
1646	"Th": 4,
1647	"F": 5,
1648	"Sa": 6,
1649	"Su": 7
1650}
1651
1652function getStartTimeOfFirstNonemptyWeekday(occurrenceData){
1653	let daysOccurring = occurrenceData.daysOccurring
1654	if(!daysOccurring){
1655		//alert('no occurrencedata')
1656		return null
1657	}
1658	let nonemptyWeekdays = findNonEmptyWeekdays(daysOccurring)
1659	let minimum = 8
1660	let minimalDay = null
1661	
1662	nonemptyWeekdays.forEach(day => {
1663		if(weekdayRanking[day] < minimum){
1664			minimum = weekdayRanking[day]
1665			minimalDay = day
1666		}
1667	}) 
1668	let relevantStartEndPair = daysOccurring[minimalDay][0]
1669	return {startTime: relevantStartEndPair.startTime, endTime: relevantStartEndPair.endTime}
1670}
1671
1672
1673function findEarliestWeekdayInstance(weekday, startDate, endDate) {
1674	var startDate = new Date(startDate * 1000)
1675	endDate = endDate ? new Date(endDate * 1000) : null	
1676	startDate.setHours(0,0,0)
1677
1678    var endDate = endDate ? new Date(endDate) : null
1679	if(endDate){
1680		endDate.setHours(0,0,0)
1681	}
1682    const weekdays = ['Su', 'M', 'Tu', 'W', 'Th', 'F', 'Sa'];
1683    if (!weekdays.includes(weekday)) {
1684        throw new Error('Invalid weekday provided');
1685    }
1686
1687    // Calculate the index of the target weekday
1688    const targetIndex = weekdays.indexOf(weekday);
1689
1690    // Start checking from the startDate
1691    let currentDate = new Date(startDate);
1692
1693    // Move currentDate to the first occurrence of target weekday after startDate
1694    currentDate.setDate(startDate.getDate() + (targetIndex - startDate.getDay() + 7) % 7);
1695
1696    // Check if the calculated date is within the range and return it
1697    if (currentDate >= startDate && (currentDate <= endDate || !endDate)) {
1698        //console.log("current date: ",currentDate)
1699        return currentDate;
1700    }
1701
1702    return null; // Return null if no valid occurrence found within the range
1703}
1704
1705
1706
1707
1708function dayOfMonthMatch(firstOccurrence, dateToCheck) {
1709    return firstOccurrence.getDate() === dateToCheck.getDate();
1710}
1711
1712function monthOfYearMatch(firstOccurrence, dateToCheck) {
1713    return firstOccurrence.getMonth() === dateToCheck.getMonth();
1714}
1715
1716
1717// TODO WHAT ARE THESEEEEE
1718function pushToPaintThese(event, dateToCheck){
1719	if(event.occurrenceData.allDay){
1720		paintThese.push([dateToCheck, 0, 1439, event.eventID])
1721	}else if(event.occurrenceData.startTime || event.occurrenceData.endTime){
1722        paintThese.push([dateToCheck, event.occurrenceData.startTime, event.occurrenceData.endTime, event.eventID])
1723	}else if(event.occurrenceData.daysOccurring && getEventTimeRanges(dateToCheck, event.occurrenceData.daysOccurring).length > 0){
1724        let occurrencesForDay = getEventTimeRanges(dateToCheck, event.occurrenceData.daysOccurring)
1725		occurrencesForDay.forEach(occurrence => {paintThese.push([dateToCheck, occurrence.startTime, occurrence.endTime, event.eventID])})
1726	}
1727
1728}
1729
1730function assignOverlapsAndIndex(events) {
1731    // Filter out events where start is 0 and end is 1439
1732
1733	//we want both types of events, but we don't want to subject allday events to overlap calculation
1734	allDayEvents = events.filter(([date, start, end, eventId]) => (start === 0 && end === 1439));
1735	events = events.filter(([date, start, end, eventId]) => !(start === 0 && end === 1439));
1736    // Sort events by start time
1737    events.sort((a, b) => a[1] - b[1]);
1738
1739    // Initialize arrays to track overlaps and indices
1740    const overlaps = new Array(events.length).fill(0);
1741    const groupIndex = new Array(events.length).fill(0);
1742
1743    // Function to recursively mark all indirectly overlapping events
1744    function markOverlaps(eventIndex, currentOverlapIndex) {
1745        if (overlaps[eventIndex] !== 0) return; // Already processed
1746
1747        overlaps[eventIndex] = currentOverlapIndex;
1748
1749        // Check overlaps with all other events
1750        for (let i = 0; i < events.length; i++) {
1751            if (i !== eventIndex && overlaps[i] === 0) {
1752                const [, start_i, end_i] = events[eventIndex];
1753                const [, start_j, end_j] = events[i];
1754
1755                // Check if there is an overlap
1756                if (start_i < end_j && start_j < end_i) {
1757                    markOverlaps(i, currentOverlapIndex);
1758                }
1759            }
1760        }
1761    }
1762
1763    // Mark overlaps for each event
1764    let currentOverlapIndex = 1;
1765    for (let i = 0; i < events.length; i++) {
1766        if (overlaps[i] === 0) {
1767            markOverlaps(i, currentOverlapIndex);
1768            currentOverlapIndex++;
1769        }
1770    }
1771
1772    // Assign sequential indices within each group
1773    const groupCounts = {};
1774    overlaps.forEach((group, index) => {
1775        if (!groupCounts[group]) {
1776            groupCounts[group] = 0;
1777        }
1778        groupCounts[group]++;
1779        groupIndex[index] = groupCounts[group];
1780    });
1781
1782    // Create result objects with overlaps and indices; add the all day events back in
1783    const result = events.concat(allDayEvents).map((event, index) => ({
1784        date: event[0],
1785        start: event[1],
1786        end: event[2],
1787        eventId: event[3],
1788        itemType: event[4] || 'event',
1789        overlaps: groupCounts[overlaps[index]], // Total overlaps for the group
1790        index: groupIndex[index]               // Index within the group
1791    }));
1792
1793    return result;
1794}
1795
1796
1797
1798function getEventsToPaint(data,dateToCheck,scheduleId, preview=false){
1799    paintThese = []
1800
1801	//we don't set equal to data.schedules.... directly because when we go to push "previewKlass" below, it will modify window.getUsersSchedulesAndEvents but
1802	//not store.get('schedulesAndEvents')
1803    const events = [].concat(data.schedules.find(schedule => schedule.id === scheduleId)?.events)
1804	
1805	if(preview == true){
1806		events.push("previewKlass")
1807	}
1808    if (!events) {
1809        console.error(`Schedule with ID ${scheduleId} not found.`);
1810        return;
1811    }
1812
1813    events.forEach(eventId => {
1814		var event
1815		if(eventId == "previewKlass" || eventId == "addKlass"){
1816			event = window.currentPreviewKlass
1817		}else{
1818			event = data.events[eventId];
1819		}
1820		// This was from very early on, and I think was an unnecessary assumption
1821		// if(event.occurrenceData.starts != event.occurrenceData.ends){
1822
1823		//For preview painting, we are setting the occurrence data starts to be Date.now(), which has the consequence of not painting event previews that happen 
1824		//on the same day or in the past. So we are going to ignore checking the starts date if we are doing preview painting as a bandaid.
1825		if (eventId != "previewKlass") {
1826			//out of time bound - left
1827			if(event.occurrenceData.starts!=null && new Date(dateToCheck).setHours(23,59,59,999) < (new Date(event.occurrenceData.starts*1000))){
1828				return;
1829			}
1830		}
1831		//out of time bound - right
1832		if(event.occurrenceData.ends!=null && new Date(dateToCheck).setHours(0,0,0,0) >= (new Date(event.occurrenceData.ends*1000))){
1833			return;
1834		}
1835
1836        if(event.occurrenceData.daysOccurring){
1837
1838            
1839            let nonEmptyDays = findNonEmptyWeekdays(event.occurrenceData.daysOccurring)
1840            nonEmptyDays.forEach(weekday => {
1841                let firstOccurrence = findEarliestWeekdayInstance(weekday, event.occurrenceData.starts, event.occurrenceData.ends ? event.occurrenceData.ends : null)
1842                if(isRepetitionDay(firstOccurrence, dateToCheck, "weekly", 0)){
1843                    pushToPaintThese(event, dateToCheck)
1844                }else if(event.occurrenceData.repeats == null && areEventsOnSameDay(firstOccurrence, dateToCheck)){
1845                    pushToPaintThese(event, dateToCheck)
1846                }else{
1847                    //console.log('failed to catch')
1848                }
1849
1850            })
1851        }
1852        else{
1853
1854            //just pick the start date as the first occurrence
1855            let firstOccurrence = new Date(1000*event.occurrenceData.starts)
1856            if (isRepetitionDay(firstOccurrence, dateToCheck, event.occurrenceData.repeats, event.occurrenceData.gapBetweenRepeats)){
1857				pushToPaintThese(event, dateToCheck)
1858            }
1859			else if(areEventsOnSameDay(firstOccurrence, dateToCheck)){
1860                pushToPaintThese(event, dateToCheck)
1861            }
1862            else{
1863                //console.log('failed to catch')
1864            }
1865        }
1866    })
1867    return paintThese
1868}
1869
1870// Returns raw paint tuples [date, startMinutes, endMinutes, taskId, 'task'] for
1871// all tasks with a due date falling on dateToCheck.
1872function getTasksRawPaintData(dateToCheck, tasksStore) {
1873	let result = []
1874	if (!tasksStore || !tasksStore.tasks) return result
1875
1876	let year = dateToCheck.getFullYear()
1877	let month = dateToCheck.getMonth()
1878	let day = dateToCheck.getDate()
1879
1880	Object.entries(tasksStore.tasks).forEach(([taskId, task]) => {
1881		if (!task.due || task.due === 'none') return
1882		let dueDate = new Date(task.due * 1000)
1883		if (dueDate.getFullYear() !== year || dueDate.getMonth() !== month || dueDate.getDate() !== day) return
1884		let dueMinutes = dueDate.getHours() * 60 + dueDate.getMinutes()
1885		let startMinutes, endMinutes
1886		if (dueMinutes < 30) {
1887			startMinutes = dueMinutes
1888			endMinutes = dueMinutes + 30
1889		} else {
1890			startMinutes = dueMinutes - 30
1891			endMinutes = dueMinutes
1892		}
1893		result.push([dueDate, startMinutes, endMinutes, taskId, 'task'])
1894	})
1895
1896	return result
1897}
1898
1899
1900function getFirstOccurrence(event){
1901	let minOccurrence = new Date('9999-12-31T23:59:59Z')
1902	let minOccurrenceStartTime
1903	let minOccurrenceEndTime
1904	let occurrenceData = event.occurrenceData
1905	if(occurrenceData.daysOccurring){
1906		let nonEmptyDays = findNonEmptyWeekdays(occurrenceData.daysOccurring)
1907		nonEmptyDays.forEach(weekday => {
1908			let firstOccurrence = findEarliestWeekdayInstance(weekday, occurrenceData.starts, occurrenceData.ends ? occurrenceData.ends : null)
1909			if (firstOccurrence == null ) return null
1910			//We are setting the occurrenceData starts to be Date.now() in the case when we do previews so we need to set the firstOccurrence to the end of the day to win tiebreakers when 
1911			//findEarliestWeekdayInstance returns the same day as Date.now().
1912			let tempDate = new Date(firstOccurrence.getTime())
1913			tempDate.setHours(23,59,59)
1914			
1915			if (firstOccurrence.getTime() < minOccurrence.getTime() && tempDate.getTime() / 1000 >= occurrenceData.starts){				
1916				minOccurrence = firstOccurrence
1917				let weekdayStartEndPair = occurrenceData.daysOccurring[weekday][0]
1918				minOccurrenceStartTime = weekdayStartEndPair.startTime
1919				minOccurrenceEndTime = weekdayStartEndPair.endTime
1920			}
1921		})
1922	}else{
1923		minOccurrence = new Date(1000*occurrenceData.starts)
1924		minOccurrenceStartTime = occurrenceData.startTime
1925		minOccurrenceEndTime = occurrenceData.endTime
1926	}
1927
1928	//looks good so now we need to populate the min date as today and scroll to.
1929	return {"minOccurrence": minOccurrence, "minOccurrenceStartTime": minOccurrenceStartTime, "minOccurrenceEndTime": minOccurrenceEndTime}
1930}
1931
1932function setDateFromMinutes(date, totalMinutes) {
1933    // Calculate total hours and remaining minutes
1934    const hours = Math.floor(totalMinutes / 60);
1935    const minutes = totalMinutes % 60;
1936
1937    // Set the hours and minutes on the date object
1938    date.setHours(hours);
1939    date.setMinutes(minutes);
1940
1941    return date;
1942}
1943
1944function nextOccurrenceAfterTimeToUnixSeconds(nextOccurrenceObj) {
1945    // Extracting the minOccurrence and minOccurrenceStartTime
1946    const minOccurrence = new Date(nextOccurrenceObj.minOccurrence);
1947    const minOccurrenceStartTime = nextOccurrenceObj.minOccurrenceStartTime; // in minutes
1948
1949	//using date object set minutes to prevent daylight savings issues
1950	minOccurrence.setMinutes(minOccurrenceStartTime)
1951
1952	//return time in seconds
1953	return Math.floor(minOccurrence.getTime()/1000)
1954}
1955
1956//non-task-due-date context
1957function getNextOccurrenceAfterTime(eventObj, timeInUnixSeconds){
1958
1959	let event = { ...eventObj } //have to make a copy because of what happens in case 3.1
1960	let eventOccurrenceData = {...eventObj.occurrenceData}	//js is bad about deep cloning
1961	event.occurrenceData = eventOccurrenceData
1962	let occurrenceData = event.occurrenceData
1963	let starts = occurrenceData.starts
1964	let ends = occurrenceData.ends
1965	let daysOccurring = occurrenceData.daysOccurring
1966	let repeats = occurrenceData.repeats
1967	let repeatGap = occurrenceData.repeatGap
1968
1969	//CASE 1 - last occurrence of the event is in the past
1970		if(ends && ends < timeInUnixSeconds){
1971			return getFirstOccurrence(event)
1972		}
1973
1974	// CASE 2 -  first occurrence of the event is in the future
1975		if(timeInUnixSeconds < starts){
1976			return getFirstOccurrence(event)
1977		}
1978
1979	//CASE 3 - starts <= timeInUnixSeconds and/or ends >= timeInUnixSeconds, i.e. timeInUnixSeconds is in some part of the event's time range.
1980	
1981		//case 3.1 - daysOccurring is set - classes and some imported events as of 11/19/2025
1982			if(daysOccurring){
1983				//If the event occurs in the same day as the time provided in timeInUnixSeconds, we need to check if the
1984				//time given is before or after the starts time of that event. If it is after or equal to the starts, we need to return the next event,
1985				//if it is before, we can return the event that happens that day.
1986				let shouldJumpADay = inSameDayAndAfterDaysOccurringStartTime(eventObj, timeInUnixSeconds)
1987				if (shouldJumpADay) {
1988					//add a day
1989					timeInUnixSeconds += 86400
1990				}
1991				//This should give the first weekday in the occurrenceData after unixTimeInSeconds
1992				event.occurrenceData.starts = timeInUnixSeconds
1993				return getFirstOccurrence(event)
1994			}
1995
1996		// case 3.2 - daysOccurring unset
1997
1998			//case 3.2.1 - repeating event
1999
2000				if(repeats != null){
2001					//event repeats
2002
2003					let firstOccurrence = getFirstOccurrence(event)
2004					let currentMinimalTime = setDateFromMinutes(firstOccurrence['minOccurrence'], firstOccurrence['minOccurrenceStartTime'])
2005					while (currentMinimalTime.getTime() / 1000 <= timeInUnixSeconds){
2006
2007						currentMinimalTime = incrementEventRepetition(currentMinimalTime, repeats, repeatGap) 
2008					}
2009					currentMinimalTime.setHours(0,0,0)					
2010					return {"minOccurrence": currentMinimalTime, "minOccurrenceStartTime" : occurrenceData['startTime']}
2011				}
2012
2013			//case 3.2.2 - one time event
2014				let minOccurrenceDate = new Date(starts * 1000)
2015				minOccurrenceDate.setHours(0,0,0,0)
2016				return {"minOccurrence": minOccurrenceDate , "minOccurrenceStartTime" : occurrenceData['startTime']}
2017
2018}
2019//helper function to determine whether or not the the time provided in timeInUnixSeconds shares a common weekday with the 
2020//occurrences in eventObj and if so if timeInUnixSeconds is after or equal to the start time of the occurrence.
2021function inSameDayAndAfterDaysOccurringStartTime(eventObj, timeInUnixSeconds) {
2022	//check that timeInUnixSeconds is inside the starts and ends of the event
2023	if (timeInUnixSeconds < eventObj.occurrenceData.starts || timeInUnixSeconds > eventObj.occurrenceData.ends) {
2024		return false
2025	}
2026
2027	const weekdays = ['Su', 'M', 'Tu', 'W', 'Th', 'F', 'Sa'];
2028	let afterClassStarts = false
2029	let timeInUnixSecondsDate = new Date(timeInUnixSeconds * 1000)
2030	let timeInUnixSecondsDateWeekday = weekdays[timeInUnixSecondsDate.getDay()]
2031	let dataForEventOnDay = eventObj.occurrenceData.daysOccurring[timeInUnixSecondsDateWeekday]
2032	
2033	if (dataForEventOnDay.length > 0) {
2034		//compare the time of day of timeInUnixSeconds with the startTime of the class. Convert to seconds since startTime is in seconds
2035		let secondsIntoTheDay = (timeInUnixSecondsDate.getHours() * 3600) + (timeInUnixSecondsDate.getMinutes() * 60) + timeInUnixSecondsDate.getSeconds()		
2036		if (secondsIntoTheDay >= dataForEventOnDay[0].startTime * 60) {
2037			afterClassStarts = true
2038		}
2039	}
2040	return afterClassStarts
2041}
2042
2043
2044function incrementEventRepetition(dateObj, repeatType, repeatGap = 0) {
2045    switch (repeatType) {
2046        case 'daily':
2047            return addDayIncrementHelper(dateObj, repeatGap);
2048            break;
2049        case 'weekly':
2050            return addWeekIncrementHelper(dateObj, repeatGap);
2051            break;
2052        case 'biweekly':
2053            return addBiWeekIncrementHelper(dateObj, repeatGap);
2054            break;
2055        case 'monthly':
2056            return addMonthIncrementHelper(dateObj, repeatGap);
2057            break;
2058        case 'yearly':
2059            return addYearIncrementHelper(dateObj, repeatGap);
2060            break;
2061        default:
2062            throw new Error('Invalid repeat type');
2063    }
2064}
2065
2066
2067function addDayIncrementHelper(dateObj, repeatGap = 0) {
2068    const daysToAdd = 1 + repeatGap; // 1 day plus repeatGap days
2069    dateObj.setDate(dateObj.getDate() + daysToAdd);
2070    return dateObj
2071}
2072
2073function addWeekIncrementHelper(dateObj, repeatGap = 0) {
2074    const weeksToAdd = 1 + repeatGap; // 1 week plus repeatGap weeks
2075    dateObj.setDate(dateObj.getDate() + weeksToAdd * 7);
2076    return dateObj
2077}
2078
2079function addBiWeekIncrementHelper(dateObj, repeatGap = 0) {
2080    const biWeeksToAdd = 1 + repeatGap; // 1 bi-week plus repeatGap bi-weeks
2081    dateObj.setDate(dateObj.getDate() + biWeeksToAdd * 14);
2082    return dateObj
2083}
2084
2085function addMonthIncrementHelper(dateObj, repeatGap = 0) {
2086    const monthsToAdd = 1 + repeatGap; // 1 month plus repeatGap months
2087    dateObj.setMonth(dateObj.getMonth() + monthsToAdd);
2088    return dateObj
2089}
2090
2091function addYearIncrementHelper(dateObj, repeatGap = 0) {
2092    const yearsToAdd = 1 + repeatGap; // 1 year plus repeatGap years
2093    dateObj.setFullYear(dateObj.getFullYear() + yearsToAdd);
2094    return dateObj
2095}
2096
2097
2098
2099/*
2100 This class is used when we create new columns. 
2101 We take the jquerytime that is created. We then iterate through our added classes and then we match the class to the column and append
2102*/
2103function addEventBlocksToDayColumn(column, eventIDs, preview = false) {
2104	if (!eventIDs) return
2105
2106	let schedulesAndEvents = window.getUsersSchedulesAndEvents()
2107	if (schedulesAndEvents == null) return // this happens when the user first ever comes to the site and hovers over a class prior to the getUsersEvents.php callback, which happens a TON
2108
2109	let year = parseInt(column.attr("data-year"))
2110	let month = parseInt(column.attr("data-month"))
2111	let day = parseInt(column.attr("data-date"))
2112	let columnDateObject = new Date(year, month, day)
2113
2114	let rawPaintData = getEventsToPaint(schedulesAndEvents, columnDateObject, getCurrentlySelectedScheduleID(), preview)
2115	let tasksStore = getTasksData()
2116	if (window.webAppRole != "viewer" && (store.get('userData')?.paintTasksOnCalendar ?? true)) {
2117		rawPaintData = rawPaintData.concat(getTasksRawPaintData(columnDateObject, tasksStore))
2118	}
2119	let itemsForDay = assignOverlapsAndIndex(rawPaintData)
2120	let calItemDocumentFragment = $(document.createDocumentFragment())
2121	let allDayItems = []
2122
2123	itemsForDay.forEach(itemInfo => {
2124		if (itemInfo.itemType === 'task') {
2125			let task = tasksStore?.tasks?.[itemInfo.eventId]
2126			if (!task) return
2127			calItemDocumentFragment.append(renderCalItem(task, 'task', itemInfo.start, itemInfo.end, itemInfo.overlaps, itemInfo.index, day, month, year))
2128			// Collect for the all-day block (batched below)
2129			let taskColor = task.associatedEventColor || PRESET_COLORS[0]
2130			allDayItems.push({ title: task.name, color: taskColor, id: task.id, itemType: 'task' })
2131		} else {
2132			let event = (itemInfo.eventId == 'previewKlass' || itemInfo.eventId == 'addKlass')
2133				? window.currentPreviewKlass
2134				: getEventByID(itemInfo.eventId)
2135			if (event.occurrenceData.allDay == 1) {
2136				allDayItems.push({ title: event.title, color: event.color, id: event.eventID, itemType: 'event' })
2137			} else {
2138				calItemDocumentFragment.append(renderCalItem(event, 'event', itemInfo.start, itemInfo.end, itemInfo.overlaps, itemInfo.index, day, month, year))
2139			}
2140		}
2141	})
2142
2143	// Build the all-day block once with all collected items
2144	column.find('.allDayEvent').remove()
2145	if (allDayItems.length) {
2146		handleAllDayCourses(column, allDayItems, tasksStore)
2147	}
2148
2149	calItemDocumentFragment.appendTo(column)
2150	// Read every background colour, then write. brightenText reads a computed style and then writes
2151	// colours, so per-item it forces a style recalc for every block on the column.
2152	const calItemsToBrighten = column.find(".calItem")
2153	const calItemBackgroundColors = calItemsToBrighten.map(function(){ return $(this).css('background-color') }).get()
2154	calItemsToBrighten.each(function(index){ brightenText($(this), true, calItemBackgroundColors[index]) })
2155	// Wrap/truncate the title and show the time and location only where they fit
2156	fitAllCalItemContent(column)
2157	const currentTheme = window.coursicleCurrentTheme || localStorage.getItem('customTheme') || 'classic'
2158	column.find('.calItemTask.taskCompleted').each(function() {
2159		if (currentTheme !== 'classic') {
2160			// Themed: keep normal colors — set calItemName explicitly so CSS gray can't override it
2161			const nameColor = isDark($(this).css('background-color')) ? themeColor('--white') : themeColor('--gray-3')
2162			$(this).find('.calItemName').css('color', nameColor)
2163			return
2164		}
2165		// Classic: fade the text so the task reads as done. timeMarkerOnCalItem() used to do this
2166		// once a second; completion is state rather than geometry, so it belongs with the paint.
2167		// The line here before used children(), which matched nothing -- these elements sit inside
2168		// .calItemTaskContent -- so the .taskCompleted CSS it meant to uncover never applied, and
2169		// brightenText's inline colour won. That is why the tick was doing the visible work.
2170		if(window.darkMode || document.documentElement.classList.contains('themeSampleActive')){ return }
2171		const completedBackground = $(this).css('background-color')
2172		$(this).find('.calItemName, .calendarTaskCheckbox').css('color', dynamicPastTextColor(completedBackground))
2173		$(this).find('.calItemTime, .calItemLocation, .calItemDeleteX').css('color', dynamicPastSubTextColor(completedBackground))
2174	})
2175	// Set checkbox border color based on mode and completion state
2176	column.find('.calItemTask').each(function() {
2177		const eventColor = $(this).attr('data-event-color')
2178		if (!eventColor) return
2179		const isCompleted = $(this).hasClass('taskCompleted')
2180		const isThemed = currentTheme !== 'classic'
2181		let checkboxColor
2182		if (isThemed) {
2183			// Themed: dynamicTextColor at 50% opacity
2184			checkboxColor = hexToRgbaAt50(dynamicTextColor(eventColor))
2185		} else if (window.darkMode) {
2186			// Classic dark: event color at 50% opacity
2187			checkboxColor = hexToRgbaAt50(darkReadableColor(eventColor))
2188		} else if (isCompleted) {
2189			// Classic light completed: dynamicPastTextColor at 50% opacity
2190			checkboxColor = hexToRgbaAt50(dynamicPastTextColor(eventColor))
2191		} else {
2192			// Classic light incomplete: dynamicSubTextColor at 50% opacity
2193			checkboxColor = hexToRgbaAt50(dynamicSubTextColor(eventColor))
2194		}
2195		$(this).find('.calendarTaskCheckbox').css('color', checkboxColor)
2196	})
2197	fitAllTaskContent(column)
2198
2199	// Whether a day is already over is date arithmetic, not geometry, so it does not need the time
2200	// marker's position and should not wait for its next tick. Without this, scrolling a past column
2201	// into view left it unfaded for up to a second. timeMarkerOnCalItem() still re-applies it.
2202	if(isBeforeToday(column.data('date'), column.data('month'), column.data('year'))){
2203		fadePastCalItems(column.find('.calItem:not(.calItemTask)'))
2204	}
2205}
2206
2207
2208// Fit each task block's due time and title into the room it has. `root` is where to search from:
2209// pass a column to do just that column's tasks, or nothing to do every task on the calendar. It
2210// searches descendants only, so root is always a container and never a .calItemTask itself.
2211function fitAllTaskContent(root) {
2212	const $root = $(root || document)
2213
2214	// Undo the previous fit before measuring anything. All three passes below are destructive --
2215	// they rewrite text, hide elements and append a title -- so without this a block that was
2216	// narrow stays truncated after the column grows back, and running twice appends two titles.
2217	// resetCalItemText stashes the original text on its first call and restores it on every one
2218	// after. Clearing the inline display hands the element back to the stylesheet, which hides the
2219	// due time above a 1050px viewport -- so the passes below only do anything on a narrow window.
2220	$root.find('.calItemTask').toArray().forEach(function(item){
2221		const timeEl = item.querySelector('.calItemTime')
2222		const contentEl = item.querySelector('.calItemTaskContent')
2223		if(timeEl){
2224			resetCalItemText(timeEl)
2225			timeEl.style.display = timeEl.style.marginLeft = timeEl.style.width = ''
2226		}
2227		if(contentEl){ contentEl.style.display = contentEl.style.overflow = '' }
2228		item.querySelectorAll('.calItemNameBelow').forEach(function(el){ el.remove() })
2229		item.style.flexWrap = ''
2230		resetCalItemText(item.querySelector('.calItemName'))
2231	})
2232
2233	// Three passes over the tasks, each dealing with something that doesn't fit:
2234	//   1. the due time overruns its box       -> shorten "Due" to "@", or shift it left, or hide it
2235	//   2. the title has almost no width left  -> move it below the checkbox, across the full block
2236	//   3. the title is still too wide         -> cut it and append '..'
2237	// Measuring a task and then changing it forces the browser to lay the page out again before the
2238	// next one can be measured, so measurements come in batches instead: once here, covering passes
2239	// 1 and 2, and again inside pass 3. Pass 3 has to measure separately because pass 2 moves the
2240	// title into a different parent at a different width. Passes 1 and 2 can share a batch because
2241	// pass 1 only ever changes the due time's own box, and the title takes its width from the
2242	// container rather than from its siblings. Keep this order.
2243	const taskItems = $root.find('.calItemTask').toArray()
2244
2245	// reads for passes 1 and 2, no writes in between
2246	const taskMetrics = taskItems.map(function(item){
2247		const timeEl = item.querySelector('.calItemTime')
2248		const nameEl = item.querySelector('.calItemName')
2249		const metrics = {
2250			item: item, timeEl: timeEl, nameEl: nameEl,
2251			contentEl: item.querySelector('.calItemTaskContent'),
2252			checkboxEl: item.querySelector('.calendarTaskCheckbox')
2253		}
2254		if(timeEl){
2255			metrics.timeScrollWidth = timeEl.scrollWidth
2256			metrics.timeClientWidth = timeEl.clientWidth
2257			metrics.timeOverflows = metrics.timeScrollWidth > metrics.timeClientWidth
2258			if(metrics.timeOverflows){
2259				const itemRect = item.getBoundingClientRect()
2260				const alignLeft = metrics.checkboxEl
2261					? metrics.checkboxEl.getBoundingClientRect().left - itemRect.left
2262					: (metrics.contentEl ? metrics.contentEl.getBoundingClientRect().left - itemRect.left : 0)
2263				const contentLeft = metrics.contentEl
2264					? metrics.contentEl.getBoundingClientRect().left - itemRect.left
2265					: 0
2266				metrics.offset = contentLeft - alignLeft
2267				metrics.availableWidth = itemRect.width - alignLeft
2268				metrics.timeText = timeEl.textContent
2269				metrics.timeWidthOf = makeCalItemTextMeasurer(window.getComputedStyle(timeEl))
2270			}
2271		}
2272		if(nameEl){
2273			metrics.nameOverflows = nameEl.scrollWidth > nameEl.clientWidth
2274			if(metrics.nameOverflows){
2275				metrics.nameClientWidth = nameEl.clientWidth
2276				metrics.nameFontSize = parseFloat(window.getComputedStyle(nameEl).fontSize)
2277			}
2278		}
2279		return metrics
2280	})
2281
2282	// PASS 1 writes. If the time string overruns its box, replace "Due" with "@", and if it still
2283	// doesn't fit, either shift it left under the checkbox indent or hide it. The first comparison
2284	// uses the batched read above; measuring the rewritten string uses the canvas, so deciding
2285	// whether "@" helped costs no layout.
2286	taskMetrics.forEach(function(metrics){
2287		if(!metrics.timeEl || !metrics.timeOverflows){ return }
2288		const applyShift = function(){
2289			if(metrics.contentEl){ metrics.contentEl.style.overflow = 'visible' }
2290			metrics.timeEl.style.marginLeft = `-${metrics.offset}px`
2291			metrics.timeEl.style.width = `calc(100% + ${metrics.offset}px)`
2292		}
2293		// fits the full calItem width — it only overflowed because of the checkbox indent
2294		if(metrics.timeScrollWidth <= metrics.availableWidth){ applyShift(); return }
2295		const shortened = metrics.timeText.replace('Due', '@')
2296		metrics.timeEl.textContent = shortened
2297		// scrollWidth includes padding and border, the canvas measures text alone. The gap between
2298		// the two for the ORIGINAL text is the non-text part, so add it back to compare like for like.
2299		const nonTextWidth = metrics.timeScrollWidth - metrics.timeWidthOf(metrics.timeText)
2300		const shortenedWidth = metrics.timeWidthOf(shortened) + nonTextWidth
2301		if(shortenedWidth > metrics.timeClientWidth){
2302			// style.display, not .hide(): jQuery reads the computed display first, which would put a
2303			// forced read back into a pass that is meant to only write. The reset at the top of this
2304			// function clears the property again, so nothing has to restore it.
2305			if(shortenedWidth <= metrics.availableWidth){ applyShift() }
2306			else { metrics.timeEl.style.display = 'none' }
2307		}
2308	})
2309
2310	// PASS 2 writes. If the title is squeezed to under two visible characters, drop it below the
2311	// checkbox on the calItem's full width so more of it is readable.
2312	taskMetrics.forEach(function(metrics){
2313		if(!metrics.nameEl || !metrics.nameOverflows){ return }
2314		const approxVisibleChars = Math.floor(metrics.nameClientWidth / (metrics.nameFontSize * 0.6))
2315		if(approxVisibleChars >= 2){ return }
2316		if(metrics.contentEl){ metrics.contentEl.style.display = 'none' }
2317		$(`<div class="calItemName calItemNameBelow">${metrics.nameEl.textContent}</div>`).appendTo($(metrics.item))
2318		$(metrics.item).css({'flex-wrap': 'wrap'})
2319	})
2320
2321	// PASS 3: truncate overflowing names with '..' (text-overflow only offers the single-character
2322	// ellipsis). Measuring on the canvas forces no layout, and read, write and verify are each their
2323	// own pass, so the whole set costs about two forced layouts rather than one per name per
2324	// character removed.
2325	const truncations = []
2326	$root.find('.calItemTask .calItemName').toArray().forEach(function(el){
2327		if(el.scrollWidth <= el.clientWidth){ return }
2328		const style = window.getComputedStyle(el)
2329		truncations.push({
2330			el: el,
2331			fullText: el.textContent,
2332			availableWidth: el.clientWidth - (parseFloat(style.paddingLeft) || 0) - (parseFloat(style.paddingRight) || 0),
2333			widthOf: makeCalItemTextMeasurer(style)
2334		})
2335	})
2336	truncations.forEach(function(truncation){
2337		let low = 1, high = truncation.fullText.length - 1, longestThatFits = 1
2338		while(low <= high){
2339			const mid = (low + high) >> 1
2340			if(truncation.widthOf(truncation.fullText.slice(0, mid).trimEnd() + '..') <= truncation.availableWidth){ longestThatFits = mid; low = mid + 1 }
2341			else { high = mid - 1 }
2342		}
2343		truncation.longestThatFits = longestThatFits
2344		truncation.el.textContent = truncation.fullText.slice(0, longestThatFits).trimEnd() + '..'
2345	})
2346	// Canvas metrics can disagree with real layout (ligatures, hinting, or the canvas resolving a
2347	// different font than the page), and an overflowing name is visible. Checking every name in one
2348	// read pass costs a single layout; only a name that actually disagrees pays to walk back.
2349	// The walk-back steps one character at a time rather than a fixed amount because a font that
2350	// resolved differently is wrong by a percentage of the string, not by a rounding error. It
2351	// always terminates: longestThatFits decreases every iteration and stops at 1.
2352	truncations.filter(function(truncation){
2353		return truncation.el.scrollWidth > truncation.el.clientWidth
2354	}).forEach(function(truncation){
2355		while(truncation.longestThatFits > 1 && truncation.el.scrollWidth > truncation.el.clientWidth){
2356			truncation.longestThatFits--
2357			truncation.el.textContent = truncation.fullText.slice(0, truncation.longestThatFits).trimEnd() + '..'
2358		}
2359	})
2360}
2361
2362
2363function handleAllDayCourses(column, items, tasksStore) {
2364	let eventIDs = []
2365	let taskIDs = []
2366
2367	items.forEach(item => {
2368		let idStr = String(item.id)
2369		if (item.itemType === 'task') {
2370			if (!taskIDs.includes(idStr)) taskIDs.push(idStr)
2371		} else {
2372			if (!eventIDs.includes(idStr)) eventIDs.push(idStr)
2373		}
2374	})
2375
2376	let fontSize = '12px'
2377	let allDayHeight = ''
2378	let width = $('.dayColumn').first().get(0).getBoundingClientRect().width - (window.timeMarkerPaddingRequirement)
2379
2380	let totalCount = eventIDs.length + taskIDs.length
2381
2382	// Determine fading: pure events fade if past; blocks with tasks fade only if all tasks are complete
2383	let isPast = isBeforeToday(parseInt(column.attr('data-date')), parseInt(column.attr('data-month')), parseInt(column.attr('data-year')))
2384	let shouldFade
2385	if (taskIDs.length === 0) {
2386		shouldFade = isPast
2387	} else {
2388		let allTasksComplete = taskIDs.every(id => {
2389			let task = tasksStore?.tasks?.[id]
2390			return task && task.completed !== null
2391		})
2392		if (eventIDs.length === 0) {
2393			shouldFade = allTasksComplete
2394		} else {
2395			shouldFade = isPast && allTasksComplete
2396		}
2397	}
2398	const currentTheme = window.coursicleCurrentTheme || localStorage.getItem('customTheme') || 'classic'
2399	let opacityStyle = (shouldFade && !window.darkMode && currentTheme === 'classic') ? 'opacity: 0.4;' : ''
2400
2401	if (totalCount > 1) {
2402		let label
2403		if (eventIDs.length > 0 && taskIDs.length === 0) {
2404			label = `${totalCount} Events`
2405		} else if (taskIDs.length > 0 && eventIDs.length === 0) {
2406			let completedCount = taskIDs.filter(id => tasksStore?.tasks?.[id]?.completed !== null).length
2407			if (completedCount === 0) {
2408				label = `${totalCount} Tasks`
2409			} else if (completedCount === totalCount) {
2410				label = `${totalCount} Tasks Done`
2411			} else {
2412				label = `${completedCount}/${totalCount} Tasks Done`
2413			}
2414		} else {
2415			label = `${totalCount} Events`
2416		}
2417
2418		column.find('.columnDateTextContainer').append(`<div class='allDayEvent' data-event-ids="${eventIDs.join(',')}" data-task-ids="${taskIDs.join(',')}" style='color: var(--all-day-multi-text); background-color: var(--all-day-multi-background, ${window.darkMode ? themeColor('--coursicle-blue-1-complement') : themeColor('--el-color-147')}); width: ${width}px; font-size: ${fontSize}; ${allDayHeight} ${opacityStyle}'>${label}</div>`)
2419	} else {
2420		// Single item — show its title and color
2421		let item = items[0]
2422		let color = mapClassicColorToThemeColor(item.color)
2423		let idStr = String(item.id)
2424		const textColorStyle = window.darkMode && currentTheme === 'classic' ? `color: ${color};` : ''
2425		let dataEventIds = item.itemType === 'event' ? idStr : ''
2426		let dataTaskIds = item.itemType === 'task' ? idStr : ''
2427		let checkboxHtml = ''
2428		let completedClass = ''
2429		let bgColor = generateDarkModeConsciousCalendarColor(color)
2430
2431		if (item.itemType === 'task') {
2432			let task = tasksStore?.tasks?.[idStr]
2433			let isCompleted = task && task.completed !== null
2434			if (isCompleted) {
2435				completedClass = 'taskCompleted'
2436			}
2437			const isThemed = currentTheme !== 'classic'
2438			let checkboxColor
2439			if (isThemed) {
2440				checkboxColor = hexToRgbaAt50(dynamicTextColor(color))
2441			} else if (window.darkMode) {
2442				checkboxColor = hexToRgbaAt50(darkReadableColor(color))
2443			} else if (isCompleted) {
2444				checkboxColor = hexToRgbaAt50(dynamicPastTextColor(color))
2445			} else {
2446				checkboxColor = hexToRgbaAt50(dynamicSubTextColor(color))
2447			}
2448			checkboxHtml = generateTaskCheckboxHtml(isCompleted, 'allDayTaskCheckbox')
2449			checkboxHtml = checkboxHtml.replace("class='calendarTaskCheckbox", `style='color:${checkboxColor}' class='calendarTaskCheckbox`)
2450		}
2451
2452		column.find('.columnDateTextContainer').append(`<div class='allDayEvent ${completedClass}' data-event-ids="${dataEventIds}" data-task-ids="${dataTaskIds}" data-event-color="${color}" style='${textColorStyle} background-color: ${bgColor}; width: ${width}px; font-size: ${fontSize}; ${allDayHeight} ${opacityStyle} ${item.itemType === 'task' ? 'justify-content: flex-start; column-gap: 3px;' : ''}'>${checkboxHtml}<span class="allDayTaskTitle">${item.title}</span></div>`)
2453	}
2454	// Adjust text color based on background darkness (like calItems)
2455	const allDayEventsToBrighten = column.find('.allDayEvent')
2456	const allDayBackgroundColors = allDayEventsToBrighten.map(function(){ return $(this).css('background-color') }).get()
2457	allDayEventsToBrighten.each(function(index){ brightenText($(this), false, allDayBackgroundColors[index]) });
2458	column.find('.columnDateTextContainer').addClass('noColumnDateTextContainerPadding')
2459}
2460
2461function getAllDayTaskSubtext(task) {
2462	if (task.completed !== null) {
2463		return 'Completed ' + prettyDate(new Date(task.completed * 1000).toLocaleString(), false)
2464	} else if (task.due && task.due !== 'none') {
2465		const dueDate = new Date(task.due * 1000)
2466		return `Due ${convertMinutesToTimeString(dueDate.getHours() * 60 + dueDate.getMinutes())}`
2467	}
2468	return ''
2469}
2470
2471function populateAllDayList(eventIds, taskIds) {
2472	if (window.isPWA) {
2473		$("#allDayEventContainer").empty()
2474		eventIds.forEach(id => {
2475			let evt = getEventByID(id)
2476			if (!evt) return
2477			$("#allDayEventContainer").append(`
2478				<div class="allDayItem" data-id="${id}" data-item-type="event">
2479					<div class='allDayColorBlock' style="background-color: ${generateSaturatedDarkModeConsciousColor(mapClassicColorToThemeColor(evt.color))};"></div>
2480					<div class='allDayTitle'>${evt.title}</div>
2481				</div>
2482			`)
2483		})
2484		taskIds.forEach(id => {
2485			let task = getTasksData()?.tasks?.[id]
2486			if (!task) return
2487			let color = generateSaturatedDarkModeConsciousColor(mapClassicColorToThemeColor(task.associatedEventColor || PRESET_COLORS[0]))
2488			let isCompleted = task.completed !== null
2489			let strikeStyle = isCompleted ? 'text-decoration: line-through;' : ''
2490			let subtext = getAllDayTaskSubtext(task)
2491			let subtextHtml = subtext ? `<div class='allDaySubtext'>${subtext}</div>` : ''
2492			$("#allDayEventContainer").append(`
2493				<div class="allDayItem" data-id="${id}" data-item-type="task">
2494					<div class='allDayColorBlock' style="background-color: ${color};"></div>
2495					<div class='allDayItemText'>
2496						<div class='allDayTitle' style='${strikeStyle}'>${task.name}</div>
2497						${subtextHtml}
2498					</div>
2499				</div>
2500			`)
2501		})
2502	} else {
2503		$("#allDayEventsList").empty()
2504		eventIds.forEach(id => {
2505			let evt = getEventByID(id)
2506			if (!evt) return
2507			$("#allDayEventsList").append(`
2508				<div class="allDayItem" data-id="${id}" data-item-type="event">
2509					<div class='allDayColorBlock' style="background-color: ${generateSaturatedDarkModeConsciousColor(mapClassicColorToThemeColor(evt.color))};"></div>
2510					<div class='allDayTitle'>${evt.title}</div>
2511				</div>
2512			`)
2513		})
2514		taskIds.forEach(id => {
2515			let task = getTasksData()?.tasks?.[id]
2516			if (!task) return
2517			let color = generateSaturatedDarkModeConsciousColor(mapClassicColorToThemeColor(task.associatedEventColor || PRESET_COLORS[0]))
2518			let isCompleted = task.completed !== null
2519			let strikeStyle = isCompleted ? 'text-decoration: line-through;' : ''
2520			let subtext = getAllDayTaskSubtext(task)
2521			let subtextHtml = subtext ? `<div class='allDaySubtext'>${subtext}</div>` : ''
2522			$("#allDayEventsList").append(`
2523				<div class="allDayItem" data-id="${id}" data-item-type="task">
2524					<div class='allDayColorBlock' style="background-color: ${color};"></div>
2525					<div class='allDayItemText'>
2526						<div class='allDayTitle' style='${strikeStyle}'>${task.name}</div>
2527						${subtextHtml}
2528					</div>
2529				</div>
2530			`)
2531		})
2532	}
2533}
2534
2535function refreshAllDayEventList() {
2536	let isListVisible = window.isPWA
2537		? $('#allDayEventModal').is(':visible')
2538		: $('#allDayEventContainer').is(':visible')
2539	if (!isListVisible || !window.allDayListDate) return
2540
2541	let column = $(`.dayColumn[data-ISOdate="${window.allDayListDate}"]`)
2542	let allDayEl = column.find('.allDayEvent')
2543	if (!allDayEl.length) {
2544		// No more all-day items for this date, close the list
2545		window.allDayListDate = null
2546		if (window.isPWA) {
2547			hideSlidableModal()
2548		} else {
2549			returnToLastLeftRailState()
2550		}
2551		return
2552	}
2553
2554	let eventIds = String(allDayEl.attr('data-event-ids') || '').split(',').filter(Boolean)
2555	let taskIds = String(allDayEl.attr('data-task-ids') || '').split(',').filter(Boolean)
2556	let totalCount = eventIds.length + taskIds.length
2557
2558	if (totalCount <= 1) {
2559		// Down to one or zero items, close the list
2560		window.allDayListDate = null
2561		if (window.isPWA) {
2562			hideSlidableModal()
2563		} else {
2564			returnToLastLeftRailState()
2565		}
2566		return
2567	}
2568
2569	populateAllDayList(eventIds, taskIds)
2570}
2571
2572$(document).on('click', '.allDayEvent, .allDayEventMultiple', function() {
2573	let eventIds = String($(this).attr('data-event-ids') || '').split(',').filter(
2573Boolean)
2574	let taskIds = String($(this).attr('data-task-ids') || '').split(',').filter(Boolean)
2575	let totalCount = eventIds.length + taskIds.length
2576
2577	// Store the date column for refreshing later
2578	window.allDayListDate = $(this).closest('.dayColumn').attr('data-ISOdate')
2579
2580	if (window.isPWA) {
2581		if (totalCount > 1) {
2582			populateAllDayList(eventIds, taskIds)
2583			renderSlidableModal($('#allDayEventModal'), true)
2584		} else if (eventIds.length === 1) {
2585			let id = eventIds[0]
2586			let evt = getEventByID(id)
2587			window.lastClickedEvent = id
2588			renderDetailView(id, evt["type"])
2589			renderSlidableModal($('#infoColumn'), true)
2590		} else if (taskIds.length === 1) {
2591			openTaskDetailView(taskIds[0])
2592		}
2593	} else {
2594		hideTaskImportScreen()
2595
2596		if (totalCount === 1 && eventIds.length === 1) {
2597			let id = eventIds[0]
2598			window.lastClickedEvent = id
2599			filterTasksForEventId(id)
2600			renderGeneralEventDetailView(id, "event")
2601			openInLeftRailView($('#infoColumn'))
2602		} else if (totalCount === 1 && taskIds.length === 1) {
2603			if ($('#leftRail').find('#allDayEventContainer').is(':visible')) {
2604				performActionForVerticalNavItemClick('verticalNavCoursesButton')
2605			}
2606			openTaskDetailView(taskIds[0])
2607		} else {
2608			populateAllDayList(eventIds, taskIds)
2609			openInLeftRailView($('#allDayEventContainer'))
2610		}
2611	}
2612});
2613
2614
2615$(document).on('click', '.allDayItem', function() {
2616	let id = $(this).attr('data-id')
2617	let itemType = $(this).attr('data-item-type')
2618
2619	if (itemType === 'task') {
2620		openTaskDetailView(id)
2621		return
2622	}
2623
2624	if (window.isPWA) {
2625		var currentClassSubjectName = $(this).find('.allDayTitle').text().replace(/[^a-zA-Z0-9\-_]/g, '');
2626		$('.'+currentClassSubjectName).css("box-shadow","0px 0px 3px rgba(30, 30, 30, .75)");
2627		var event = getEventByID(id)
2628		window.lastClickedEvent = id
2629		renderDetailView(id, event["type"]);
2630		renderSlidableModal($('#infoColumn'), true);
2631	} else {
2632		window.lastClickedEvent = id
2633		filterTasksForEventId(id)
2634		renderGeneralEventDetailView(id, "event")
2635		openInLeftRailView($('#infoColumn'))
2636	}
2637});
2638
2639function refreshTBAClasses(preview = false) {
2640	
2641	// check all events of type class in selected schedule where there are no daysOccuring 
2642	// this happens on initial page load and a user is hovering over a TBA class
2643
2644
2645	// clear existing tba classes
2646	$(".tbaItem").remove()
2647	let allEvents = window.getUsersSchedulesAndEvents()?.events
2648	// schedule data isn't in local storage yet for new users / cleared storage — the getUsersEvents.php sync hasn't completed yet
2649	if(!allEvents) return;
2650	getActiveScheduleEventIDs().forEach(eventID => {
2651		let event = allEvents[eventID]
2652		if(event.occurrenceData.daysOccurring == null && event.type == "class" && event.semester == window.currentSemesterInViewport) {
2653			$("#tbaClassesContainer").append(`
2654				<div class="tbaItem" data-id="${event.eventID}">
2655					<div class='tbaColorBlock' style="background-color: ${generateSaturatedDarkModeConsciousColor(mapClassicColorToThemeColor(event.color))};"></div>
2656					<div class='tbaNameSubjectContainer'>
2657						<div class='tbaItemName'>${event.title}</div>
2658						<div class="tbaItemSubject">${event.subtitle}</div>
2659					</div>
2660				</div>
2661			`);
2662		}
2663	})
2664
2665	var tbaClassCount = $("#tbaClassesContainer").children().length
2666	if(preview){
2667		tbaClassCount += 1;
2668		$("#viewTBAButton").addClass("preview");
2669	}else{
2670		$("#viewTBAButton").removeClass("preview");
2671	}
2672	if (tbaClassCount > 0) {
2673		let newText = tbaClassCount == 1 ? "1 TBA class" : `${tbaClassCount} TBA classes`
2674		$("#viewTBAButton").text(newText);
2675		$('#viewTBAButton').css("display",'flex')
2676	}else{
2677		$('#viewTBAButton').css("display",'none')
2678	}
2679}
2680
2681$(document).on('click', '#viewTBAButton', function() {
2682	if (window.isPWA) {
2683		renderSlidableModal($('#tbaClassModal'), true);
2684
2685		//overriding the ~20px of top padding on usual slidable modals.
2686		$('.slidableModal:has(#tbaClassModal)').css({
2687			'padding': '0px'
2688		});
2689		
2690	} else {
2691		openInLeftRailView($('#tbaClassesContainer'))
2692	}
2693
2694});
2695
2696function hideTaskImportScreen(){
2697	$('#taskImportScreen').hide()
2698	$('#tasksMainContentHeader').css('display', 'flex')
2699	$('#filterIconBtn').show()
2700	$('#taskViewOptionContainerCenter').show()
2701}
2702
2703$(document).on('click','.tbaItem', function(e){
2704	let eventID = $(this).data('id')
2705	var event = getEventByID(eventID)
2706	window.lastClickedEvent = eventID
2707
2708	if (window.isPWA){
2709		// Save the class to look up
2710		var currentClassSubjectName = $(this).find('.tbaItemName').text().replace(/\s/g,'');
2711		$('.'+currentClassSubjectName).css("box-shadow","0px 0px 3px rgba(30, 30, 30, .75)");
2712		renderDetailView(eventID, event["type"]);
2713		renderSlidableModal($('#infoColumn'), true);
2714	} else {
2715		hideTaskImportScreen();
2716		filterTasksForEventId(eventID)
2717		renderClassDetailView(eventID, 'class')
2718		openInLeftRailView($('#infoColumn'))
2719	}
2720});
2721
2722
2723
2724function convertMinutesToTimeString(minutes) {
2725    // Calculate hours and minutes
2726    let hours = Math.floor(minutes / 60);
2727    let mins = minutes % 60;
2728
2729    // Determine am or pm
2730    let period = hours >= 12 ? 'pm' : 'am';
2731    
2732    // Adjust hours for 12-hour format
2733    if (hours === 0) {
2734        hours = 12; // Midnight case
2735    } else if (hours > 12) {
2736        hours -= 12; // Afternoon case
2737    }
2738
2739    // Return the formatted time string, omitting :00 when on the hour
2740    if (mins === 0) return `${hours}${period}`;
2741    let minsString = mins < 10 ? '0' + mins : mins;
2742    return `${hours}:${minsString}${period}`;
2743}
2744
2745function updatePixelsPerMinute() {
2746    window.pixelsPerMinuteForCalItems = ($('.hourLineWithinDayColumn').first().get(0).getBoundingClientRect().height) / 60 
2747}
2748
2749
2750// we will make this happen on days loaded and on window resize
2751function updatePixelPositionCalItemOffset() {
2752    // Get the elements
2753	const higherDiv = $('.dayColumn').first().get(0);
2754	const lowerDiv = $('.hourLineWithinDayColumn').first().get(0)
2755
2756	// Get the bounding rectangles of the divs
2757	const higherDivRect = higherDiv.getBoundingClientRect();
2758	const lowerDivRect = lowerDiv.getBoundingClientRect();
2759
2760// Calculate the vertical distance
2761	const offset = lowerDivRect.top - higherDivRect.top;
2762	window.pixelPositionCalItemOffset = offset
2763}
2764
2765function getPixelPositionFromMinutes(minutes){
2766	return window.pixelPositionCalItemOffset +  window.pixelsPerMinuteForCalItems * minutes
2767}
2768
2769function getMinutesFromPixelPosition(pixels){
2770	return (pixels) / window.pixelsPerMinuteForCalItems
2771}
2772
2773function returnRelativeScrollOffsetNeededToGetIntoViewport(hoursOutOf24Start, hoursOutOf24End, smallArbitraryOffset){
2774
2775	//earliest time visible on screen, in pixels since 12am.
2776    let twelveAMScrollOffset =(-1 * ($('.dayColumn').eq(window.firstVisible).children('.hourLineWithinDayColumn').first().get(0).getBoundingClientRect().y - $('.columnDateTextContainer').first().get(0).getBoundingClientRect().bottom))
2777
2778	//the amount of the calendar that is visible on screen
2779	let perceivedHeightOfContainer = window.innerHeight - $('.columnDateTextContainer').first().get(0).getBoundingClientRect().bottom
2780	
2781	//the latest time visible on screen, in pixels.
2782	let lastVisibleScrollOffset = ((twelveAMScrollOffset + perceivedHeightOfContainer))
2783
2784	//earliest time visible on screen, in hours
2785	let leftBound = getMinutesFromPixelPosition(twelveAMScrollOffset) / 60
2786
2787	//latest time visible on screen, in hours
2788	let rightBound = getMinutesFromPixelPosition(lastVisibleScrollOffset) / 60
2789
2790	function fallsInIntervalHelper(hoursOutOf24){
2791		return hoursOutOf24 >= leftBound && hoursOutOf24 <= rightBound
2792	}
2793
2794	//check whether the input falls in the interval.
2795
2796	//CASE I
2797
2798	if(fallsInIntervalHelper(hoursOutOf24Start) && !fallsInIntervalHelper(hoursOutOf24End)){
2799		//ends off screen
2800		return (window.pixelsPerMinuteForCalItems * (hoursOutOf24End - rightBound) * 60 + smallArbitraryOffset)
2801	}
2802
2803	//CASE II
2804
2805	if(fallsInIntervalHelper(hoursOutOf24Start) && fallsInIntervalHelper(hoursOutOf24End)){
2806		//already fully in view. Do nothing.
2807		return 0
2808	}
2809
2810	//CASE III
2811
2812	if(!fallsInIntervalHelper(hoursOutOf24Start) && fallsInIntervalHelper(hoursOutOf24End)){
2813		//starts off screen.
2814		return (-1 * window.pixelsPerMinuteForCalItems * (leftBound - hoursOutOf24Start) * 60 - smallArbitraryOffset)
2815	}
2816
2817	//CASE IV
2818	if(!fallsInIntervalHelper(hoursOutOf24Start) && !fallsInIntervalHelper(hoursOutOf24End)){
2819		//both off screen
2820
2821		//reality check: is this event larger than the perceived height of the calendar?
2822
2823		if(hoursOutOf24Start < leftBound && hoursOutOf24End > rightBound){
2824			//already in view. Do nothing.
2825			return 0
2826		}
2827
2828		//okay, so it's small but offscreen.
2829
2830		if(hoursOutOf24End < leftBound){
2831			//way above current viewport
2832			return (-1 * window.pixelsPerMinuteForCalItems * (leftBound - hoursOutOf24Start) * 60 - smallArbitraryOffset)
2833		}
2834
2835		if(hoursOutOf24Start > rightBound){
2836			//way below current viewport
2837			return (window.pixelsPerMinuteForCalItems * (hoursOutOf24End - rightBound) * 60 + smallArbitraryOffset)
2838		}
2839	}
2840}
2841
2842
2843function lightenColorHelper(hueValue, percent){
2844	return Math.min(255, hueValue + Math.round((255 - hueValue) * percent / 100))
2845}
2846
2847function lightenColorForUseOnPastEvents(color, percent) {
2848	//no lightening of past events for dark mode users.
2849	if(window.darkMode){
2850		return(color)
2851	}
2852	const {r, g, b, a} = parseColor(color);
2853
2854	const newR = lightenColorHelper(r, percent)
2855	const newG = lightenColorHelper(g, percent)
2856	const newB = lightenColorHelper(b, percent)
2857	
2858	if (a < 1) {
2859        return `rgba(${newR}, ${newG}, ${newB}, ${+a.toFixed(3)})`;
2860    } else {
2861        return `rgb(${newR}, ${newG}, ${newB})`;
2862    }
2863}
2864
2865
2866
2867// Width and left offset for one block, dividing the column between however many blocks overlap it
2868// at that time. The initial render and the resize path both call this and have to agree exactly, or
2869// blocks jump when the window changes.
2870function calculateCalItemWidthAndLeft(columnWidth, overlapCount, overlapLevel){
2871	const width = (columnWidth - window.timeMarkerPaddingRequirement) / overlapCount
2872	return { width: width, left: width * (overlapLevel - 1) + window.timeMarkerPaddingRequirement / 2 }
2873}
2874
2875function renderCalItem(item, itemType, startTime, endTime, overlapCount, overlapLevel, day, month, year) {
2876	const isTask = itemType === 'task'
2877
2878	// Derive item-specific display values
2879	const color    = mapClassicColorToThemeColor(isTask ? (item.associatedEventColor || PRESET_COLORS[0]) : (item.color || getNextColor()))
2880	const title    = isTask ? item.name : item.title
2881	const location = isTask ? null : item.location
2882	const id       = isTask ? item.id : item.eventID
2883	const isTBA    = !isTask && ('' + startTime === '')
2884
2885	// Layout
2886	const { width, left } = calculateCalItemWidthAndLeft(window.columnWidth, overlapCount, overlapLevel)
2887	const yPos   = getPixelPositionFromMinutes(startTime)
2888	const height = isTBA ? 60 * window.pixelsPerMinuteForCalItems : (endTime - startTime) * window.pixelsPerMinuteForCalItems
2889
2890	// Width class
2891	let widthClass
2892	if (overlapCount == 1)      widthClass = 'noOverlap'
2893	else if (overlapCount <= 5) widthClass = `overlapCount${overlapCount} hasOverlap`
2894	else                        widthClass = 'overlapCount6Plus hasOverlap'
2895
2896	// Colors — the block background is opaque in every mode; fadedColor only feeds
2897	// the light-mode past-event gradient, where both helpers agree anyway
2898	const baseColor  = generateDarkModeConsciousCalendarColor(color)
2899	const fadedColor = generateDarkModeConsciousColor(lightenColorForUseOnPastEvents(color, 40))
2900
2901	// Task state flags
2902	const isCompleted = isTask && item.completed !== null
2903	const isOverdue   = isTask && !isCompleted && item.due && (item.due * 1000 < Date.now())
2904
2905	const currentTheme = window.coursicleCurrentTheme || localStorage.getItem('customTheme') || 'classic'
2906
2907	// Type-specific CSS class and position style
2908	const typeClass = isTask ? 'calItem calItemTask' : (isTBA ? 'calItem calItemTBA' : 'calItem')
2909	let positionStyle
2910	if (isTBA) {
2911		positionStyle = 'position: static;'
2912	} else if (isTask && !isCompleted) {
2913		positionStyle = `top:${yPos}px; left:${left}px;`
2914	} else if (!window.darkMode && (isBeforeToday(day, month, year) || (isCompleted && currentTheme === 'classic'))) {
2915		positionStyle = `top:${yPos}px; left:${left}px; background-image: linear-gradient(to top, ${baseColor} 0%, ${fadedColor} 0% 100%, ${fadedColor});`
2916	} else if (!window.darkMode) {
2917		positionStyle = `top:${yPos}px; left:${left}px; background-image: linear-gradient(to top, ${fadedColor} 0%, ${baseColor} 0% 100%, ${baseColor});`
2918	} else {
2919		positionStyle = `top:${yPos}px; left:${left}px;`
2920	}
2921
2922	const xButton     = (!window.isPWA && window.webAppRole != 'viewer') ? '<i class="fa-solid fa-xmark calItemDeleteX"></i>' : ''
2923	const timeDisplay = isTask
2924		? `Due ${convertMinutesToTimeString(new Date(item.due * 1000).getHours() * 60 + new Date(item.due * 1000).getMinutes())}`
2925		: `${convertMinutesToTimeString(startTime)} - ${convertMinutesToTimeString(endTime)}`
2926
2927	const bgColor = baseColor
2928	const completedClass = isCompleted ? 'taskCompleted' : ''
2929
2930	// Theme-aware text color: classic dark mode uses event color, themed dark mode uses dark text
2931	let inlineTextColor = ''
2932	if (window.darkMode) {
2933		inlineTextColor = currentTheme === 'classic' ? `color: ${color};` : 'color: #333;'
2934	}
2935
2936	const innerContent = isTask ? `
2937		${generateTaskCheckboxHtml(isCompleted)}
2938		<div class='calItemTaskContent'>
2939			<div class='calItemName'>${title}</div>
2940			<div class='calItemTime'>${timeDisplay}</div>
2941			<div class='calItemLocation'>${location ?? ''}</div>
2942		</div>
2943	` : (() => {
2944		const duration = isTBA ? 60 : (endTime - startTime)
2945		// Title font size: ≤6min→6px, <10min→7-9px, 10-14min→9-12px, ≥15min→12px
2946		let titleFontSize, titleLineHeight
2947		if (duration <= 6) { titleFontSize = 6; titleLineHeight = 1 }
2948		else if (duration < 10) { titleFontSize = Math.max(7, Math.min(9, 7 + (duration - 5) * 2 / 5)); titleLineHeight = 1 }
2949		else if (duration < 15) { titleFontSize = Math.max(9, Math.min(12, 9 + (duration - 10) * 3 / 5)); titleLineHeight = null }
2950		else { titleFontSize = null; titleLineHeight = null }
2951		const nameStyle = titleFontSize ? ` style='font-size:${titleFontSize}px;${titleLineHeight ? ` line-height:${titleLineHeight};` : ''}${duration <= 6 ? ' margin-top:-1px;' : ''}'` : ''
2952		// Which of these actually get shown, and how many lines each gets, is decided
2953		// from the block's real geometry in fitAllCalItemContent() once it's in the DOM.
2954		return `
2955		<div class='calItemName'${nameStyle}>${title}</div>
2956		<div class='calItemTime'>${timeDisplay}</div>
2957		<div class='calItemLocation'>${location ?? ''}</div>
2958	`})()
2959
2960	// Vertical padding scales from 0px (≤5min) to 5px (≥60min)
2961	const duration = isTBA ? 60 : (endTime - startTime)
2962	const vPad = duration < 10 ? 1 : Math.max(0, Math.min(5, (duration - 5) * 5 / 55))
2963	// Border radius: >22min→8px, ≤22min→6px, <15min→3px, <10min→2px
2964	let borderRadius
2965	if (duration > 22) borderRadius = 8
2966	else if (duration >= 15) borderRadius = 6
2967	else if (duration >= 10) borderRadius = 3
2968	else borderRadius = 2
2969
2970	// Events get matching bottom padding so text never kisses the bottom edge (as on iOS).
2971	// Tasks keep their existing single-sided padding.
2972	const padding = isTask ? `padding-top:${vPad}px;` : `padding-top:${vPad}px; padding-bottom:${vPad}px;`
2973
2974	return `
2975		<div class='${typeClass} ${widthClass} ${completedClass}' style='${positionStyle} height:${height}px; max-width:${width}px; min-width:${width}px; font-size:2vw; background-color:${bgColor}; ${padding} border-radius:${borderRadius}px; ${inlineTextColor}'
2976			data-professor-rating="${item.professorRating ?? ''}" data-id="${id}" data-overlap-count="${overlapCount}" data-overlap-level="${overlapLevel}" data-tba="${isTBA}" data-startTime="${startTime}" data-endTime="${endTime}" data-event-color="${color}">
2977			${xButton}
2978			${innerContent}
2979		</div>
2980	`
2981}
2982
2983//
2984// Fitting an event block's title / time / location into the space it actually has.
2985//
2986// This mirrors the iOS layout in DayCell.drawClassBlock: the title gets as many
2987// lines as vertically fit (holding one line back for the time), and the time and
2988// location are only drawn if there's still room underneath. Everything is measured
2989// off real geometry rather than the event's duration, so it stays correct at any
2990// zoom level, column width, or #-day view.
2991//
2992
2993// Measure text with a canvas so we can size a truncation without touching the DOM.
2994function measureCalItemTextWidth(text, font) {
2995	const ctx = measureCalItemTextWidth.ctx || (measureCalItemTextWidth.ctx = document.createElement('canvas').getContext('2d'))
2996	ctx.font = font
2997	return ctx.measureText(text).width
2998}
2999
3000function calItemFontShorthand(computedStyle) {
3001	return `${computedStyle.fontStyle} ${computedStyle.fontWeight} ${computedStyle.fontSize} ${computedStyle.fontFamily}`
3002}
3003
3004// line-height resolves to px for our cal item text, but fall back to measuring a
3005// real line if a theme ever leaves it as 'normal'.
3006function getCalItemLineHeight(computedStyle) {
3007	const lineHeight = parseFloat(computedStyle.lineHeight)
3008	if (!isNaN(lineHeight)) return lineHeight
3009	const cacheKey = calItemFontShorthand(computedStyle)
3010	getCalItemLineHeight.cache = getCalItemLineHeight.cache || {}
3011	if (getCalItemLineHeight.cache[cacheKey]) return getCalItemLineHeight.cache[cacheKey]
3012	const probe = getCalItemLineHeight.probe || (getCalItemLineHeight.probe = $('<div>').css({
3013		position: 'absolute', visibility: 'hidden', whiteSpace: 'nowrap', top: '-9999px', left: '-9999px'
3014	}).text('Mg').appendTo(document.body)[0])
3015	probe.style.font = cacheKey
3016	return (getCalItemLineHeight.cache[cacheKey] = probe.getBoundingClientRect().height)
3017}
3018
3019// Single-line truncation, matching adjustLabelTextForSmallSpace on iOS: normally
3020// trim to fit and append '..', but when the column is so narrow that the dots would
3021// be all you see, show the first 2-3 characters bare instead.
3022// fontShorthand is optional: pass one when the caller already read the computed style.
3023function truncateCalItemTextToWidth(el, availableWidth, fontShorthand) {
3024	const text = el.textContent
3025	if (!text) return
3026	// A non-positive width means we have no layout to fit into, not that the title has to
3027	// go — without this the 2-3 character branch below reads a width of 0 as "impossibly
3028	// narrow" and leaves you with 'MA'.
3029	if (!(availableWidth > 0)) return
3030	const font = fontShorthand || calItemFontShorthand(window.getComputedStyle(el))
3031	if (measureCalItemTextWidth(text, font) <= availableWidth) return
3032
3033	const ellipsisWidth = measureCalItemTextWidth('..', font)
3034	if (availableWidth < ellipsisWidth * 2.5) {
3035		el.textContent = text.slice(0, availableWidth < ellipsisWidth * 2 ? 2 : 3)
3036		return
3037	}
3038
3039	let truncated = text
3040	while (truncated.length > 1 && measureCalItemTextWidth(truncated.trimEnd() + '..', font) > availableWidth) {
3041		truncated = truncated.slice(0, -1)
3042	}
3043	el.textContent = truncated.trimEnd() + '..'
3044}
3045
3046// Restores the pristine string so the fit can be recomputed from scratch on resize.
3047function resetCalItemText(el) {
3048	if (!el) return
3049	if (el.dataset.fullText === undefined) el.dataset.fullText = el.textContent
3050	else el.textContent = el.dataset.fullText
3051}
3052
3053function applyCalItemLineClamp(el, lines, lineHeight) {
3054	if (lines <= 1) {
3055		el.style.display = 'block'
3056		el.style.whiteSpace = 'nowrap'
3057		el.style.textOverflow = ''
3058		el.style.webkitLineClamp = ''
3059		el.style.webkitBoxOrient = ''
3060	} else {
3061		el.style.display = '-webkit-box'
3062		el.style.whiteSpace = 'normal'
3063		// line-clamp adds its own ellipsis on the final line; text-overflow on top of
3064		// it puts one at the end of every overlong line, mid-word
3065		el.style.textOverflow = 'clip'
3066		el.style.webkitLineClamp = lines
3067		el.style.webkitBoxOrient = 'vertical'
3068	}
3069	// A flex container blockifies -webkit-box, and not every engine keeps line-clamp
3070	// working through that. The max-height holds the line budget regardless — worst
3071	// case an engine clips the overflowing line instead of ellipsizing it.
3072	el.style.maxHeight = (lines * lineHeight + 0.5) + 'px'
3073	el.style.overflow = 'hidden'
3074}
3075
3076// Pass a column to fit just the blocks that were painted into it; pass nothing to
3077// refit the whole calendar after a width or height change. Deliberately synchronous:
3078// deferring to requestAnimationFrame leaves the calendar laid out for the old
3079// geometry, and rAF doesn't run at all while the tab is in the background.
3080// Unlike fitAllTaskContent(), root may also be a single .calItem — hence the .filter() below,
3081// which .find() alone would miss since it only searches descendants.
3082function fitAllCalItemContent(root) {
3083	const $root = $(root || document)
3084	const items = $root.find('.calItem:not(.calItemTask)').add($root.filter('.calItem:not(.calItemTask)')).toArray()
3085	if(!items.length){ return }
3086
3087	// Fitting one block needs writes (restore the text, clamp it) and reads (clientHeight, line
3088	// heights, the height the title ended up taking). Done block by block that alternation makes the
3089	// browser lay out the page two or three times per block, which is the single most expensive thing
3090	// in a repaint on a busy calendar. Split into phases, the whole column costs three layouts total.
3091	// Phase order matters, including reading clientHeight BEFORE restoring the text.
3092	const fits = []
3093	items.forEach(function(calItem){
3094		const nameEl = calItem.querySelector('.calItemName')
3095		if(!nameEl){ return }
3096		fits.push({
3097			calItem: calItem,
3098			nameEl: nameEl,
3099			timeEl: calItem.querySelector('.calItemTime'),
3100			locationEl: calItem.querySelector('.calItemLocation')
3101		})
3102	})
3103
3104	// read: does this block have a layout box at all? A block inside a display:none ancestor
3105	// measures zero for everything, and fitting against zeros clamps the title to one line and cuts
3106	// it to two characters. Reachable because the PWA hides #scheduleTab while you are on another
3107	// tab and the theme picker sits on the Account tab, so picking a theme fits a calendar nobody
3108	// can see. Blocks that measure zero are skipped by every phase below and left as they were;
3109	// showElementsForTab() fits them when the schedule comes back on screen. Reading this before
3110	// the phase that restores text is what keeps an unmeasurable block from being cleared first.
3111	fits.forEach(function(fit){ fit.clientHeight = fit.calItem.clientHeight })
3112
3113	// write: restore full text, and clear any inline display so the next pass reads the sheet's answer
3114	fits.forEach(function(fit){
3115		if(!fit.clientHeight){ return }
3116		resetCalItemText(fit.nameEl)
3117		resetCalItemText(fit.locationEl)
3118		if(fit.locationEl && fit.locationEl.dataset.fullText){ fit.locationEl.style.display = '' }
3119	})
3120
3121	// read: every measurement as a primitive — a live computed-style object would re-force a recalc
3122	// when read again after the writes below
3123	fits.forEach(function(fit){
3124		if(!fit.clientHeight){ return }
3125		const itemStyle = window.getComputedStyle(fit.calItem)
3126		fit.usableHeight = fit.clientHeight - (parseFloat(itemStyle.paddingTop) || 0) - (parseFloat(itemStyle.paddingBottom) || 0)
3127		const nameStyle = window.getComputedStyle(fit.nameEl)
3128		fit.titleLineHeight = getCalItemLineHeight(nameStyle)
3129		fit.nameFont = calItemFontShorthand(nameStyle)
3130		fit.nameClientWidth = fit.nameEl.clientWidth
3131		fit.timeLineHeight = fit.timeEl ? getCalItemLineHeight(window.getComputedStyle(fit.timeEl)) : 0
3132		fit.locationLineHeight = fit.locationEl ? getCalItemLineHeight(window.getComputedStyle(fit.locationEl)) : 0
3133		// A stylesheet can rule the location out entirely (the PWA hides it on 5/7-day views)
3134		fit.hasLocation = !!(fit.locationEl && fit.locationEl.dataset.fullText)
3135			&& window.getComputedStyle(fit.locationEl).display !== 'none'
3136	})
3137
3138	// write: clamp the title, and truncate it when it only gets a single line.
3139	// Only hold a line back for the time if the block is tall enough to have both. Nothing is held
3140	// back for the location: as on iOS, the title gets first claim and the location takes the rest.
3141	fits.forEach(function(fit){
3142		if(!fit.clientHeight){ return }
3143		fit.timeCouldFit = !!fit.timeEl && fit.usableHeight >= fit.titleLineHeight + fit.timeLineHeight
3144		const reservedForTime = fit.timeCouldFit ? fit.timeLineHeight : 0
3145		fit.maxTitleLines = Math.max(1, Math.floor((fit.usableHeight - reservedForTime) / fit.titleLineHeight))
3146		applyCalItemLineClamp(fit.nameEl, fit.maxTitleLines, fit.titleLineHeight)
3147		if(fit.maxTitleLines === 1){ truncateCalItemTextToWidth(fit.nameEl, fit.nameClientWidth, fit.nameFont) }
3148	})
3149
3150	// read: what the title actually took, so a short title leaves room a long one wouldn't
3151	fits.forEach(function(fit){
3152		if(!fit.clientHeight){ return }
3153		fit.remainingHeight = fit.usableHeight - fit.nameEl.getBoundingClientRect().height
3154	})
3155
3156	// write: show or hide the time and location in whatever space is left
3157	fits.forEach(function(fit){
3158		if(!fit.clientHeight){ return }
3159		const showTime = fit.timeCouldFit && fit.remainingHeight >= fit.timeLineHeight
3160		const showLocation = showTime && fit.hasLocation && fit.remainingHeight >= fit.timeLineHeight + fit.locationLineHeight
3161		if(fit.timeEl){ fit.timeEl.style.display = showTime ? 'block' : 'none' }
3162		if(fit.locationEl){
3163			if(showLocation){
3164				// the location wraps into whatever is left under the time
3165				applyCalItemLineClamp(fit.locationEl, Math.max(1,
3166					Math.floor((fit.remainingHeight - fit.timeLineHeight) / fit.locationLineHeight)
3167				), fit.locationLineHeight)
3168			} else {
3169				fit.locationEl.style.display = 'none'
3170			}
3171		}
3172		// Short blocks that show nothing but a title center it vertically, as on iOS
3173		fit.calItem.style.justifyContent = (showTime || showLocation) ? '' : 'center'
3174	})
3175}
3176
3177function convertTimeMinuteToStr(timeAsInt, hour) {
3178    var minute = (timeAsInt - hour*60) + ''
3179    if (minute.length == 1) {
3180        minute = '0' + minute
3181    }
3182    return minute
3183}
3184
3185//
3186// Calendar Util Functions
3187//
3188
3189// Generate task checkbox HTML for calItems and allDay blocks
3190function generateTaskCheckboxHtml(isCompleted, extraClasses = '') {
3191	const completedClass = isCompleted ? 'calendarTaskCheckboxCompleted' : ''
3192	const checkMarkStyle = isCompleted ? '' : 'style="display:none;"'
3193	return `<div class='calendarTaskCheckbox ${completedClass} ${extraClasses}'><i class="fa-solid fa-check calendarTaskCheckMark" ${checkMarkStyle}></i></div>`
3194}
3195
3196// Return the currently selected schedule
3197// Where is this used?
3198function getCurrentScheduleName() {
3199	let name = 'Schedule 1'
3200	var schedules = window.getUsersSchedulesAndEvents().schedules;
3201    if (typeof schedules !== "undefined") {
3202        schedules.forEach(schedule => {
3203            if (schedule.scheduleIsCurrentlySelected) name = schedule.scheduleName
3204        })
3205    }
3206	return name
3207}
3208
3209
3210
3211
3212function getJumpToHourTarget(hour){
3213	hourWithOffset = hour - 1
3214	//scrolling to center vertically
3215	let hourLineHeight = $('.hourLineWithinDayColumn').first().height()
3216	let changeViewButtonsHeight = window.isPWA ? $('#changeViewButtons').get(0).getBoundingClientRect().height : 0
3217	let colDateTextContainer = $('.columnDateTextContainer').first().get(0)
3218	let headerHeight = colDateTextContainer.getBoundingClientRect().bottom
3219	
3220
3221	var scrollTopTarget
3222
3223	if(window.isPWA){
3224		scrollTopTarget = hour * hourLineHeight - 0.5*(window.innerHeight - changeViewButtonsHeight - headerHeight)
3225		return scrollTopTarget
3226	}
3227	
3228	scrollTopTarget = hour * hourLineHeight - 0.5*(window.innerHeight - headerHeight) 
3229
3230	return scrollTopTarget
3231}
3232
3233function jumpSoThatTimeMarkerIsInTopLeft(animation = false){
3234    //It seems important to pass new Date each time because jumpToDate modifies the date object
3235    var dateToJumpTo = new Date();
3236
3237    // 10/19/24: Joe deciding we try making it so that we center the day instead of putting it on the far left?
3238    // I think it looks nicer. But on mobile it doesn't quite look as good is the thing
3239    if(!isPWA){
3240    	let scheduleView = store.get('selectedDayViewCount')
3241    	if (scheduleView === '7day') {
3242			dateToJumpTo.setDate(dateToJumpTo.getDate() - 3);
3243		} else if (scheduleView === '5day') {
3244			dateToJumpTo.setDate(dateToJumpTo.getDate() - 2);
3245		} else if (scheduleView === '3day') {
3246			dateToJumpTo.setDate(dateToJumpTo.getDate() - 1);
3247		}
3248		// we don't do anything for 1day, obviously
3249    }
3250 
3251    let now = new Date()
3252    let hours = now.getHours()
3253    let minutes = now.getMinutes()
3254 
3255    let hourHeight = $('.hourLineWithinDayColumn').first().height()
3256 
3257    //scrolling to almost the top (leave ~2 hours)
3258    //the -2 corresponds to the subtracting of 2 hours.
3259	let verticalScrollTarget = (hours + minutes / 60 - 2) * hourHeight
3260    + parseInt($('.dayColumn').first().children('.hourLineWithinDayColumn').first().css('margin-top'))
3261	jumpToDate(dateToJumpTo, "left", animation, verticalScrollTarget)
3262 
3263}
3264	
3265
3266
3267
3268
3269function findFirstVisibleDayIndex(partial = false){
3270	//get the index of the leftmost column visible in the center view.
3271	let returnIndex = null
3272	let done = false
3273	$('.dayColumn').each(function(index){
3274		if(done){
3275			return
3276		}
3277		
3278		if(isElementVisibleInCenterView($(this), partial)){
3279			returnIndex = index
3280			done = true
3281		}
3282		
3283		
3284	})	
3285	return returnIndex
3286}
3287
3288function findFirstVisibleHourIndex(){
3289	let returnIndex = null
3290	let done = false
3291	//we don't want to iterate through all hourLineWithin.. elements, so we pick a day we know will be in view.
3292	let firstVisibleDay = $('.dayColumn').eq(findFirstVisibleDayIndex())
3293	firstVisibleDay.children('.hourLineWithinDayColumn').each(function(index){
3294		if(done){
3295			return
3296		}
3297		//check if fully in the center view
3298		if(isElementVisibleInCenterView($(this), false, true)){
3299			returnIndex = index
3300			done = true
3301		}
3302	})
3303	return returnIndex
3304}
3305
3306function userHasCourseSavedInRegistrationSemester(only = false){ 
3307	let schedulesAndEvents = getUsersSchedulesAndEvents()
3308	if(!schedulesAndEvents){
3309		return false
3310	}
3311
3312	let eventIDs = window.getEventIDsForCurrentlySelectedSchedule()
3313
3314	//we are checking whether a user's courses are ONLY saved in the registration semester.
3315	if(only){
3316		//we should here make sure to exclude events, which don't have semester attributes.
3317		return eventIDs.every(eventID => {
3318			let semester = getEventByID(eventID).semester
3319			if(!semester || getEventByID(eventID).semester === window.registrationSemester){
3320				return true
3321			}
3322			return false
3323		});
3324	}
3325	//we are checking whether a user has SOME courses saved in the registration semester.
3326	//we are collecting the schedule's events' semesters to see if any of them matches registration semester, with short circuiting
3327	return eventIDs.some(eventID => getEventByID(eventID).semester === window.registrationSemester);
3328}
3329
3330function showTodayButtonIfNeeded(e){
3331
3332	if(window.isPWA && window.currentTab != 'schedule'){
3333		return
3334	}
3335	
3336	//how to check whether we are 2+ weeks away?
3337	const today = new Date();
3338    const todayMonth = today.getMonth(); // Months are zero-based in JavaScript
3339    const todayDate = today.getDate();
3340    const todayYear = today.getFullYear();
3341	let inView = false
3342    $('.dayColumn').each(function(index) {
3343        // Get the data attributes for the current column
3344		
3345		if(isElementVisibleInCenterView($(this))){
3346
3347			const dataMonth = $(this).data('month');
3348			const dataDate = $(this).data('date');
3349			const dataYear = $(this).data('year');
3350			// Check if the data attributes match today's date
3351
3352			if (dataMonth === todayMonth && dataDate === todayDate && dataYear === todayYear) {
3353				inView = true
3354			}
3355		}
3356    });
3357
3358	if(inView) {
3359		let today = new Date()
3360		let semesterStartsAfterToday = (today.getTime() < 1000 * window.registrationSemesterStartDate)
3361		let courseSaved = userHasCourseSavedInRegistrationSemester()
3362		if(semesterStartsAfterToday && courseSaved){
3363			//show the jump to registration semester button.
3364			$('#returnToTodayBtn').css('display', 'none')
3365			$('#jumpToNextSemesterBtn').css("display", "flex")
3366			$('#jumpToNextSemesterBtnText').text(prettifySemester(window.registrationSemester))
3367		}else{
3368			$('#jumpToNextSemesterBtn').css("display", "none")
3369			$('#returnToTodayBtn').fadeOut(200).css("display","none")
3370		}
3371	} else {
3372		$('#jumpToNextSemesterBtn').css("display", "none")
3373		$('#returnToTodayBtn').css("display","flex").fadeIn(200)
3374	}
3375	window.firstVisible = findFirstVisibleDayIndex()
3376	//for use on window resize
3377	if(!window.isPWA){
3378		window.firstVisibleHour = findFirstVisibleHourIndex()
3379	}
3380}
3381
3382$('#returnToTodayBtn').on('click', function(){
3383	//jump so today is in view, with annimation.
3384	jumpSoThatTimeMarkerIsInTopLeft(true)
3385})
3386
3387$('#jumpToNextSemesterBtn').on('click', function(){
3388	//jump to next semester, with animation
3389	jumpToSemesterStart(window.registrationSemester, true)
3390})
3391
3392// Updates and creates the red time marker
3393function updateTimeMarker() {
3394    var timeMarker = $("#timeMarker")
3395    var now = new Date();
3396
3397    // Adds it to the correct column
3398    const date = now.getDate()
3399    const month = now.getMonth()
3400    const year = now.getFullYear()
3401
3402	let currentCol = $(`.dayColumn[data-date="${date}"][data-month="${month}"][data-year="${year}"]`);
3403
3404	// The today tractor beam only needs restyling on a day change or after a repaint wiped it. It ran
3405	// every tick, and the removeClass selectors are document-wide, so each one invalidated style for
3406	// the whole page. Second condition catches the repaint; skipped when today isn't rendered.
3407	const todayKey = `${date}-${month}-${year}`
3408	const todayStylingWasWiped = (currentCol.length > 0 && !currentCol.hasClass('todayColumn'))
3409	if(window.timeMarkerTodayKey !== todayKey || todayStylingWasWiped){
3410		window.timeMarkerTodayKey = todayKey
3411		$('#todayIndicatorBar').remove()
3412		$('.todayIndicatorStyling').removeClass('todayIndicatorStyling')
3413		$('.todayColumn').removeClass('todayColumn')
3414		currentCol.find('.dayOfWeekText, .calendarDateText, .hourLineWithinDayColumn, .columnDateTextContainer').addClass('todayIndicatorStyling')
3415		let todayIndicatorBar = `<div id='todayIndicatorBar'></div>`
3416		currentCol.find('.calendarDateText').after(todayIndicatorBar)
3417		currentCol.addClass('todayColumn')
3418
3419		// The day that just ended will not repaint on its own, so fade it here. This is the only
3420		// moment the paint path cannot cover, because nothing about the column changed except the
3421		// date it is being compared against.
3422		$('.dayColumn').each(function(){
3423			const dayColumn = $(this)
3424			if(isBeforeToday(dayColumn.data('date'), dayColumn.data('month'), dayColumn.data('year'))){
3425				fadePastCalItems(dayColumn.find('.calItem:not(.calItemTask):not(.beforeNow)'))
3426			}
3427		})
3428	}
3429
3430    if ((!(timeMarker.length)) || (currentCol[0] != timeMarker.parent()[0])) { // Compare to make sure that they are the same element
3431        timeMarker.remove()
3432        currentCol.append($(`<div id="timeMarker"></div>`));
3433        timeMarker = $('#timeMarker')
3434    }
3435    
3436    // Puts it in the correct space of the correct column
3437    const hour = now.getHours()
3438    const minute = now.getMinutes()
3439    const second = now.getSeconds()
3440
3441    const minutesSoFar = hour*60 + minute + second/60
3442
3443	// Vertical position only. Width and inset come from CSS insets on #timeMarker (all.css), which
3444	// resolve against the parent column and follow #leftRail's transition without measuring — that
3445	// getBoundingClientRect, issued right after the class writes above, was the forced reflow and the
3446	// only reason this ran every 25ms. The z-index it set was "1000px", invalid and silently dropped.
3447	timeMarker.css('top', getPixelPositionFromMinutes(minutesSoFar) + "px");
3448	const position = timeMarker.position();
3449    if (position && typeof position.top !== 'undefined') {
3450        timeMarkerOnCalItem(date, month, year, position.top, minutesSoFar)
3451    }
3452	if(window.updateTimeMarkerTimeout){
3453		clearTimeout(window.updateTimeMarkerTimeout)
3454	}
3455	
3456	// Once a second: the finest thing rendered is a whole-second countdown, and the marker moves
3457	// about a pixel a minute. The 25ms was to track #leftRail's transition, which CSS insets now do.
3458	window.updateTimeMarkerTimeout = setTimeout(updateTimeMarker, 1000)
3459}
3460
3461
3462/*
3463	basically we are itereating through every calItem
3464	we check if the timeMarker's top padding is between the calItem's top padding and
3465	its top padding plus its height
3466*/
3467function isIncompleteTask(calItemEl) {
3468	if (!calItemEl.hasClass('calItemTask')) return false
3469	let tasksStore = getTasksData()
3470	let task = tasksStore?.tasks?.[calItemEl.attr('data-id')]
3471	return task && task.completed === null
3472}
3473
3474// The lighter treatment for anything already over. Shared by the paint path and the time marker's
3475// tick so the two cannot drift apart. Reads every background first, then writes, because it is
3476// otherwise a read-after-write per block.
3477function fadePastCalItems($items){
3478	if(!$items.length){ return }
3479	if(window.darkMode || document.documentElement.classList.contains('themeSampleActive')){
3480		$items.addClass('beforeNow')
3481		return
3482	}
3483	const backgrounds = $items.map(function(){ return $(this).css('background-color') }).get()
3484	$items.each(function(index){
3485		const originalBackgroundColor = generateDarkModeConsciousColor(backgrounds[index])
3486		const lighterColor = generateDarkModeConsciousColor(lightenColorForUseOnPastEvents(backgrounds[index], 40))
3487		$(this).css('background-image', `linear-gradient(to top,${originalBackgroundColor}  0%, ${lighterColor}  0% 100%, ${lighterColor} )`)
3488		$(this).find('.calItemName, .calendarTaskCheckbox').css('color', dynamicPastTextColor(originalBackgroundColor))
3489		$(this).find('.calItemTime, .calItemLocation, .calItemDeleteX').css('color', dynamicPastSubTextColor(originalBackgroundColor))
3490		$(this).addClass('beforeNow')
3491	})
3492}
3493
3494function timeMarkerOnCalItem(date, month, year, position, minutesSoFar) {
3495	let inEvent = false;
3496	let nextEventTime = null;
3497	let nextEventStartMinutes = null;
3498
3499	// Today's column only. Every branch below is guarded on the item being on today, so walking the
3500	// other columns was work that could never change anything.
3501	$('.todayColumn .calItem').each(function() {
3502		let rangeBottom = $(this).position().top
3503		let rangeTop = rangeBottom + $(this).outerHeight(true);
3504		parent = $(this).parent('.dayColumn')
3505		// console.log("from" , rangeBottom, 'to', rangeTop, 'currPosition', "position of marker", position)
3506		let fullDate = parent.data("date") + "-" + parent.data("month") + "-" + parent.data("year")
3507		let todayDate = date + "-" + month + "-" + year
3508		if((rangeBottom < position && position < rangeTop) && fullDate == todayDate) {
3509			const relativePosition = position - rangeBottom
3510			inEvent = true
3511			$('#timeMarker').removeClass('hide-after');
3512            const calItemHeight = $(this).outerHeight()
3513			// Minutes left comes from the block's own end time, not from the gap between the marker
3514			// and the bottom of the block. Measuring it made the countdown a function of geometry
3515			// instead of the clock, so it was only right while the marker and the blocks had been
3516			// laid out from the same pixels-per-minute. During a resize they are updated in separate
3517			// steps, so the two disagree and the number can be out by several minutes, not by
3518			// rounding. data-endTime and minutesSoFar are both minutes from midnight, which is exact
3519			// at any row height. TBA blocks have no end time, so those still measure.
3520			const endMinutes = parseFloat($(this).attr('data-endTime'))
3521			let minutesLeft = (isFinite(endMinutes) && typeof minutesSoFar === 'number')
3522				? Math.max(0, endMinutes - minutesSoFar)
3523				: (rangeTop - position) / window.pixelsPerMinuteForCalItems
3524			let newContent;
3525
3526			if (minutesLeft >= 60) {
3527				const hours = Math.floor(minutesLeft / 60);
3528				const minutes = Math.floor(minutesLeft % 60);
3529				newContent = `${hours}h${minutes == 0 ? '' : ' ' + minutes + 'm'} left`;
3530			} else if (minutesLeft >= 1) {
3531				newContent = `${Math.floor(minutesLeft)}m left`;
3532			} else {
3533				const secondsLeft = Math.floor(minutesLeft * 60);
3534				newContent = `${secondsLeft}s left`;
3535			}
3536
3537			// adding the content to the banner
3538			const element = document.querySelector('#timeMarker');
3539			element.style.setProperty('--pseudo-content', `"${newContent}"`);
3540
3541            const percentage = (relativePosition / calItemHeight) * 100
3542			var originalBackgroundColor = $(this).css('background-color')
3543			var lighterColor = lightenColorForUseOnPastEvents(originalBackgroundColor, 40);
3544
3545			//make more transparent if dark mode. Skip incomplete tasks.
3546			originalBackgroundColor = generateDarkModeConsciousColor(originalBackgroundColor)
3547			lighterColor = generateDarkModeConsciousColor(lighterColor)
3548			if(!window.darkMode && !$(this).hasClass('calItemTask') && !document.documentElement.classList.contains('themeSampleActive')){
3549				$(this).css('background-image', `linear-gradient(to top,${originalBackgroundColor}  ${100 - percentage}%, ${lighterColor}  0% ${percentage}%, ${lighterColor} )`);
3550			}
3551			$(this).removeClass('beforeNow')
3552
3553
3554			// If the time marker is not in a calendar item than we are going to identify if there
3555			// is an left in the event or not and identifying how many minutes until
3556		} else if(position < rangeBottom && fullDate == todayDate) {
3557			// Only count as "next event" if it's not a completed task
3558			if ((!$(this).hasClass('calItemTask') || isIncompleteTask($(this))) && (!nextEventTime || rangeBottom < nextEventTime)) {
3559                nextEventTime = rangeBottom;
3560                nextEventStartMinutes = parseFloat($(this).attr('data-startTime'));
3561            }
3562			$(this).removeClass('beforeNow')
3563		}
3564
3565		// painting past classes as the lighter shade
3566		// This is checking if the calendar item is before today or if the the range is before
3567		// the position of the time marker. Skip tasks — completed tasks use .taskCompleted class instead.
3568		// Only today's already-elapsed events, which is the one fade that genuinely depends on where
3569		// the marker is. A whole day being over is date arithmetic, so it is applied where the column
3570		// is painted (addEventBlocksToDayColumn) and on the day change below.
3571		if (!$(this).hasClass('calItemTask') && rangeTop < position && fullDate == todayDate) {
3572			fadePastCalItems($(this))
3573        }
3574
3575
3576    });
3577
3578	if (!inEvent) {
3579        if (nextEventTime) {
3580            // From the block's start time rather than its pixel offset, so the count doesn't shift
3581            // when the row height does.
3582            let minutesUntilNextEvent = (isFinite(nextEventStartMinutes) && typeof minutesSoFar === 'number')
3583                ? Math.max(0, nextEventStartMinutes - minutesSoFar)
3584                : (nextEventTime - position) / window.pixelsPerMinuteForCalItems
3585            let newContent;
3586
3587            if (minutesUntilNextEvent >= 60) {
3588                const hours = Math.floor(minutesUntilNextEvent / 60);
3589                const minutes = Math.floor(minutesUntilNextEvent % 60);
3590                newContent = `${hours}h${minutes == 0 ? '' : ' ' + minutes + 'm'} until`;
3591            } else if (minutesUntilNextEvent >= 1) {
3592                newContent = `${Math.floor(minutesUntilNextEvent)}m until`;
3593            } else {
3594                const secondsUntilNextEvent = Math.floor(minutesUntilNextEvent * 60);
3595                newContent = `${secondsUntilNextEvent}s until`;
3596            }
3597
3598            const element = document.querySelector('#timeMarker');
3599            element.style.setProperty('--pseudo-content', `"${newContent}"`);
3600        } else {
3601            const element = document.querySelector('#timeMarker');
3602            element.style.setProperty('--pseudo-content', `"done today"`);
3603        }
3604    }
3605}
3606
3607function isBeforeToday(day, month, year) {
3608    // Create a Date object for the given date
3609    const givenDate = new Date(year, month, day); // month is 0-indexed
3610    // Create a Date object for today's date
3611    const today = new Date();
3612    // Set time to the start of the day for an accurate comparison
3613    today.setHours(0, 0, 0, 0);
3614
3615    // Compare the dates
3616    return givenDate < today;
3617}
3618
3619
3620//in jumpToSemesterStart, this function is called after an event in the relevant semester has already been confirmed.
3621//thus no apparent need to handle no events gracefully.
3622function averageOfEarliestStartAndLatestEnd(arrayOfStartsEndsPairs) {
3623    // Initialize variables to hold the earliest start time and latest end time
3624    let earliestStart = Infinity;
3625    let latestEnd = -Infinity;
3626
3627    // Loop through the array to find the earliest start and latest end
3628    for (const { start, end } of arrayOfStartsEndsPairs) {
3629        if (start < earliestStart) {
3630            earliestStart = start; // Update the earliest start
3631        }
3632        if (end > latestEnd) {
3633            latestEnd = end; // Update the latest end
3634        }
3635    }
3636
3637    // Calculate the average of the earliest start and latest end
3638    const average = (earliestStart + latestEnd) / 2;
3639
3640    return average; // Return the average in raw minutes
3641}
3642
3643
3644async function jumpToSemesterStart(semester, animation = false){
3645
3646
3647	let currentScheduleID = getCurrentlySelectedScheduleID()
3648	let currentScheduleEventIDs= getActiveScheduleEventIDs()
3649	let dateToJumpTo
3650	let eventToJumpTo = null
3651
3652	let semesterHasEvents = false
3653
3654	let minStartTime = Infinity;
3655	currentScheduleEventIDs.forEach(eventID => {
3656		let event = getEventByID(eventID);
3657		if (event.semester == semester) {
3658			semesterHasEvents = true;
3659			// Check if the current event's start time is less than the recorded minimum
3660			if (event.occurrenceData.starts < minStartTime) {
3661				minStartTime = event.occurrenceData.starts; // Update minimum start time
3662				dateToJumpTo = minStartTime; // Update the date to jump to
3663				eventToJumpTo = event; // Update the event to jump to
3664			}
3665		}
3666	});
3667
3668	if(!semesterHasEvents){
3669		jumpSoThatTimeMarkerIsInTopLeft(animation) //jump to today
3670	} else if(eventToJumpTo){
3671		//TODO: change this to get the output of Gabriel's function that will tell us the leftmost day when scrolled, given eventToJumpTo.
3672		let incrementedDate = new Date(dateToJumpTo * 1000)//gabrielGiveEvtGetDate(eventToJumpTo)
3673		
3674		evtsInLookaheadWindow = []
3675		var evtsForDay
3676
3677		//collecting all of the events for the number of days displayed at once in the schedule
3678		for(i=0; i<getScheduleDayNumber(); i++){
3679			
3680			let rawItemsForDay = getEventsToPaint(getUsersSchedulesAndEvents(), incrementedDate, currentScheduleID, false)
3681			if (window.webAppRole != "viewer") {
3682				rawItemsForDay = rawItemsForDay.concat(getTasksRawPaintData(incrementedDate, getTasksData()))
3683			}
3684
3685			//get the start/end minute numbers.
3686			evtsForDay = rawItemsForDay.map(item => ({start: item[1], end: item[2]}))
3687
3688			//combine the events for the day into the bigger collection of events within the lookahead window
3689			evtsInLookaheadWindow = evtsInLookaheadWindow.concat(evtsForDay)
3690			//increment the day up 1.
3691			incrementedDate.setDate(incrementedDate.getDate() + 1)
3692		}
3693		
3694		scheduleViewDependentScrollToView(eventToJumpTo,animation)
3695
3696	}
3697
3698	
3699	
3700}
3701
3702function eventWillBeInView(scrollTarget, evtsInLookaheadWindow){
3703	let totalCalendarHeight = window.innerHeight - $('.columnDateTextContainer').get(0).getBoundingClientRect().bottom + parseInt($('.columnDateTextContainer').css('padding-bottom'), 10)
3704	if(window.isPWA){
3705		totalCalendarHeight -= $('#changeViewButtons').get(0).getBoundingClientRect().height
3706	}
3707
3708	let halfHeight = totalCalendarHeight * .5
3709	let hourHeight = $('.hourLineWithinDayColumn').first().get(0).getBoundingClientRect().height
3710	//this is the number of hours in view above and below the midpoint
3711	let hoursVisibleAboveAndBelow = halfHeight / hourHeight
3712
3713	let minHourVisible = Math.max(scrollTarget - hoursVisibleAboveAndBelow, 0)
3714	let maxHourVisible = Math.min(scrollTarget + hoursVisibleAboveAndBelow, 23.99)
3715
3716	var willBeInView = false
3717	evtsInLookaheadWindow.forEach(evt => {
3718
3719
3720		let maxTime = evt['end'] / 60
3721		let minTime = evt['start'] / 60
3722
3723    
3724		// Calculate the intersection of the two intervals
3725		const intersectionStart = Math.max(minTime, minHourVisible);
3726		const intersectionEnd = Math.min(maxTime, maxHourVisible);
3727		// If there's no intersection, return false
3728		if (intersectionStart >= intersectionEnd) {
3729			return
3730		}
3731		
3732		// Calculate the length of the intersection
3733		const intersectionLength = intersectionEnd - intersectionStart;
3734	
3735		// Check if the intersection is greater than 50% of the visible window
3736		if(intersectionLength > 0.5 * (maxTime - minTime)){
3737			willBeInView = true
3738			return
3739		}
3740
3741		
3742	})
3743
3744	return willBeInView
3745
3746}
3747
3748// Function to determine if color is dark
3749function isDark(rgb) {
3750    // Extract RGB values from format "rgb(r, g, b)" or "rgba(r, g, b, a)"
3751    // Alpha is ignored - we only use RGB for brightness calculation. This is safe because
3752    // generateDarkModeConsciousColor returns alpha=1 in light mode and themed dark mode.
3753    // Classic dark mode uses alpha=0.05, but text color is set inline there, not via brightenText.
3754    let match = rgb.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/);
3755    if (!match) return false;
3756
3757    let r = parseInt(match[1]);
3758    let g = parseInt(match[2]);
3759    let b = parseInt(match[3]);
3760
3761    // Calculate perceived brightness
3762    let brightness = (r * 299 + g * 587 + b * 114) / 1000;
3763
3764    // Return true if color is dark, false otherwise
3765    return brightness < 150;
3766}
3767
3768// Brightness-based color helpers for task checkbox borders
3769// brightness = (0.299*R + 0.587*G + 0.114*B) / 255
3770function colorBrightness(hex) {
3771	const {r, g, b} = parseColor(hex)
3772	return (0.299 * r + 0.587 * g + 0.114 * b) / 255
3773}
3774function dynamicTextColor(hex)     { return colorBrightness(hex) > 0.5 ? themeColor('--black') : themeColor('--white') }
3775function dynamicSubTextColor(hex)  { return colorBrightness(hex) > 0.6 ? themeColor('--gray-14') : themeColor('--gray-33') }
3776function dynamicPastTextColor(hex) { return colorBrightness(hex) > 0.6 ? themeColor('--gray-21') : themeColor('--gray-33') }
3777function dynamicPastSubTextColor(hex) { return colorBrightness(hex) > 0.6 ? themeColor('--gray-20') : themeColor('--gray-19') }
3778
3779// Dark-mode-classic analog of iOS themedDynamicDarkTextColor: keep the class color's hue but
3780// lift dark colors to a readable lightness floor (bright colors pass through) so text and
3781// checkmarks stay legible on the color@5%-alpha-on-dark block. Accepts hex or rgb(a) — e.g. a
3782// block's computed background-color. The 0.62 floor is the tunable knob, matched to iOS.
3783function darkReadableColor(color) {
3784	const {r, g, b} = parseColor(color)
3785	let {h, s, l} = rgbToHsl(r, g, b)
3786	l = Math.max(l, 0.62)
3787	s = Math.min(s + (1 - s) * 0.3, 1)
3788	const rgb = hslToRgb(h, s, l)
3789	return `rgb(${Math.round(rgb.r)}, ${Math.round(rgb.g)}, ${Math.round(rgb.b)})`
3790}
3791
3792function hexToRgbaAt50(hex) {
3793	const {r, g, b} = parseColor(hex)
3794	return `rgba(${r}, ${g}, ${b}, 0.5)`
3795}
3796
3797// Measures text on one shared 2D canvas so candidate lengths can be tried without writing to the
3798// DOM. letterSpacing is added by hand because the canvas API ignores it and themes do set it.
3799function makeCalItemTextMeasurer(style){
3800	if(!window.calItemTextMeasureContext){
3801		window.calItemTextMeasureContext = document.createElement('canvas').getContext('2d')
3802	}
3803	const context = window.calItemTextMeasureContext
3804	const font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`
3805	const letterSpacing = parseFloat(style.letterSpacing) || 0
3806	// Per call, not once at creation: the context is shared, so an earlier measurer would otherwise
3807	// inherit a later one's font once several are alive at the same time.
3808	return function(text){
3809		context.font = font
3810		return context.measureText(text).width + letterSpacing * text.length
3811	}
3812}
3813
3814// Adjusts text color based on background brightness for readability.
3815// isContainer: true for .calItem elements (styles children: calItemName, calItemTime, etc.)
3816//              false for simple elements like .allDayEvent, .taskAssociatedEventTag
3817// precomputedBackgroundColor: pass one when calling in a loop, so the getComputedStyle read can be
3818// batched with the others by the caller rather than forcing a style recalc per element.
3819function brightenText(backgroundElement, isContainer = false, precomputedBackgroundColor = null){
3820	let bgColor = (precomputedBackgroundColor !== null) ? precomputedBackgroundColor : $(backgroundElement).css('background-color');
3821
3822	// Multi-item all-day events already have color set via CSS variable, skip them
3823	if ($(backgroundElement).hasClass('allDayEvent') && !isContainer) {
3824		const eventIds = $(backgroundElement).attr('data-event-ids') || '';
3825		const taskIds = $(backgroundElement).attr('data-task-ids') || '';
3826		if (eventIds.includes(',') || taskIds.includes(',') || (eventIds && taskIds)) {
3827			return;
3828		}
3829	}
3830
3831	// Classic dark: lift the class color to a readable floor so dark custom colors stay legible
3832	// (mirror of iOS themedDynamicDarkTextColor); overrides the undefined-var no-op path below.
3833	const currentTheme = window.coursicleCurrentTheme || localStorage.getItem('customTheme') || 'classic'
3834	if (window.darkMode && currentTheme === 'classic') {
3835		// Blocks are painted as the class colour already composited onto the calendar
3836		// surface, so their background no longer carries the hue to lift — read the
3837		// class colour off the element instead.
3838		const rawColor = $(backgroundElement).attr('data-event-color') || bgColor
3839		const readable = darkReadableColor(rawColor)
3840		if (isContainer) {
3841			$(backgroundElement).find('.calItemName, .calendarTaskCheckbox').css('color', readable)
3842			$(backgroundElement).find('.calItemTime, .calItemLocation, .calItemDeleteX').css('color', readable)
3843		} else {
3844			$(backgroundElement).css('color', readable)
3845		}
3846		return
3847	}
3848
3849	if (isDark(bgColor)) {
3850		// Dark background: use light text
3851		if(isContainer){
3852			$(backgroundElement).find('.calItemName, .calendarTaskCheckbox').css('color', 'var(--cal-item-title-on-dark-bg)');
3853			$(backgroundElement).find('.calItemTime, .calItemLocation, .calItemDeleteX').css('color', 'var(--cal-item-subtitle-on-dark-bg)');
3854		}else{
3855			$(backgroundElement).css('color',themeColor('--white'))
3856		}
3857	} else {
3858		// Light background: use dark text
3859		if(isContainer){
3860			$(backgroundElement).find('.calItemName, .calendarTaskCheckbox').css('color', 'var(--cal-item-title-on-light-bg)');
3861			$(backgroundElement).find('.calItemTime, .calItemLocation, .calItemDeleteX').css('color', 'var(--cal-item-subtitle-on-light-bg)');
3862		}
3863	}
3864}
3865
3866function brightenOrDarkenColorBoxCheckMark(colorBox){
3867	let bgColor = $(colorBox).css('background-color');
3868	let dataColor = $(colorBox).attr('data-color')
3869	let checkBox = $(colorBox).find('#chosenColorCheckbox')
3870	// Use window.coursicleCurrentTheme first to respect sharer's theme when viewing shared schedules
3871	const currentTheme = window.coursicleCurrentTheme || localStorage.getItem('customTheme') || 'classic'
3872
3873	// Classic dark mode uses brightened color; themed dark mode uses white/black like light mode
3874	if(window.darkMode && currentTheme === 'classic'){
3875		checkBox.css('color', darkReadableColor(dataColor))
3876		return
3877	}
3878	if(isDark(bgColor)){
3879		checkBox.css('color', themeColor('--white'))
3880	}else{
3881		checkBox.css('color', themeColor('--gray-3'))
3882	}
3883}
3883
3884
3885function getEventByID(eventID) {
3886	return window.getUsersSchedulesAndEvents().events[parseInt(eventID)] || null; // return the event or null if not found
3887}
3888
3889
3890// for arrow key jumping
3891function jumpDays(numberOfDays, direction){
3892	let dayWidth = $('.dayColumn').first().width()
3893	let container = $('#viewportWithinWhichCalendarScrolls')
3894
3895	if(window.jumpDayAnimating){
3896		//if a previous animation is going on, stop it and immediately advance to where it was going.
3897		container.stop()
3898		container.animate({scrollLeft: jumpAnimationEndingPositionTarget},0)
3899	}
3900	
3901	//had to deduplicate some code so made this to set scroll to go left or right
3902	let directionNegationMultiplier = 1
3903	switch (direction){
3904		case "left":
3905			directionNegationMultiplier = -1
3906			break;
3907
3908		case "right":
3909			break;
3910	}
3911
3912	//where the animation will finish if uninterrupted
3913	window.jumpAnimationEndingPositionTarget = container.scrollLeft() + directionNegationMultiplier * dayWidth * numberOfDays
3914
3915	addMoreDaysOnScroll(true);
3916
3917	//don't tell joe about these window vars :^|
3918	window.jumpDayAnimating = true
3919	container.animate({scrollLeft: window.jumpAnimationEndingPositionTarget},350,function(){
3920		window.jumpDayAnimating = false
3921	})
3922}
3923
3924$('.jumpDayArrow').on('click', function(){
3925	let dayNumber = getScheduleDayNumber()
3926	if($(this).hasClass('jumpDayArrowLeft')){
3927		jumpDays(dayNumber,"left")
3928	}else if($(this).hasClass('jumpDayArrowRight')){
3929		jumpDays(dayNumber,"right")
3930	}
3931
3932})
3933
3934function correctCalItemWidths(preview = false){
3935	// Every block divides the same column width, so read it once. Reading it inside the loop meant
3936	// each iteration measured a column that the previous iteration had just written to, forcing a
3937	// fresh layout every time. Same value, same writes, same order.
3938	const dayColumnEl = document.querySelector('.dayColumn')
3939	if(!dayColumnEl){ return }
3940	const dayColumnWidth = dayColumnEl.getBoundingClientRect().width
3941
3942	$('.calItem').each(function(){
3943		let overlapCount = $(this).data('overlap-count') 
3944		if(preview){
3945			overlapCount+=1
3946		}
3947		let overlapLevel = $(this).data('overlap-level')
3948		const { width: correctedWidth, left: correctedLeft } = calculateCalItemWidthAndLeft(dayColumnWidth, overlapCount, overlapLevel)
3949		$(this).css("min-width",correctedWidth + "px")
3950		$(this).css("max-width",correctedWidth + "px")
3951		$(this).css("left", correctedLeft + "px")
3952	})
3953
3954	var correctedWidth = dayColumnWidth - window.timeMarkerPaddingRequirement
3955	$('.allDayEvent').css("min-width",correctedWidth + "px").css("max-width",correctedWidth + "px")
3956	// Blocks changed width, so their text needs refitting -- but the caller decides when. During a
3957	// window drag this runs on every resize event, and refitting text 60 times a second to land on
3958	// the answer only the last one needs is the most expensive thing on that path.
3959}
3960
3961
3962//move the calendar left and right on arrow key
3963$(document).on("keydown", function(event){
3964	//prevent navigating the calendar when text is being edited
3965	if ($('input:focus, textarea:focus, [contenteditable="true"]:focus').length > 0) {
3966		return
3967	}
3968	switch (event.key) {
3969		case 'ArrowLeft':
3970			event.preventDefault()
3971			$('.jumpDayArrowLeft').click()
3972			break;
3973		case 'ArrowRight':
3974			event.preventDefault()
3975			$('.jumpDayArrowRight').click()
3976			break;
3977	}
3978})
3979
3980
3981function resizeAllCalItemHeights(){
3982	$('.calItem').each(function(){
3983		let tba = $(this).attr('data-tba')
3984		let startTime= parseInt($(this).attr("data-starttime"))
3985		let endTime = parseInt($(this).attr("data-endtime"))
3986		var correctedHeight
3987		var correctedTop
3988		if(tba == "true"){
3989			correctedHeight = 60 *  window.pixelsPerMinuteForCalItems
3990		}else{
3991			correctedHeight = (endTime - startTime) *  window.pixelsPerMinuteForCalItems
3992			correctedTop = getPixelPositionFromMinutes(startTime)
3993		}
3994		
3995		$(this).css('height', correctedHeight)
3996		$(this).css('top', correctedTop)
3997	})
3998	// Blocks changed height, so their text needs refitting -- the caller decides when. See the note
3999	// in correctCalItemWidths(); a vertical drag used to refit the whole document twice per event.
4000}
4001
4002$('#shareIcon').on('click', function(){
4003	if(hasNonEditPermissionOnCurrentlySelectedSchedule){
4004		alert("You don't have permission to share this schedule.");
4005		return;
4006	}
4007	if(window.isPWA){
4008		renderSlidableModal('#shareScheduleDialogue')
4009	}
4010})
4011
4012
4013$('#onlyShareClassesBtn').on('click', function(){
4014	createSharableLink('classes')
4015
4016	
4017})
4018
4019$('#shareEverythingBtn').on('click', function(){
4020	createSharableLink('everything')
4021
4022	hideSlidableModal()
4023})
4024
4025$('#shareScheduleCancelButton').on('click', function(){
4026	hideSlidableModal()
4027})
4028
4029function updateCreditsAndSemesterTextInHeader(){
4030	let semestersData = getSemesterData()
4031	let currentScheduleID = getCurrentlySelectedScheduleID()
4032	let middleDayColumn = fastGetFirstLastDayInView(fullyInView = true, false).center
4033	
4034	if (middleDayColumn) {
4035		let middleDayColumnTime = Math.floor((new Date(middleDayColumn.attr('data-isodate'))).getTime()/1000)
4036		let semesterName;
4037		
4038		semestersData.forEach(semester => {
4039			if (middleDayColumnTime >= semester.starts && middleDayColumnTime <= semester.ends) {
4040				//set global variable for reference later such as list view
4041				window.currentSemesterInViewport = semester.value
4042				semesterName = semester.display
4043				
4044				//display the corresponding credits for this semester
4045				let credits = getCreditsForSemester(currentScheduleID, semester.value)					
4046				$('#creditsText').text(credits + ' credits')
4047				$('#creditsText').show()
4048				$('#semesterAndCreditsText').show()
4049				return
4050			}
4051		})
4052		
4053		if (!semesterName) {
4054			//set global variable for reference later such as list view and tba classes
4055			window.currentSemesterInViewport = null
4056
4057			let startOfSupportedSemesters = semestersData.reduce((earliestStarts, currentSemester) => {return currentSemester.starts < earliestStarts ? currentSemester.starts : earliestStarts}, Infinity)
4058			let endOfSupportedSemesters = semestersData.reduce((latestEnds, currentSemester) => {return currentSemester.ends > latestEnds ? currentSemester.ends : latestEnds}, -Infinity)
4059
4060			//if we're scrolled outside of the range of semesters we support, hide credit and semesters string
4061			if (middleDayColumnTime < startOfSupportedSemesters || middleDayColumnTime > endOfSupportedSemesters) {
4062				$('#semesterAndCreditsText').hide()
4063				return
4064			}
4065			semesterName = getBreakName(middleDayColumn.attr('data-month'))
4066			
4067			//since we're on a break, there are no credits to show so hide them 
4068			$('#creditsText').hide()
4069			
4070			$('#semesterAndCreditsContainer').removeClass('semesterAndCreditsPointer')
4071			
4072		}else{
4073			$('#semesterAndCreditsContainer').addClass('semesterAndCreditsPointer')
4074		}
4075		
4076		$('#semesterText').text(semesterName)
4077	}
4078}
4079
4080//Most of the time, we can rely on the window variable semestersWeHaveClassesFor to provide us all relevent semester information.
4081//However, if we don't have data on an upcoming semester (happens with ucla currently), we want to supplement this data by iterating through the users classes
4082//and adding any semesters that they have saved that aren't in the window variable.
4083function getSemesterData(){
4084	// note: we don't want to modify window.semestersWeHaveClassesFor with this, because 
4085	// otherwise you'll get the course search on schools that you don't have classes for if you have
4086	// events from another school / maybe even custom events. This is what slice does.
4087	// note though, it's a shallow copy, so don't modify the things inside severSemesterData 
4088	// because that'll affect things in semestersWeHaveClassesFor too.
4089	let severSemesterData = window.semestersWeHaveClassesFor.slice();	
4090	let events = getUsersSchedulesAndEvents().events
4091	for (let event in events){
4092		event = events[event]
4093		if (event['semester']) {			
4094			if (!severSemesterData.some(semesterFromSever => semesterFromSever.value == event['semester'])){
4095				let newSemester = {
4096					display : prettifySemester(event['semester']),
4097					value : event['semester'],
4098					starts: event['occurrenceData']['starts'],
4099					ends : 	event['occurrenceData']['ends']
4100				}
4101				severSemesterData.push(newSemester)
4102			}
4103		}
4104	}
4105	return severSemesterData
4106}
4107
4108//Return which break the student is in based on what date the day column is on. For example, if the day column is in july, we can assume its summer break
4109//Define summer to be between May 1st to Sept 30th and winter to be between Nov 1st to Mar 1st
4110function getBreakName(dayColumnMonth){
4111	let breakString;		
4112	
4113	if (dayColumnMonth >= 4 && dayColumnMonth <= 8 ) {
4114		breakString = 'Summer Break'
4115	} else if (dayColumnMonth > 10 || dayColumnMonth <= 1) {
4116		breakString = 'Winter Break'
4117	} else {
4118		breakString = 'Break'
4119	}
4120	
4121	return breakString;
4122}
4123
4124
4125
4126function renderHTMLForSemesterAndCreditTippyListView() {
4127	let classesAndCredits = getListOfClassesAndCredits(getCurrentlySelectedScheduleID(), window.currentSemesterInViewport)
4128	
4129	let innerHTML = ''
4130
4131	if(classesAndCredits.length == 0){
4132		let card = 
4133		`
4134			<div id='userHasNoClassesContainer'>
4135				No classes
4136			</div>
4137		`
4138		innerHTML += card
4139		return innerHTML
4140	}
4141	
4142	classesAndCredits.forEach(classObj => {
4143		let mappedColor = mapClassicColorToThemeColor(classObj.color)
4144		let card =
4145		`
4146			<div class='classAndSemesterTile'>
4147				<span class='colorSectionClassesAndCreditsListView' style='background-color:${window.darkMode ? generateSaturatedDarkModeConsciousColor(mappedColor) : mappedColor}'></span>
4148				<span class='classSectionClassesAndCreditsListView' onclick='listViewClassClick(${classObj.id})'>${classObj.title}</span>
4149				<span class='creditSectionClassesAndCreditsListView'>${classObj.credits}</span>
4150			</div>
4151		`
4152		innerHTML += card
4153	})	
4154	return innerHTML
4155}
4156
4157function getListOfClassesAndCredits(scheduleID, semester) {
4158	if (!scheduleID || !semester) {
4159		return []
4160	}
4161
4162	// Find the schedule with the given ID
4163	const schedule = getScheduleByID(scheduleID)
4164	
4165	if (!schedule) {
4166	  console.error('Schedule not found');
4167	  return [];
4168	}
4169	
4170	// Sum the credits of all events in the schedule for the specified semester
4171	let classesWithCreditsInSemester = []
4172	
4173	schedule.events.forEach(eventID => {
4174	  const event = getEventByID(eventID);
4175	  if (event && event.credits >= 0 && event.semester === semester) {
4176		classesWithCreditsInSemester.push({title: event.title, credits: event.credits, color: event.color, id: eventID})
4177	  }
4178	});	
4179
4180	return classesWithCreditsInSemester
4181}
4182
4183function listViewClassClick(id){
4184	renderDetailView(id, 'class')
4185	openInLeftRailView($('#infoColumn'))
4186	window.lastClickedEvent = id
4187}
4188
4189
4190
4191// fun stuff 4 later
4192
4193
4194function getCalItemBoundsInActionableWindow() {
4195	const windowBounds = getActionableThemeWindow();
4196	if (!windowBounds) return [];
4197
4198	const calItems = document.querySelectorAll('.calItem');
4199	const results = [];
4200
4201	calItems.forEach(el => {
4202		const rect = el.getBoundingClientRect();
4203
4204		// intersection test
4205		const intersects =
4206			rect.right > windowBounds.left &&
4207			rect.left < windowBounds.right &&
4208			rect.bottom > windowBounds.top &&
4209			rect.top < windowBounds.bottom;
4210
4211		if (intersects) {
4212			results.push({
4213				element: el,
4214				left: rect.left,
4215				right: rect.right,
4216				top: rect.top,
4217				bottom: rect.bottom,
4218				width: rect.width,
4219				height: rect.height
4220			});
4221		}
4222	});
4223
4224	return results;
4225}
4226
4227
4228function getActionableThemeWindow() {
4229	const $hourLabels = $('#columnContainingHourLabels');
4230	const $rightRail = $('#rightRail');
4231	const $header = $('.columnDateTextContainer');
4232
4233	if (!$hourLabels.length || !$header.length) {
4234		console.warn('Missing one or more bounding elements');
4235		return null;
4236	}
4237
4238	const leftBound = $hourLabels[0].getBoundingClientRect().right;
4239	// If rightRail is hidden (e.g., viewer mode), use window width as right bound
4240	const rightRailRect = $rightRail.length ? $rightRail[0].getBoundingClientRect() : null;
4241	const rightBound = (rightRailRect && rightRailRect.left > 0) ? rightRailRect.left : window.innerWidth;
4242	const topBound = $header[0].getBoundingClientRect().bottom;
4243
4244	// In PWA mode, exclude the nav bar at the bottom so rain/puddle stays in the calendar area
4245	let bottomBound = window.innerHeight;
4246	if (window.isPWA) {
4247		const $pwaNav = $('#toggleView');
4248		if ($pwaNav.length) {
4249			const pwaNavTop = $pwaNav[0].getBoundingClientRect().top;
4250			if (pwaNavTop > 0) {
4251				bottomBound = pwaNavTop;
4252			}
4253		}
4254	}
4255
4256	return {
4257		left: leftBound,
4258		right: rightBound,
4259		top: topBound,
4260		bottom: bottomBound,
4261		width: rightBound - leftBound,
4262		height: bottomBound - topBound
4263	};
4264}
4265
4266
4267
4268function showActionableThemeWindowOverlay() {
4269	const bounds = getActionableThemeWindow();
4270	if (!bounds) return;
4271
4272	let overlay = document.getElementById('actionableThemeWindowOverlay');
4273
4274	if (!overlay) {
4275		overlay = document.createElement('div');
4276		overlay.id = 'actionableThemeWindowOverlay';
4277		document.body.appendChild(overlay);
4278	}
4279
4280	Object.assign(overlay.style, {
4281		position: 'fixed',
4282		left: bounds.left + 'px',
4283		top: bounds.top + 'px',
4284		width: bounds.width + 'px',
4285		height: bounds.height + 'px',
4286		background: themeColor('--el-color-150'),
4287		outline: '2px dashed rgba(0, 150, 255, 0.8)',
4288		pointerEvents: 'none',
4289		zIndex: 999999
4290	});
4291}
4292
4293function removeActionableThemeWindowOverlay() {
4294	const overlay = document.getElementById('actionableThemeWindowOverlay');
4295	if (overlay) overlay.remove();
4296}
4297
4298
4299
4300
4301// Creates (or reuses) the single full-calendar canvas every FX effect draws into —
4302// not snow-specific despite the element id, which stays put for now.
4303function createFxCanvas() {
4304	const bounds = getActionableThemeWindow();
4305	if (!bounds) return null;
4306
4307	let canvas = document.getElementById('snowCanvas');
4308	if (!canvas) {
4309		canvas = document.createElement('canvas');
4310		canvas.id = 'snowCanvas';
4311		document.body.appendChild(canvas);
4312	}
4313
4314	// Render at device resolution so thin strokes stay crisp on retina;
4315	// the FX engine reads canvas._fxDPR and works in CSS-pixel coordinates
4316	const dpr = Math.min(window.devicePixelRatio || 1, 2);
4317	canvas.width = bounds.width * dpr;
4318	canvas.height = bounds.height * dpr;
4319	canvas._fxDPR = dpr;
4320
4321	Object.assign(canvas.style, {
4322		position: 'fixed',
4323		left: bounds.left + 'px',
4324		top: bounds.top + 'px',
4325		width: bounds.width + 'px',
4326		height: bounds.height + 'px',
4327		pointerEvents: 'none',
4328		zIndex: 998  // below modals (9998+)
4329	});
4330
4331	return canvas;
4332}
4333
4334
4335
4336// Makes the banner's icon open the same link as the text does (the icon is the obvious click target).
4337// Idempotent: bails if the icon is already wrapped, so it's safe to call after every render.
4338function wrapWebViewBannerIconInLink(){
4339	var icon = $("#webViewCalendarBannerNoteIcon");
4340	if(!icon.length || icon.parent().is("a")){return}
4341	var bannerLink = $("#webViewCalendarBannerNoteText").find("a");
4342	if(!bannerLink.length){return}
4343	var bannerHref = bannerLink.attr('href');
4344	var bannerOnclick = bannerLink.attr('onclick');
4345	if(bannerHref){
4346		icon.wrap(`<a target="_blank" href="${bannerHref}" />`);
4347	}else if(bannerOnclick){
4348		icon.wrap(`<a onclick='${bannerOnclick}' />`);
4349	}
4350}
4351
4352// Replaces (or removes, or inserts) the desktop header banner from a getBannerAd() response.
4353// Mirrors the markup all/index.php renders server-side on page load, so CSS and the offer
4354// countdown ticker in all/js/shared.js see exactly the same nodes either way.
4355window.renderWebViewCalendarBannerNote = function(bannerAd){
4356	var webHTML = bannerAd ? bannerAd["webHTML"] : null;
4357	var existing = $("#webViewCalendarBannerNote");
4358	if(!webHTML){
4359		existing.remove();
4360		return;
4361	}
4362	var markup = `<div id="webViewCalendarBannerNote" class="webViewHeaderElement ${webHTML["singleLine"] ? "singleLine" : ""}">
4363		<div id="webViewCalendarBannerNoteIcon">${webHTML["icon"]}</div>
4364		<div id="webViewCalendarBannerNoteText">
4365			<div id="webViewCalendarBannerNoteTopLine">${webHTML["topLine"]}</div>
4366			<div id="webViewCalendarBannerNoteBottomLine">${webHTML["bottomLine"]}</div>
4367		</div>
4368	</div>`;
4369	if(existing.length){
4370		// no-op when nothing changed, so a wakeup doesn't visibly re-flow an identical banner
4371		// (and doesn't reset a running offer countdown, which anchors its deadline on first tick)
4372		if(existing.data("bannerMarkup") === markup){return}
4373		existing.replaceWith(markup);
4374	}else{
4375		$("#webAppCalendarHeaderViewLeftButtons").after(markup);
4376	}
4377	$("#webViewCalendarBannerNote").data("bannerMarkup", markup);
4378	wrapWebViewBannerIconInLink();
4379}
4380
4381window.getLatestWebViewBannerFromServer = function(){
4382	var params = {
4383		school: retrieveSchoolParameter(),
4384		versionNumber: document.getElementById("versionCheckerScript").getAttribute("version-as-of-page-load"),
4385		buildNumber: 0, // deprecated
4386		html: 1
4387	};
4388	if(store.get('uuid')){
4389		params['uuid'] = store.get('uuid');
4390	}
4391	$.ajax({
4392		url: '/app/backend/getNote.php',
4393		type: 'GET',
4394		data: params,
4395		success: function(response){
4396			renderWebViewCalendarBannerNote(response);
4397		},
4398		error: function(xhr, status, error){
4399			console.log(error);
4400		}
4401	});
4402}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.