Gradescope Time Display Optimizer

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

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

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.

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

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

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.

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!)

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