AtCoder Problems Color Mod

AtCoder Problems のユーザページ上で色の塗り方を細分化します

// ==UserScript==
// @name         AtCoder Problems Color Mod
// @namespace    iilj
// @version      2020.01.16.1
// @description  AtCoder Problems のユーザページ上で色の塗り方を細分化します
// @author       iilj
// @supportURL   https://github.com/iilj/AtCoderProblemsColorMod/issues
// @match        https://kenkoooo.com/atcoder/*
// @grant        GM_addStyle
// ==/UserScript==

(function () {
    'use strict';

    GM_addStyle(`
td.apcm-intime {
    background-color: #9AD59E;
    position: relative;
}
td.apcm-intime-writer {
    background-color: #9CF;
}
td.apcm-intime-nonac {
    background-color: #F5CD98;
    position: relative;
}
div.apcm-timespan {
    position: absolute;
    right: 0;
    bottom: 0;
    color: #888;
    font-size: x-small;
}

/* style for list table */
.react-bs-table .table-striped tbody tr td, .react-bs-table thead th {
    font-size: small;
    padding: 0.3rem;
    line-height: 1.0;
    white-space: normal;
}
.react-bs-table .table-striped tbody tr.apcm-intime td {
    background-color: #9AD59E;
    border-color: #DDD;
}
.react-bs-table .table-striped tbody tr.apcm-intime-writer td {
    background-color: #9CF;
}
.react-bs-table .table-striped tbody tr.apcm-intime-nonac td {
    background-color: #F5CD98;
}

`);

    /**
     * AtCoder コンテストの URL を返す.
     *
     * @param {string} contestId コンテスト ID
     * @returns {string} AtCoder コンテストの URL
     */
    const getContestUrl = (contestId) => `https://atcoder.jp/contests/${contestId}`;

    /**
     * AtCoder コンテストの問題 URL を返す.
     *
     * @param {string} contestId コンテスト ID
     * @param {string} problemId 問題 ID
     * @returns {string} AtCoder コンテストの問題 URL
     */
    const getProblemUrl = (contestId, problemId) => `${getContestUrl(contestId)}/tasks/${problemId}`;

    /**
     * url string to json object
     *
     * @date 2020-01-15
     * @param {string} uri 取得するリソースのURI
     * @returns {Promise<Object[]>} 配列
     */
    async function getJson(uri) {
        const response = await fetch(uri);
        /** @type {Object[]} */
        const obj = await response.json();
        return obj;
    }

    /**
     * get contestId->contest map and contestUrl->contestId map
     *
     * @date 2020-01-15
     * @returns {Promise<Object[]>} array [contestId->contest map, contestUrl->contestId map]
     */
    async function getContestsMap() {
        const contests = await getJson('https://kenkoooo.com/atcoder/resources/contests.json');
        const contestsMap = contests.reduce((hash, contest) => {
            hash[contest.id] = contest;
            return hash;
        }, {});
        const contestsUrl2Id = contests.reduce((hash, contest) => {
            hash[getContestUrl(contest.id)] = contest.id;
            return hash;
        }, {});
        return [contestsMap, contestsUrl2Id];
    }

    /**
     * return problemUrl->submit map from userId string
     *
     * @date 2020-01-15
     * @param {string} userId
     * @returns {Promise<Object>} problemUrl->submit map
     */
    async function getUserResultsMap(userId) {
        const userResults = await getJson(`https://kenkoooo.com/atcoder/atcoder-api/results?user=${userId}`);
        const userResultsMap = userResults.reduce((hash, submit) => {
            const key = getProblemUrl(submit.contest_id, submit.problem_id);
            if (key in hash) {
                // ACなら,なるべく昔のACを保持する
                if (submit.result == 'AC') {
                    if (hash[key].result != 'AC') { // AC 済みではないなら,最新の結果で上書き
                        hash[key] = submit;
                    } else if (submit.epoch_second < hash[key].epoch_second) {// AC同士なら,なるべく昔のACを保持する
                        hash[key] = submit;
                    }
                } else {
                    if (hash[key].result != 'AC' && submit.epoch_second < hash[key].epoch_second) { // ペナ同士なら,なるべく昔の提出を保持する
                        hash[key] = submit;
                    }
                }
            } else {
                hash[key] = submit;
            }
            return hash;
        }, {});
        return userResultsMap;
    }

    /**
     * return contestId->[problemId] map
     *
     * @date 2020-01-15
     * @returns {Promise<Object>} contestId->[problemId] map
     */
    async function getContestProblemListMap() {
        const contestProblem = await getJson('https://kenkoooo.com/atcoder/resources/contest-problem.json');
        const contestProblemListsMap = contestProblem.reduce((hash, problem) => {
            if (problem.contest_id in hash) {
                hash[problem.contest_id].push(problem.problem_id);
            } else {
                hash[problem.contest_id] = [problem.problem_id];
            }
            return hash;
        }, {});
        return contestProblemListsMap;
    }

    /**
     * 時間(秒)を表す整数値を mm:ss の形にフォーマットする.
     *
     * @param {number} sec 時間(秒)を表す整数値
     * @returns {string} mm:ss の形にフォーマットされた文字列
     */
    const formatTimespan = sec => {
        let sign;
        if (sec >= 0) {
            sign = '';
        } else {
            sign = '-';
            sec *= -1;
        }
        return `${sign}${Math.floor(sec / 60)}:${('0' + (sec % 60)).slice(-2)}`;
    }

    /**
     * Table 表示ページで "Show Accepted" の変更検知に利用する MutationObserver
     * 
     * @type {MutationObserver}
     */
    let tableObserver;

    /**
     * Table 表示ページで表のセルの色を塗り分ける.
     *
     * @date 2020-01-16
     * @param {string} userId
     */
    async function processTable(userId) {
        const [contestsMap, contestsUrl2Id] = await getContestsMap();
        const userResultsMap = await getUserResultsMap(userId);
        const contestProblemListsMap = await getContestProblemListMap();

        const tableChanged = () => {
            if (tableObserver) {
                tableObserver.disconnect();
            }
            document.querySelectorAll('td.table-success, td.table-warning').forEach(td => {
                const lnk = td.querySelector('a[href]');
                if (lnk.href in userResultsMap) {
                    const userResult = userResultsMap[lnk.href];
                    const contest = contestsMap[userResult.contest_id];
                    if (userResult.epoch_second <= contest.start_epoch_second + contest.duration_second) {
                        td.classList.add(td.classList.contains('table-success') ? 'apcm-intime' : 'apcm-intime-nonac');
                        if (userResult.epoch_second < contest.start_epoch_second) {
                            td.classList.add('apcm-intime-writer');
                        }
                        const divTimespan = document.createElement("div");
                        divTimespan.innerText = formatTimespan(userResult.epoch_second - contest.start_epoch_second);
                        divTimespan.classList.add('apcm-timespan');
                        td.insertAdjacentElement('beforeend', divTimespan);
                    }
                } else if (lnk.href in contestsUrl2Id) {
                    const contestId = contestsUrl2Id[lnk.href];
                    const contest = contestsMap[contestId];
                    const contestProblemList = contestProblemListsMap[contestId];
                    if (contestProblemList.every(problemId => {
                        const key = getProblemUrl(contestId, problemId);
                        if (key in userResultsMap) {
                            const userResult = userResultsMap[key];
                            return (userResult.epoch_second <= contest.start_epoch_second + contest.duration_second);
                        }
                        return false;
                    })) {
                        td.classList.add('apcm-intime');
                        if (contestProblemList.every(problemId => {
                            const key = getProblemUrl(contestId, problemId);
                            const userResult = userResultsMap[key];
                            return (userResult.epoch_second < contest.start_epoch_second);
                        })) {
                            td.classList.add('apcm-intime-writer');
                        }
                    }
                }
            });
            if (tableObserver) {
                document.querySelectorAll('.react-bs-container-body').forEach(div => {
                    tableObserver.observe(div, { childList: true, subtree: true });
                });
            }
        };

        tableObserver = new MutationObserver(mutations => tableChanged());
        tableChanged();
        document.querySelectorAll('.react-bs-container-body').forEach(div => {
            tableObserver.observe(div, { childList: true, subtree: true });
        });
    }

    /**
     * List 表示ページでページ移動の検知に利用する MutationObserver
     *
     * @type {MutationObserver}
     */
    let listObserver;

    /**
     * List 表示ページで表の行の色を塗り分ける.
     *
     * @date 2020-01-15
     * @param {string} userId ユーザID
     */
    async function processList(userId) {
        const [contestsMap, contestsUrl2Id] = await getContestsMap();
        const userResultsMap = await getUserResultsMap(userId);
        const contestProblemListsMap = await getContestProblemListMap();

        const tbl = document.querySelector('.react-bs-table');
        const tableChanged = () => {
            tbl.querySelectorAll('tr.table-success, tr.table-warning').forEach(tr => {
                const lnk = tr.querySelector('a[href]');
                if (lnk.href in userResultsMap) {
                    const userResult = userResultsMap[lnk.href];
                    const contest = contestsMap[userResult.contest_id];
                    if (userResult.epoch_second <= contest.start_epoch_second + contest.duration_second) {
                        tr.classList.add(tr.classList.contains('table-success') ? 'apcm-intime' : 'apcm-intime-nonac');
                        if (userResult.epoch_second < contest.start_epoch_second) {
                            tr.classList.add('apcm-intime-writer');
                        }
                    }
                }
            });
        };
        listObserver = new MutationObserver(mutations => tableChanged());
        tableChanged();
        listObserver.observe(tbl, { childList: true, subtree: true });
    }

    /**
     * ページ URL が変化した際のルートイベントハンドラ.
     *
     * @date 2020-01-15
     */
    const hrefChanged = () => {
        if (tableObserver) {
            tableObserver.disconnect();
        }
        if (listObserver) {
            listObserver.disconnect();
        }

        /** @type {RegExpMatchArray} */
        let result;
        if (result = location.href.match(/^https?:\/\/kenkoooo\.com\/atcoder\/#\/table\/([^/?#]+)/)) {
            const userId = result[1];
            processTable(userId);
        }
        else if (result = location.href.match(/^https?:\/\/kenkoooo\.com\/atcoder\/#\/list\/([^/?#]+)/)) {
            const userId = result[1];
            processList(userId);
        }
    };

    let href = location.href;
    const observer = new MutationObserver(mutations => {
        if (href === location.href) {
            return;
        }
        // href changed
        href = location.href;
        hrefChanged();
    });
    observer.observe(document, { childList: true, subtree: true });
    hrefChanged();
})();