AtCoderVirtualTimer

バーチャルの開始までの時間、残り時間をコンテストと同じように表示します

// ==UserScript==
// @name         AtCoderVirtualTimer
// @namespace    ocha98-virtual-timer
// @version      0.2
// @description  バーチャルの開始までの時間、残り時間をコンテストと同じように表示します
// @author       Ocha98
// @match        https://atcoder.jp/contests/*
// @supportURL   https://github.com/ocha98/AtCoderVirtualTimer/issues
// @source       https://github.com/ocha98/AtCoderVirtualTimer
// @license      MIT
// ==/UserScript==

class Timer {
    constructor(targetElementId, startDate, durationMinutes) {
        this.startDate = startDate;
        this.endDate = new Date(this.startDate.getTime() + durationMinutes * 60 * 1000);  // durationMinutesをミリ秒に変換してendDateを計算
        this.targetElement = document.getElementById(targetElementId);
        this.virtualTimer = this.createVirtualTimer();
        this.timeDelta = Cookies.getJSON("timeDelta");
        document.body.appendChild(this.virtualTimer);

        if(typeof this.timeDelta === 'undefined'){
            this.timeDelta = 0;
        }
    }

    now() {
        const date = new Date();
        date.setTime(date.getTime() + this.timeDelta);
        return date;
    }

    createVirtualTimer() {
        const p = document.createElement('p');
        p.classList.add("contest-timer")
        p.id = 'virtual-timer';
        p.style.position = 'fixed';
        p.style.right = '10px';
        p.style.bottom = '0';
        p.style.width = '160px';
        p.style.height = '80px';
        p.style.margin = '0';
        p.style.padding = '20px 0';
        p.style.backgroundImage = 'url("//img.atcoder.jp/assets/contest/digitalclock.png")';
        p.style.textAlign = 'center';
        p.style.lineHeight = '20px';
        p.style.fontSize = '15px';
        p.style.cursor = 'pointer';
        p.style.zIndex = '50';
        p.style.userSelect = 'none';
        p.style.display = 'none';  // 初めから非表示にする
        return p;
    }

    start() {
        if (this.intervalId) { return; }
        this.bindClickEvent();

        this.targetElement.style.display = 'none';
        this.virtualTimer.style.display = 'block';

        const updateDisplay = () => {
            const nowDate = this.now();
            if (this.startDate > nowDate) {
                // バーチャル開始前
                const formattedTimeDiff = this.formatTimeDifference(this.startDate, nowDate);
                this.virtualTimer.innerHTML = `開始まであと<br/>${formattedTimeDiff}`;
            } else if(nowDate < this.endDate) {
                // バーチャル中
                const formattedTimeDiff = this.formatTimeDifference(this.endDate, nowDate);
                this.virtualTimer.innerHTML = `残り時間<br/>${formattedTimeDiff}`;
            } else {
                // バーチャル終了後
                this.targetElement.innerHTML = "";
                this.stop();
                this.unbindClickEvent();  // イベント破棄
            }
        };

        updateDisplay();
        this.intervalId = setInterval(updateDisplay, 1000);
    }

    stop() {
        if (this.intervalId) {
            clearInterval(this.intervalId);
            this.intervalId = null;
        }
    }

    formatTimeDifference(startDate, nowDate) {
        const diffInSeconds = (startDate - nowDate) / 1000;
        const days = Math.floor(diffInSeconds / 86400);
        const hours = Math.floor((diffInSeconds % 86400) / 3600);
        const minutes = Math.floor((diffInSeconds % 3600) / 60);
        const seconds = Math.floor(diffInSeconds % 60);

        if (days > 0) {
            return `${days}日と${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
        } else {
            return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
        }
    }

    unbindClickEvent() {
        // イベントを破棄するためのメソッド
        this.targetElement.removeEventListener('click', this.toggleDisplay);
        this.virtualTimer.removeEventListener('click', this.toggleDisplay);
        this.virtualTimer.style.display = 'none';
        this.targetElement.style.display = 'block';
    }

    bindClickEvent() {
        // バチャの時計ともとからある時計を切り替える
        this.toggleDisplay = (e) => {
            if (e.target === this.targetElement && this.targetElement.style.opacity == 1) {
                this.targetElement.style.display = 'none';
                this.virtualTimer.style.display = 'block';
            } else {
                this.virtualTimer.style.display = 'none';
                this.targetElement.style.display = 'block';
            }
        };
        this.targetElement.addEventListener('click', this.toggleDisplay);
        this.virtualTimer.addEventListener('click', this.toggleDisplay);
    }
}

async function getVirtualContestPage() {
    const contestName = window.location.pathname.split('/')[2];
    const virtualUrl = `https://atcoder.jp/contests/${contestName}/virtual`;

    const response = await fetch(virtualUrl);
    if (!response.ok) {
        throw new Error(`Failed to fetch ${virtualUrl}. response status: ${response.status} status text: ${response.statusText}`);
    }

    const pageContent = await response.text();

    const parser = new DOMParser();
    return parser.parseFromString(pageContent, "text/html");
}

// コンテスト時間を分で返す
function getContestDurationMinutes(dom) {
    const contestDurationElement = dom.querySelectorAll('small.contest-duration a');
    // YYYYMMDDTHHMM
    const strToDate = (str) => {
        return new Date(
            parseInt(str.substring(0, 4)), // year
            parseInt(str.substring(4, 6)) - 1, // month (0-indexed)
            parseInt(str.substring(6, 8)), // day
            parseInt(str.substring(9, 11)), // hour
            parseInt(str.substring(11, 13)) // minute
        );
    };
    const startMatch = contestDurationElement[0].href.match(/iso=(\d+T\d+)/);
    const endMatch = contestDurationElement[1].href.match(/iso=(\d+T\d+)/);

    const startStr = startMatch[1];
    const endStr = endMatch[1];

    const startDate = strToDate(startStr);
    const endDate = strToDate(endStr);

    const durationMinutes = (endDate.getTime() - startDate.getTime()) / (1000 * 60); // ミリ秒を分に変換

    return durationMinutes;
}

// バチャの開始時刻を取得
function getVirtualStartTime(dom) {
    const timeElement = dom.querySelector('#main-container time.fixtime-second');
    const timeText = timeElement.textContent.trim();
    return new Date(timeText);
}

async function main() {
    try {
        const dom = await getVirtualContestPage()
        const virtualStartDate = getVirtualStartTime(dom);
        const contestDurationMinutes = getContestDurationMinutes(dom);

        const timer = new Timer('fixed-server-timer', virtualStartDate, contestDurationMinutes);
        timer.start();
    } catch (error) {
        console.error(error);
    }
}

main();