Gradescope Time Display Optimizer

Convert common 12-hour time text on webpages to 24-hour format

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

Advertisement:

// ==UserScript==
// @name         Gradescope Time Display Optimizer
// @version      1.2
// @namespace    https://github.com/yxzlwz/browser_scripts
// @description  Convert common 12-hour time text on webpages to 24-hour format
// @match        https://*.gradescope.com/*
// @run-at       document-end
// @grant        none
// ==/UserScript==
(() => {
    const TIME_SELECTOR = [
        'time.submissionTimeChart--dueDate[datetime]',
        'time.submissionTimeChart--releaseDate[datetime]'
    ].join(',');

    const STYLE_ID = 'gradescope-local-time-style';
    const STORAGE_KEY = 'gradescopeLocalTime.showTimezone';

    let scheduled = false;

    function getStoredShowTimezone() {
        const value = localStorage.getItem(STORAGE_KEY);
        return value === null ? true : value === 'true';
    }

    function setStoredShowTimezone(show) {
        localStorage.setItem(STORAGE_KEY, String(show));
    }

    function applyTimezoneVisibility() {
        document.documentElement.classList.toggle(
            'gs-local-time-hide-zone',
            !getStoredShowTimezone()
        );
    }

    function toggleTimezoneVisibility() {
        const next = !getStoredShowTimezone();
        setStoredShowTimezone(next);
        applyTimezoneVisibility();
    }

    function injectStyle() {
        if (document.getElementById(STYLE_ID)) return;

        const style = document.createElement('style');
        style.id = STYLE_ID;
        style.textContent = `
      time.gs-local-time {
        display: inline-flex;
        align-items: baseline;
        gap: 0.35em;
        white-space: nowrap;
        cursor: pointer;
      }

      .gs-local-time-main,
      .gs-local-time-prefix {
        white-space: nowrap;
      }

      .gs-local-time-dot {
        opacity: 0.45;
        font-size: 1em;
        font-weight: inherit;
      }

      .gs-local-time-zone {
        font-size: 1em;
        font-weight: inherit;
        opacity: 0.68;
        white-space: nowrap;
      }

      .gs-local-time-hide-zone .gs-local-time-dot,
      .gs-local-time-hide-zone .gs-local-time-zone {
        display: none;
      }

      .progressBar--caption time.gs-local-time + time.gs-local-time {
        margin-left: 0.85em;
      }

      .progressBar--caption br + time.gs-local-time {
        margin-left: 0;
      }
    `;
        document.head.appendChild(style);
    }

    function parseGradescopeDatetime(value) {
        const match = value.trim().match(
            /^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::(\d{2}))?\s+([+-]\d{2})(\d{2})$/
        );

        if (!match) return null;

        const [, y, mo, d, h, mi, s = '00', offsetHour, offsetMin] = match;
        const iso = `${y}-${mo}-${d}T${h}:${mi}:${s}${offsetHour}:${offsetMin}`;
        const date = new Date(iso);

        return Number.isNaN(date.getTime()) ? null : date;
    }

    function getGMTOffsetLabel(date = new Date()) {
        const offsetMinutes = -date.getTimezoneOffset();
        const sign = offsetMinutes >= 0 ? '+' : '-';
        const abs = Math.abs(offsetMinutes);
        const hours = Math.floor(abs / 60);
        const minutes = abs % 60;

        if (minutes === 0) return `GMT${sign}${hours}`;
        return `GMT${sign}${hours}:${String(minutes).padStart(2, '0')}`;
    }

    function getLocalTimeZoneName() {
        return Intl.DateTimeFormat().resolvedOptions().timeZone || getGMTOffsetLabel();
    }

    function formatReadableDateTime(date) {
        const now = new Date();
        const sameYear = date.getFullYear() === now.getFullYear();

        const weekday = date.toLocaleString('en-US', { weekday: 'short' });
        const month = date.toLocaleString('en-US', { month: 'short' });
        const day = date.getDate();
        const hour = String(date.getHours()).padStart(2, '0');
        const minute = String(date.getMinutes()).padStart(2, '0');

        if (sameYear) return `${weekday}, ${month} ${day}, ${hour}:${minute}`;
        return `${weekday}, ${month} ${day}, ${date.getFullYear()}, ${hour}:${minute}`;
    }

    function getKind(el) {
        const originalText = el.dataset.originalText || el.textContent.trim();
        const originalAriaLabel =
            el.dataset.originalAriaLabel || el.getAttribute('aria-label') || '';

        if (
            /^Late Due Date\s*:/i.test(originalText) ||
            /^Late Due Date\s+at\b/i.test(originalAriaLabel)
        ) {
            return 'Late Due Date';
        }

        if (el.classList.contains('submissionTimeChart--releaseDate')) {
            return 'Released';
        }

        if (el.classList.contains('submissionTimeChart--dueDate')) {
            return 'Due';
        }

        return 'Time';
    }

    function getDisplayPrefix(el) {
        return getKind(el) === 'Late Due Date' ? 'Late Due Date: ' : '';
    }

    function convertTimeElement(el) {
        const raw = el.getAttribute('datetime');
        if (!raw) return;

        const date = parseGradescopeDatetime(raw);
        if (!date) return;

        if (!el.dataset.originalText) {
            el.dataset.originalText = el.textContent.trim();
            el.dataset.originalAriaLabel = el.getAttribute('aria-label') || '';
        }

        const kind = getKind(el);
        const prefixText = getDisplayPrefix(el);
        const mainText = formatReadableDateTime(date);
        const zoneText = getGMTOffsetLabel(date);
        const fullText = `${prefixText}${mainText} ${zoneText}`;

        el.classList.add('gs-local-time');

        const newTitle = [
            `Local: ${fullText}`,
            `Time zone: ${getLocalTimeZoneName()}`,
            `Original: ${el.dataset.originalText}`,
            `Original datetime: ${raw}`,
            '',
            'Click to show/hide timezone'
        ].join('\n');

        if (el.title !== newTitle) {
            el.title = newTitle;
        }

        el.innerHTML = '';

        if (prefixText) {
            const prefix = document.createElement('span');
            prefix.className = 'gs-local-time-prefix';
            prefix.textContent = prefixText;
            el.append(prefix);
        }

        const main = document.createElement('span');
        main.className = 'gs-local-time-main';
        main.textContent = mainText;

        const dot = document.createElement('span');
        dot.className = 'gs-local-time-dot';
        dot.textContent = '·';

        const zone = document.createElement('span');
        zone.className = 'gs-local-time-zone';
        zone.textContent = zoneText;

        el.append(main, dot, zone);

        const aria = `${kind} at ${mainText} ${zoneText}`;
        if (el.getAttribute('aria-label') !== aria) {
            el.setAttribute('aria-label', aria);
        }
    }

    function isDateHeader(th) {
        const text = th.textContent || '';
        const aria = th.getAttribute('aria-label') || '';

        return (
            /Due Date/i.test(aria) ||
            /Release and due date/i.test(aria) ||
            /Release and due date/i.test(text) ||
            /\bDue\b/i.test(text)
        );
    }

    function convertHeaderTimezone(root = document) {
        const localZoneName = getLocalTimeZoneName();

        const headers = new Set();

        root.querySelectorAll?.('th').forEach(th => {
            if (isDateHeader(th)) headers.add(th);
        });

        if (root.matches?.('th') && isDateHeader(root)) {
            headers.add(root);
        }

        for (const th of headers) {
            const abbrs = th.querySelectorAll('abbr');

            for (const abbr of abbrs) {
                if (!abbr.dataset.originalText) {
                    abbr.dataset.originalText = abbr.textContent.trim();
                    abbr.dataset.originalTitle = abbr.getAttribute('title') || '';
                }

                abbr.textContent = localZoneName;
                abbr.setAttribute('title', localZoneName);
                abbr.dataset.gsLocalTimeZoneAbbr = 'true';
            }

            if (!th.dataset.originalAriaLabel) {
                th.dataset.originalAriaLabel = th.getAttribute('aria-label') || '';
            }

            const originalAria = th.dataset.originalAriaLabel;

            let newAria = originalAria;

            // Example:
            // "Release and due date in Eastern Time (US & Canada): activate to sort column ascending"
            // -> "Release and due date in Asia/Shanghai: activate to sort column ascending"
            newAria = newAria.replace(
                /(Release and due date in )[^:]+(:.*)?$/i,
                (_, prefix, suffix = '') => `${prefix}${localZoneName}${suffix}`
            );

            // Fallback for other possible header labels.
            newAria = newAria.replace(
                /(Due date in )[^:]+(:.*)?$/i,
                (_, prefix, suffix = '') => `${prefix}${localZoneName}${suffix}`
            );

            newAria = newAria.replace(
                /(Release date in )[^:]+(:.*)?$/i,
                (_, prefix, suffix = '') => `${prefix}${localZoneName}${suffix}`
            );

            if (newAria && th.getAttribute('aria-label') !== newAria) {
                th.setAttribute('aria-label', newAria);
            }
        }
    }

    function convertAll(root = document) {
        injectStyle();
        applyTimezoneVisibility();

        root.querySelectorAll?.(TIME_SELECTOR).forEach(convertTimeElement);

        if (root.matches?.(TIME_SELECTOR)) {
            convertTimeElement(root);
        }

        convertHeaderTimezone(root);
    }

    function scheduleConvertAll(root = document) {
        if (scheduled) return;

        scheduled = true;

        requestAnimationFrame(() => {
            scheduled = false;
            convertAll(root);
        });
    }

    document.addEventListener(
        'click',
        event => {
            const timeEl = event.target.closest?.('time.gs-local-time');
            if (!timeEl) return;

            event.preventDefault();
            event.stopPropagation();

            toggleTimezoneVisibility();
        },
        true
    );

    convertAll();

    const observer = new MutationObserver(() => {
        scheduleConvertAll();
    });

    observer.observe(document.documentElement, {
        childList: true,
        subtree: true,
        characterData: true,
        attributes: true,
        attributeFilter: [
            'datetime',
            'aria-label',
            'title',
            'class'
        ]
    });
})();