// ==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);
}
})();