MyPlan Calendar

Add a calendar view to MyPlan

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         MyPlan Calendar
// @namespace    http://tampermonkey.net/
// @version      1.4
// @description  Add a calendar view to MyPlan
// @author       Hangyu Feng
// @match        https://myplan.uw.edu/plan/*
// @grant        none
// @license      GPL-3.0-or-later
// @homepageURL  https://github.com/hangyu-feng/myplan_calendar
// @supportURL   https://github.com/hangyu-feng/myplan_calendar/issues
// ==/UserScript==

(function() {
    'use strict';

    // Configuration
    const CALENDAR_ID = 'myplan-calendar-modal';
    const BUTTON_ID = 'myplan-calendar-btn';

    // Utility to parse time string "10:30 - 11:20" to minutes from midnight
    function parseTime(timeStr) {
        let parts = timeStr.match(/(\d{1,2}):(\d{2})\s*(AM|PM)?/i);
        if (!parts) return null;

        let hours = parseInt(parts[1], 10);
        let minutes = parseInt(parts[2], 10);
        let meridiem = parts[3] ? parts[3].toUpperCase() : null;

        return { hours, minutes, meridiem };
    }

    function convertToMinutes(timeObj, isEnd = false) {
        let { hours, minutes, meridiem } = timeObj;

        if (meridiem === 'PM' && hours !== 12) hours += 12;
        if (meridiem === 'AM' && hours === 12) hours = 0;

        if (!meridiem) {
             if (hours < 8) hours += 12;
        }

        return hours * 60 + minutes;
    }

    function findCourses() {
        const courses = [];
        const items = document.querySelectorAll('li[id^="plan-item-"]');
        
        items.forEach(item => {
            // Title extraction
            let code = "Unknown";
            let courseName = "";
            let deptName = "";
            let coursePageLink = "";
            
            const codeLink = item.querySelector('h3 a');
            if (codeLink) {
                code = codeLink.textContent.trim();
                coursePageLink = codeLink.href;
                const ariaLabel = (codeLink.getAttribute('aria-label') || "").trim().replace(/\s+/g, ' ');
                
                const match = ariaLabel.match(/^(.*?)\s+(\d{3})\s+(.*)$/);
                if (match) {
                    deptName = match[1].trim();
                    courseName = match[3].trim();
                }

                if (!courseName || courseName.length < 3) {
                    const allLinks = Array.from(item.querySelectorAll('a'));
                    const nameLink = allLinks.find(a => a !== codeLink && !a.href.includes('sln.asp') && !a.textContent.includes('Check enrollment'));
                    if (nameLink) {
                        courseName = nameLink.textContent.trim();
                    }
                }
            }

            const title = courseName ? `${code} ${courseName}` : code;

            // Robust Detail Extraction
            const sectionEl = item.querySelector('.code.primary');
            const section = sectionEl ? sectionEl.innerText.trim() : "";

            const instrEl = item.querySelector('.section-instructor');
            const instructor = instrEl ? instrEl.innerText.replace('Instructor:', '').trim().replace(/\n/g, ', ') : "";

            // Precise Format Extraction
            const formatEl = Array.from(item.querySelectorAll('span')).find(el => {
                const t = el.innerText.trim();
                return t === 'In-person' || t === 'Online' || t === 'Hybrid';
            });
            const learningFormat = formatEl ? formatEl.innerText.trim() : "";

            // Precise Availability Extraction
            let availability = "";
            const badges = Array.from(item.querySelectorAll('.badge'));
            const statusBadge = badges.find(b => b.innerText.includes('SECTION IS'));
            const status = statusBadge ? statusBadge.innerText.replace('SECTION IS', '').trim() : "";
            
            const seatsBadge = badges.find(b => b.innerText.includes('SEATS'));
            if (seatsBadge) {
                const text = seatsBadge.innerText.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
                const match = text.match(/(\d+)\s+.*?(\d+)/);
                if (match) {
                    availability = `${status} (${match[1]}/${match[2]} available)`;
                } else {
                    availability = status ? `${status} (${text})` : text;
                }
            } else {
                availability = status;
            }

            // Precise Location Extraction
            let location = "";
            const locAnchor = Array.from(item.querySelectorAll('span')).find(el => el.innerText.includes('building room'));
            if (locAnchor) {
                location = locAnchor.parentElement.innerText.replace('building room', '').replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
            }

            const slnEl = Array.from(item.querySelectorAll('a')).find(a => a.href.includes('sln.asp') && /^\d{5}$/.test(a.innerText.trim()));
            const sln = slnEl ? slnEl.innerText.trim() : "";

            // Precise Credit Extraction
            let credits = "";
            const creditBadge = Array.from(item.querySelectorAll('.badge')).find(el => el.innerText.includes('CR') || el.innerText.includes('Credit'));
            if (creditBadge) credits = creditBadge.innerText.replace(/\n/g, ' ').trim();

            const restLinkEl = Array.from(item.querySelectorAll('a')).find(a => a.innerText.includes('Check enrollment restrictions'));
            const restrictionsLink = restLinkEl ? restLinkEl.href : (slnEl ? slnEl.href : "");

            // Days and Times extraction
            const daySpan = item.querySelector('span[title*="day"], span[title*="Monday"], span[title*="Tuesday"]');
            const times = item.querySelectorAll('time');

            if (daySpan && times.length >= 2) {
                const dayStr = daySpan.textContent.trim();
                const startTimeStr = times[0].getAttribute('datetime');
                const endTimeStr = times[1].getAttribute('datetime');

                if (startTimeStr && endTimeStr) {
                    const startMin = parse24hToMinutes(startTimeStr);
                    const endMin = parse24hToMinutes(endTimeStr);

                    const days = [];
                    let d = dayStr;
                    if (d.includes("Th")) {
                        days.push("Th");
                        d = d.replace("Th", "");
                    }
                    for (let char of d) {
                        if (['M','T','W','F'].includes(char)) days.push(char);
                    }

                    const colors = getCourseColor(code);
                    courses.push({
                        code, deptName, courseName, title, section, instructor, location, sln, restrictionsLink,
                        coursePageLink,
                        learningFormat, availability, credits,
                        days, start: startMin, end: endMin, bg: colors.bg, text: colors.text
                    });
                }
            }
        });
        
        return courses;
    }

    function parse24hToMinutes(timeStr) {
        if (!timeStr) return 0;
        const [h, m] = timeStr.split(':').map(Number);
        return h * 60 + m;
    }

    const UW_PALETTE = [
        { bg: '#39275B', text: '#ffffff' }, // Primary Purple
        { bg: '#C79900', text: '#000000' }, // Secondary Gold
        { bg: '#E3BF42', text: '#000000' }, // Background Gold
        { bg: '#DFDDE8', text: '#000000' }, // Background Light Purple
        { bg: '#5B8F22', text: '#ffffff' }, // Accent Bright Green
        { bg: '#0046AD', text: '#ffffff' }, // Accent Bright Blue
        { bg: '#C75B12', text: '#ffffff' }, // Accent Bright Orange
        { bg: '#165788', text: '#ffffff' }, // Muted Dark Blue
        { bg: '#BD4F19', text: '#ffffff' }, // Muted Burnt Orange
        { bg: '#4b2e83', text: '#ffffff' }, // Spirit Purple
        { bg: '#898F4B', text: '#000000' }, // Muted Olive
        { bg: '#93B1CC', text: '#000000' }  // Muted Blue
    ];

    function getCourseColor(title) {
        let hash = 0;
        for (let i = 0; i < title.length; i++) {
            hash = title.charCodeAt(i) + ((hash << 5) - hash);
        }
        const index = Math.abs(hash) % UW_PALETTE.length;
        return UW_PALETTE[index];
    }

    function renderCalendar(courses) {
        let modal = document.getElementById(CALENDAR_ID);
        if (modal) modal.remove();

        modal = document.createElement('div');
        modal.id = CALENDAR_ID;
        Object.assign(modal.style, {
            position: 'fixed',
            top: '0',
            left: '0',
            width: '100%',
            height: '100%',
            backgroundColor: 'rgba(0,0,0,0.8)',
            zIndex: '10000',
            display: 'flex',
            justifyContent: 'center',
            alignItems: 'center',
            fontFamily: '"Open Sans", sans-serif'
        });

        const content = document.createElement('div');
        Object.assign(content.style, {
            backgroundColor: '#fff',
            width: '95%',
            height: '95%',
            borderRadius: '8px',
            padding: '10px',
            overflow: 'hidden',
            position: 'relative',
            display: 'flex',
            flexDirection: 'column'
        });

        // Side Popover element (Google Calendar style)
        const popover = document.createElement('div');
        Object.assign(popover.style, {
            position: 'fixed',
            display: 'none',
            backgroundColor: '#39275B', // UW Purple
            color: 'white',
            padding: '16px',
            borderRadius: '8px',
            boxShadow: '0 8px 30px rgba(0,0,0,0.4)',
            zIndex: '10001',
            width: '320px',
            lineHeight: '1.4',
            border: '2px solid #C79900', // UW Gold border
            pointerEvents: 'auto'
        });
        document.body.appendChild(popover);

        const closeCalendar = (e) => {
            if (e && e.type === 'keydown' && e.key !== 'Escape') return;
            modal.remove();
            popover.remove();
            window.removeEventListener('keydown', closeCalendar);
        };
        window.addEventListener('keydown', closeCalendar);

        // Close entire calendar if clicking the backdrop (the modal div itself)
        modal.addEventListener('click', (e) => {
            if (e.target === modal) {
                closeCalendar();
            } else {
                popover.style.display = 'none';
            }
        });

        // Prevent clicks inside the popover from closing itself
        popover.addEventListener('click', (e) => {
            e.stopPropagation();
        });

        const header = document.createElement('div');
        Object.assign(header.style, {
            display: 'flex',
            justifyContent: 'space-between',
            alignItems: 'center',
            marginBottom: '10px',
            padding: '0 5px'
        });

        const calendarTitle = document.createElement('h2');
        calendarTitle.textContent = 'Planned Courses Calendar';
        calendarTitle.style.margin = '0';
        calendarTitle.style.color = '#39275B';
        header.appendChild(calendarTitle);

        const closeBtn = document.createElement('button');
        closeBtn.innerHTML = '&times; Close';
        closeBtn.onclick = closeCalendar;

        Object.assign(closeBtn.style, {
            padding: '8px 16px',
            cursor: 'pointer',
            backgroundColor: '#39275B', // UW Purple
            color: 'white',
            border: '1px solid #C79900', // UW Gold
            borderRadius: '4px',
            fontWeight: 'bold',
            fontSize: '14px',
            transition: 'all 0.2s ease',
            display: 'flex',
            alignItems: 'center',
            gap: '5px',
            boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
        });

        closeBtn.onmouseenter = () => {
            closeBtn.style.backgroundColor = '#C79900'; // UW Gold
            closeBtn.style.color = '#000000'; // Black
        };
        closeBtn.onmouseleave = () => {
            closeBtn.style.backgroundColor = '#39275B'; // UW Purple
            closeBtn.style.color = 'white';
        };
        header.appendChild(closeBtn);
        content.appendChild(header);

        // Calendar Grid
        const gridWrapper = document.createElement('div');
        Object.assign(gridWrapper.style, {
            flex: '1',
            overflowY: 'auto',
            position: 'relative',
            border: '1px solid #ccc'
        });

        const grid = document.createElement('div');
        Object.assign(grid.style, {
            display: 'flex',
            minHeight: '100%'
        });

        // Define time range
        const startHour = 7; // 7 AM
        const endHour = 22; // 10 PM
        const hourHeight = 80; // px

        // Time Column
        const timeCol = document.createElement('div');
        timeCol.style.width = '60px';
        timeCol.style.flexShrink = '0';
        timeCol.style.backgroundColor = '#f9f9f9';
        timeCol.style.borderRight = '1px solid #ccc';

        // Spacer for header
        const timeHeaderSpacer = document.createElement('div');
        timeHeaderSpacer.style.height = '30px';
        timeHeaderSpacer.style.borderBottom = '1px solid #ccc';
        timeCol.appendChild(timeHeaderSpacer);

        for (let h = startHour; h < endHour; h++) {
            const cell = document.createElement('div');
            cell.textContent = `${h > 12 ? h - 12 : h} ${h >= 12 ? 'PM' : 'AM'}`;
            Object.assign(cell.style, {
                height: `${hourHeight}px`,
                borderBottom: '1px solid #eee',
                textAlign: 'right',
                paddingRight: '5px',
                fontSize: '12px',
                color: '#666'
            });
            timeCol.appendChild(cell);
        }
        grid.appendChild(timeCol);

        // Prepare Day Events
        const dayMap = { 'M': 0, 'T': 1, 'W': 2, 'Th': 3, 'F': 4 };
        const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
        const dayEvents = [[], [], [], [], []];

        courses.forEach(course => {
            course.days.forEach(day => {
                const dayIdx = dayMap[day];
                if (dayIdx !== undefined) {
                    dayEvents[dayIdx].push({ ...course });
                }
            });
        });

        // Day Columns
        dayNames.forEach((dayName, dayIdx) => {
            const col = document.createElement('div');
            Object.assign(col.style, {
                flex: '1',
                position: 'relative',
                borderRight: '1px solid #ccc',
                minWidth: '100px'
            });

            // Header
            const header = document.createElement('div');
            header.textContent = dayName;
            Object.assign(header.style, {
                height: '30px',
                borderBottom: '1px solid #ccc',
                textAlign: 'center',
                fontWeight: 'bold',
                backgroundColor: '#f0f0f0',
                lineHeight: '30px',
                position: 'sticky',
                top: '0',
                zIndex: '10'
            });
            col.appendChild(header);

            // Content Container
            const contentContainer = document.createElement('div');
            contentContainer.style.position = 'relative';
            contentContainer.style.height = `${(endHour - startHour) * hourHeight}px`;

            // Grid lines
            for (let h = startHour; h < endHour; h++) {
                const line = document.createElement('div');
                line.style.height = `${hourHeight}px`;
                line.style.borderBottom = '1px solid #eee';
                line.style.boxSizing = 'border-box';
                contentContainer.appendChild(line);
            }

            // Layout Algorithm: Side-by-Side
            const events = dayEvents[dayIdx].sort((a, b) => a.start - b.start);
            const columns = [];
            events.forEach(ev => {
                let placed = false;
                for (let i = 0; i < columns.length; i++) {
                    if (columns[i] <= ev.start) {
                        ev.col = i;
                        columns[i] = ev.end;
                        placed = true;
                        break;
                    }
                }
                if (!placed) {
                    ev.col = columns.length;
                    columns.push(ev.end);
                }
            });

            const clusters = [];
            let currentCluster = [];
            let clusterMaxEnd = -1;

            events.forEach(ev => {
                if (currentCluster.length === 0) {
                    currentCluster.push(ev);
                    clusterMaxEnd = ev.end;
                } else {
                    if (ev.start < clusterMaxEnd) {
                        currentCluster.push(ev);
                        clusterMaxEnd = Math.max(clusterMaxEnd, ev.end);
                    } else {
                        clusters.push(currentCluster);
                        currentCluster = [ev];
                        clusterMaxEnd = ev.end;
                    }
                }
            });
            if (currentCluster.length > 0) clusters.push(currentCluster);

            // Render Events
            clusters.forEach(cluster => {
                let maxCol = 0;
                cluster.forEach(ev => maxCol = Math.max(maxCol, ev.col));
                const widthPercent = 100 / (maxCol + 1);

                cluster.forEach(ev => {
                    const top = ((ev.start / 60) - startHour) * hourHeight + (ev.start % 60) * (hourHeight/60);
                    const height = (ev.end - ev.start) * (hourHeight/60);

                    const eventEl = document.createElement('div');
                    const startH = Math.floor(ev.start / 60);
                    const startM = ev.start % 60;
                    const endH = Math.floor(ev.end / 60);
                    const endM = ev.end % 60;
                    const timeText = `${startH > 12 ? startH-12 : startH}:${startM.toString().padStart(2,'0')} - ${endH > 12 ? endH-12 : endH}:${endM.toString().padStart(2,'0')}`;

                    eventEl.innerHTML = `
                        <div style="font-weight:bold; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${ev.code}</div>
                        <div style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-size:0.95em; opacity:0.9;">${ev.courseName}</div>
                        <div style="font-size:0.85em; opacity:0.85; margin-top:1px;">${timeText}</div>
                    `;

                    Object.assign(eventEl.style, {
                        position: 'absolute',
                        top: `${top}px`,
                        left: `${ev.col * widthPercent}%`,
                        width: `${widthPercent}%`,
                        height: `${height}px`,
                        backgroundColor: ev.bg,
                        color: ev.text,
                        borderRadius: '4px',
                        padding: '2px 4px',
                        fontSize: '11px',
                        lineHeight: '1.2',
                        overflow: 'hidden',
                        opacity: '0.95',
                        zIndex: '5',
                        border: '1px solid white',
                        boxSizing: 'border-box',
                        cursor: 'pointer',
                        transition: 'transform 0.1s ease',
                        display: 'flex',
                        flexDirection: 'column',
                        justifyContent: height < 40 ? 'center' : 'start'
                    });

                    eventEl.onmouseenter = () => {
                        eventEl.style.transform = 'scale(1.02)';
                        eventEl.style.zIndex = '100';
                    };
                    eventEl.onmouseleave = () => {
                        eventEl.style.transform = 'scale(1)';
                        eventEl.style.zIndex = '5';
                    };

                    eventEl.onclick = (e) => {
                        e.stopPropagation();
                        
                        let content = `<div style="margin-bottom:4px;">`;
                        content += `<div style="color:#C79900; font-weight:800; font-size:1.25em; margin-bottom:2px; line-height:1.2;">${ev.code}</div>`;
                        content += `<a href="${ev.coursePageLink}" target="_blank" 
                                       onmouseover="this.style.textDecoration='underline';this.style.color='#8E632A';" 
                                       onmouseout="this.style.textDecoration='none';this.style.color='#C79900';"
                                       style="color:#C79900; text-decoration:none; font-weight:700; font-size:1.1em; line-height:1.2; display:inline-block; transition: color 0.2s ease;">${ev.courseName}</a>`;
                        content += `</div>`;
                        
                        if (ev.deptName) {
                            content += `<div style="font-size:0.85em; opacity:0.8; margin-bottom:12px; font-style:italic; border-bottom:1px solid rgba(255,255,255,0.1); padding-bottom:8px;">${ev.deptName}</div>`;
                        }

                        content += `<div style="display:grid; grid-template-columns: auto 1fr; gap: 8px 12px; font-size:0.95em; align-items: start;">`;
                        if (ev.sln) content += `<span style="opacity:0.7; font-size:0.9em; text-transform:uppercase; letter-spacing:0.05em;">SLN:</span><span style="font-family:monospace; font-weight:bold;">${ev.sln}</span>`;
                        if (ev.section) content += `<span style="opacity:0.7; font-size:0.9em; text-transform:uppercase; letter-spacing:0.05em;">Section:</span><span>${ev.section}</span>`;
                        if (ev.credits) content += `<span style="opacity:0.7; font-size:0.9em; text-transform:uppercase; letter-spacing:0.05em;">Credits:</span><span>${ev.credits}</span>`;
                        if (ev.learningFormat) content += `<span style="opacity:0.7; font-size:0.9em; text-transform:uppercase; letter-spacing:0.05em;">Format:</span><span>${ev.learningFormat}</span>`;
                        if (ev.instructor) content += `<span style="opacity:0.7; font-size:0.9em; text-transform:uppercase; letter-spacing:0.05em;">Instructor:</span><span>${ev.instructor}</span>`;
                        if (ev.location) content += `<span style="opacity:0.7; font-size:0.9em; text-transform:uppercase; letter-spacing:0.05em;">Location:</span><span>${ev.location}</span>`;
                        if (ev.availability) content += `<span style="opacity:0.7; font-size:0.9em; text-transform:uppercase; letter-spacing:0.05em;">Availability:</span><span>${ev.availability}</span>`;
                        content += `</div>`;

                        content += `<div style="margin-top:15px; border-top:1px solid rgba(255,255,255,0.2); padding-top:10px; display:flex; flex-direction:column; gap:8px;">`;
                        content += `<div style="font-weight:bold; font-size:1.0em; color:#C79900;">🕒 ${timeText}</div>`;
                        
                        if (ev.restrictionsLink) {
                            content += `<div style="font-size:0.9em;"><a href="${ev.restrictionsLink}" target="_blank" style="color:#C79900; text-decoration:underline; font-weight:600;">Check Enrollment Restrictions</a></div>`;
                        }
                        content += `</div>`;
                        
                        popover.innerHTML = content;
                        popover.style.display = 'block';

                        const rect = eventEl.getBoundingClientRect();
                        let left = rect.right + 10;
                        let top = rect.top;

                        if (left + 320 > window.innerWidth) {
                            left = rect.left - 330;
                        }
                        
                        if (top + popover.offsetHeight > window.innerHeight) {
                            top = window.innerHeight - popover.offsetHeight - 20;
                        }

                        popover.style.left = `${left}px`;
                        popover.style.top = `${top}px`;
                    };

                    contentContainer.appendChild(eventEl);
                });
            });

            col.appendChild(contentContainer);
            grid.appendChild(col);
        });

        gridWrapper.appendChild(grid);
        content.appendChild(gridWrapper);
        modal.appendChild(content);
        document.body.appendChild(modal);
    }

    function addTriggerButton() {
        const isPlanPage = /^#\/[a-z]{2}\d{2}/i.test(window.location.hash);
        const existingBtn = document.getElementById(BUTTON_ID);

        if (!isPlanPage) {
            if (existingBtn) existingBtn.remove();
            return;
        }

        if (existingBtn) return;

        const btn = document.createElement('button');
        btn.id = BUTTON_ID;
        btn.textContent = '📅 View Calendar';
        Object.assign(btn.style, {
            position: 'fixed',
            bottom: '20px',
            right: '20px',
            zIndex: '9999',
            padding: '10px 20px',
            backgroundColor: '#4b2e83', // UW Purple
            color: 'white',
            border: 'none',
            borderRadius: '5px',
            cursor: 'pointer',
            boxShadow: '0 2px 5px rgba(0,0,0,0.3)',
            fontSize: '16px'
        });

        btn.onclick = () => {
            const courses = findCourses();
            if (courses.length === 0) {
                alert("No courses found! Make sure you are on the 'Planned' or 'Schedule' page and course times are visible.");
            } else {
                renderCalendar(courses);
            }
        };

        document.body.appendChild(btn);
    }

    window.addEventListener('load', addTriggerButton);
    window.addEventListener('hashchange', addTriggerButton);
    setInterval(addTriggerButton, 2000);

})();