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.