Gradescope Time Display Optimizer

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

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

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

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

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

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

Advertisement:

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.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

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'
        ]
    });
})();