Greasy Fork is available in English.

Lichess Puzzle Timer

Adds a timer to the lichess.org puzzle trainer

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

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

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==UserScript==
// @name         Lichess Puzzle Timer
// @namespace    http://tampermonkey.net/
// @license      MIT
// @version      1.7
// @description  Adds a timer to the lichess.org puzzle trainer
// @author       https://github.com/cristoper/
// @match        https://lichess.org/training*
// @resource     style https://raw.githubusercontent.com/cristoper/lichess-puzzle-timer/refs/heads/main/lctimer.css
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_getResourceText
// @grant        GM_addStyle
// ==/UserScript==
(function() {
    'use strict';

    // Storage abstraction that works in both browser extensions and Tampermonkey
    class Storage {
        static setItem(key, value) {
            if (typeof chrome !== 'undefined' && chrome.storage) {
                const val = {};
                val[key] = value;
                return chrome.storage.local.set(val);
            } else {
                return GM_setValue(key, value);
            }
        }

        static getItem(key) {
            if (typeof chrome !== 'undefined' && chrome.storage) {
                return chrome.storage.local.get(key);
            } else {
                let result = {};
                const v = GM_getValue(key);
                result[key] = v;
                return Promise.resolve(result);
            }
        }
    }

    class LCSettings {
        constructor() {
            // TODO set/get settings from local storage
            this.enabled = true;
            this.slowMode = true;
            this.autoFail = false;
            this.startTime = 60 * 1000; // ms
            this.settingsCallback = function() {};

            const template =
    `
    <dialog id='lctimer-settings'>

        <div class="lctimer-set">
            <input type="number" id="lctimer-min" min="0" max="59" value="0">
            <label for="lctimer-min">Minutes</label>
            <input type="number" id="lctimer-time" min="0" max="59" value="0">
            <label for="lctimer-time">Seconds</label>
        </div>

        <group id='lctimer-mode' class='radio'>
            <div>
                <input id="lctimer-mode-slow" type="radio" value="slow" name="mode"><label for="lctimer-mode-slow">Thinking mode</label>
            </div>
            <div>
                <input id="lctimer-mode-fast" type="radio" value="fast" name="mode" checked=""><label for="lctimer-mode-fast">Blitz mode</label>
            </div>
        </group>

        <div class="lcsettings-row">
            <label for="autofail-btn">Autofail (lose points if too slow):</label>
            <div class="cmn-toggle">
                <input id="autofail-btn" class="form-control cmn-toggle" name="autofail" value="false" type="checkbox" checked="unchecked">
                <label for="autofail-btn"></label>
            </div>
        </div>

        <form method="dialog"><button id="doneBtn" class="button">Done</button></form>
    </dialog>
    `
            document.body.insertAdjacentHTML('beforeend', template);
            this.settingsDialog = document.getElementById('lctimer-settings');
            this.minInput = document.getElementById('lctimer-min');
            this.timeInput = document.getElementById('lctimer-time');
            this.modeSlow = document.getElementById('lctimer-mode-slow');
            this.modeFast = document.getElementById('lctimer-mode-fast');
            this.autoFailBtn = document.getElementById('autofail-btn');
            this.doneBtn = document.getElementById('doneBtn');

            // load settings
            this.loadSettings();
            
            const modeGroup = document.getElementById('lctimer-mode');
            modeGroup.addEventListener('change', () => {
                if (this.modeSlow.checked) {
                    document.querySelector('.lcsettings-row').style.visibility = 'hidden';
                } else {
                    document.querySelector('.lcsettings-row').style.visibility = 'visible';
                }
            });

            // when dialog closes, update settings and emit event
            this.settingsDialog.addEventListener('close', (e) => {
                let didUpdate = false;
                if (this.minInput.value == "") {
                    this.minInput.value = 0;
                }
                if (this.timeInput.value == "") {
                    if (this.minInput.value == 0) {
                        this.timeInput.value = Math.floor(this.startTime / 1000 % 60);
                    } else {
                        this.timeInput.value = 0;
                    }
                }

                let newTime = (parseInt(this.minInput.value) * 60 + parseInt(this.timeInput.value)) * 1000;
                const newMode = this.modeSlow.checked;
                const newAutoFail = this.autoFailBtn.checked;

                if (newTime < 1000) {
                    newTime = this.startTime;
                }

                if (this.startTime != newTime) {
                    this.startTime = newTime;
                    didUpdate = true;
                }
                if (this.slowMode != newMode) {
                    this.slowMode = newMode;
                    didUpdate = true;
                }
                if (this.autoFail != newAutoFail) {
                    this.autoFail = newAutoFail;
                    didUpdate = true;
                }

                this.saveSettings();
                this.updateDOM(); // to nromalize minute and second text inputs


                if (didUpdate) {
                    this.settingsChanged();
                }
            });

        }

        settingsChanged() {
            this.settingsCallback();
            this.updateDOM();
        }

        updateDOM() {
            this.minInput.value = Math.floor(this.startTime / 1000 / 60);
            this.timeInput.value = Math.floor(this.startTime / 1000 % 60);
            this.modeSlow.checked = this.slowMode;
            this.modeFast.checked = !this.slowMode;
            this.autoFailBtn.checked = this.autoFail;
            // only show autofail when Blitz mode selected
            if (this.modeSlow.checked) {
                document.querySelector('.lcsettings-row').style.visibility = 'hidden';
            } else {
                document.querySelector('.lcsettings-row').style.visibility = 'visible';
            }
        }

        // load settings from chrome.local
        async loadSettings() {
            const settings = Storage.getItem("settings").then((items) => {
                if (typeof items!== 'undefined' && items.settings) {
                    this.enabled = items.settings.enabled;
                    this.slowMode = items.settings.slowMode;
                    this.autoFail = items.settings.autoFail;
                    this.startTime = items.settings.startTime;
                }
                this.settingsChanged();
            });
        }

        saveSettings() {
            Storage.setItem("settings", {
                enabled: this.enabled,
                slowMode: this.slowMode,
                autoFail: this.autoFail,
                startTime: this.startTime
            });
        }

        showDialog() {
            this.settingsDialog.showModal();
        }
    }

    class LCPuzzleTimer {
        constructor(container, settings) {
            this.container = container;
            this.settings = settings;
            this.running = false;
            this.time = settings.startTime;

            this.tickFreq = 100; //ms
            this.lastTick = null;
            this.lastColonFlash = Date.now();
            this.startTime = null;

            this.flashBG = false;

            const template = `
    <div class="cmn-toggle" role="button" title="Toggle Lichess Timer Extension">
    <input id="lctimer-toggle-enabled" class="cmn-toggle cmn-toggle--subtle" type="checkbox">
    <label for="lctimer-toggle-enabled"></label>
    </div>

    <div id='lcpuzzletimer'><span id='lcminutes'>00</span><span id='lccolon'>:</span><span id='lcseconds'>00</span></div>

    <button class="settings-gear" role="button" data-icon="" id="lctimer-settings-btn" title="Lichess Puzzle Timer settings"></button>
    `
            container.insertAdjacentHTML('beforeend', template)

            this.board = document.querySelector("cg-board");
            this.lcminutes = document.getElementById('lcminutes')
            this.lcseconds = document.getElementById('lcseconds')
            this.lccolon = document.getElementById('lccolon')

            this.settingsButton = document.getElementById('lctimer-settings-btn');

            // so that we only add a single event listener to cg-board
            this.boundClickedBoard = this.clickedBoard.bind(this);

            // event listeners
            this.settings.settingsCallback = () => {
                this.reset();
                if (this.settings.enabled) {
                    this.start();
                }
            };

            this.settingsButton.addEventListener('click', this.clickedSettings.bind(this));

            this.enableButton = document.getElementById('lctimer-toggle-enabled');
            this.enableButton.addEventListener('change', (e) => {
                if (e.target.checked) {
                    this.settings.enabled = true;
                    this.reset();
                    this.start();
                } else {
                    this.settings.enabled = false;
                    this.reset();
                }
                this.settings.saveSettings();
            });

            // We detect when a new puzzle starts by detecting when
            // the div.puzzle_feedback.after element is removed from puzzle__tools
            const board = document.querySelector(".main-board");
            const puzzletools = document.querySelector(".puzzle__tools");
            const cgCallback = (mutationsList, observer) => {
                for (let mutation of mutationsList) {
                    if (mutation.type === 'childList') {
                        // if div.puzzle__feedback.after is removed, assume new puzzle
                        for (let node of mutation.removedNodes) {
                            if (node.classList && node.classList.contains('puzzle__feedback')
                                && node.classList.contains('after'))
                            {
                                this.newPuzzle();
                                return;
                            }
                        }
                        // check for success and fail feedback messages
                        for (let node of mutation.addedNodes) {
                            if (node.classList && node.classList.contains('puzzle__feedback')
                                && node.classList.contains('fail'))
                            {
                                this.puzzleFailed();
                                return;
                            }
                            if (node.classList && node.classList.contains('puzzle__feedback') && node.classList.contains('after'))
                            {
                                this.puzzleSucceeded();
                                return;
                            }
                        }
                    }
                }
            };
            const cgobserver = new MutationObserver(cgCallback);
            cgobserver.observe(puzzletools, {childList: true, subtree: true});

            this.reset();
        }

        clickedSettings() {
            this.settings.showDialog();
        }

        newPuzzle() {
            this.reset();
            this.start();
        }

        puzzleFailed() {
            // in Blitz mode when the user fails puzzle
            // do nothing: all player to keep trying with timer running
            // this.expired();
        }

        puzzleSucceeded() {
            // in Blitz mode when the user succeeds puzzle
            this.stop();
        }

        reset() {
            this.stop();
            this.flashBG = false;
            this.time = this.settings.startTime;

            // the reference to cg-board can break between puzzles
            this.board = document.querySelector("cg-board");
            this.board.addEventListener("mousedown", this.boundClickedBoard, true);

            this.render();
        }

        start() {
            if (!this.settings.enabled) {
                return;
            }
            this.running = true;
            this.startTime = Date.now();
            this.lastTick = this.startTime;
            this.timer = setInterval(() => {
                this.tick();
            }, this.tickFreq);
        }

        stop() {
            if (this.timer) {
                this.running = false;
                clearInterval(this.timer);
                this.timer = null;
            }
        }

        expired() {
            this.stop();
            this.time = 0;

            if (!this.settings.slowMode && this.settings.autoFail) {
                clickViewSolution();
            }
            this.render();
        }


        tick() {
            if (this.running) {
                const now = Date.now();
                const diff = now - this.lastTick;
                this.time -= diff;
                this.lastTick = now;
                if (this.time <= 0) {
                    this.expired();
                }
            }
            this.render();
        }

        renderBoardBackground() {
            if (!this.settings.enabled) {
                this.board.style.boxShadow = "";
                return;
            }
            if (this.settings.slowMode) {
                if (this.running) {
                    if (this.flashBG) {
                        this.board.style.boxShadow = "0px 0px 8px 2px red";
                    } else {
                        this.board.style.boxShadow = "0px 0px 6px 1px red";
                    }
                } else {
                    this.board.style.boxShadow = "0px 0px 6px 1px green";
                }
            } else {
                // fast mode
                if (this.running) {
                    this.board.style.boxShadow = "0px 0px 6px 1px green";
                } else {
                    this.board.style.boxShadow = "0px 0px 6px 1px red";
                }
            }
        }

        renderSettings() {
            this.enableButton.checked = this.settings.enabled;
        }

        render() {
            const minutes = Math.floor((this.time / 1000) / 60);
            const seconds = (this.time / 1000) % 60;

            let fixed = 1;
            if (this.time > 10 * 1000) {
                fixed = 0;
            }

            this.renderSettings();
            this.renderBoardBackground();

            this.lcminutes.textContent = minutes.toString().padStart(2, '0');
            this.lcseconds.textContent = seconds.toFixed(fixed).padStart(2, '0');

            if (Date.now() - this.lastColonFlash > 500) {
                if (this.lccolon.style.opacity == 1 ) {
                    this.lccolon.style.opacity = 0.5;
                } else {
                    this.lccolon.style.opacity = 1.0;
                }
                this.lastColonFlash = Date.now();
            }
        }

        clickedBoard(event) {
            // user tried to click board while locked, flash the background
            // and block event
            const leftButton = event.button === 0;
            if (this.running && this.settings.enabled && this.settings.slowMode && leftButton) {
                this.flashBG = true;
                setTimeout(() => {
                    this.flashBG = false;
                }, 100);
                event.stopPropagation(); // prevent making moves on the board
            }
        }

    }

    function clickViewSolution() {
        document.querySelector(".view_solution").querySelectorAll("a")[1].click();
    }

    function startExt() {
        // tampermonkey: load css
        if (typeof GM_addStyle !== 'undefined') {
            const cssstyle = GM_getResourceText("style");
            GM_addStyle(cssstyle);
        }

        // Create container at top of tools
        const puzzle_tools = document.querySelector(".puzzle__tools");
        const timer = document.createElement("div")
        timer.className = "lctimer-container";
        puzzle_tools.prepend(timer);

        const settings = new LCSettings();
        const app = new LCPuzzleTimer(timer, settings);

        app.newPuzzle();
    }

    // set up timer on load
    if (document.readyState === 'complete') {
        startExt();
    } else {
        window.addEventListener("load", startExt);
    }
})();