// ==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 = `
` 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 = `
00:00
` 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: allow 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); } })();