MyPlan Calendar

Add a calendar view to MyPlan

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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);

})();