UNPKG

@dilapidated-penguin/cubetimer

Version:

fast and lightweight CLI timer for speedcubing. Track your solves, get random scrambles, and analyze your times

557 lines (479 loc) 21.5 kB
#! /usr/bin/env node import chalk from "chalk"; import { Command } from "commander"; import {event_choices,events_list} from './events.json' import {activeWindowSync} from 'get-windows'; import { createTable } from 'nice-table'; import { select,number, input,Separator} from '@inquirer/prompts'; import { plot, Plot } from 'nodeplotlib' import { spawn } from 'child_process'; import {settings, sessionLog, session_statistics, SolveInstance} from "./util/interfaces" import * as storage from "./util/storage" import * as settingsUtil from "./util/settings" import path from 'path' var Scrambow = require('scrambow').Scrambow; const cfonts = require('cfonts'); import {string as cli_title_string} from './cli-title.json' const program = new Command(); var saved_data = storage.loadData() //main_window_id let main_window_id:number| null = null //timer variables********************************** let timer_running:boolean = false let startTime:[number,number] | null = null let space_been_pressed:boolean = false let new_scramble:boolean = false let solve_labelled:boolean = false import {GlobalKeyboardListener} from "@futpib/node-global-key-listener"; const listener = new GlobalKeyboardListener(); //************************************************* console.log(cli_title_string) function normalizeArg(arg:string):string|null{ const aliases = { fastest_solve: ['f','b','best','fast','fastest','fastest_time'], slowest_solve: ['w','s','worst','slow','slowest','slowest_timer'], session_mean: ['m','mean','avg','average','session_mean'], standard_deviation:['dev','standard_deviation','std.dev','deviation','d'], variance:['var','v','variance','var.'] } for(const [key,val] of Object.entries(aliases)){ if((key === arg) || (val.includes(arg))){ return key } } return null } program .version("1.0.16") .description("fast and lightweight CLI timer for speedcubing. Cstimer in the command line (in progress)") program .command('graph') .argument('<property>','desired statistic to graph') .description(`generate a graph of one of the below stats: \n session_mean \n standard_deviation \n variance \n fastest_solve \n slowest_solve`) .action((property:string)=>{ const normalized_property:string = normalizeArg(property) if(normalized_property !== null){ const session_data:Map<string,session_statistics> = storage.loadStats().session_data if(session_data.size >=0){ const x_dates:Date[] = Array.from(session_data.keys()) .map((ISO_date)=>{ return new Date(ISO_date) }) const y_data:number[] = x_dates.map((date:Date)=>{ return session_data.get(date.toISOString())[normalized_property] }) const data: Plot[] = [ { x:x_dates, y:y_data, type: 'scatter' } ] plot(data) }else{ console.log(`error: ` +chalk.red(`Session data.size === 0`)) } }else{ console.log(chalk.red(`${property}`) + ` is not a valid property. Below are the valid values`) console.log("session_mean \n" + "standard_deviation \n" + "variance \n" + "fastest_solve \n" + "slowest_solve") } }) program .command('start') .argument('[event]', 'the event you wish to practice','333') .option('-f, --focusMode','Displays only the most important stats') .option('-w --window','Opens a second command prompt window to display the informationa and stats related to the solve') .description('Begin a session of practicing a certain event') .action((event:string,options:any)=>{ if(event !== undefined){ const normalized_event = event .toLowerCase() .trim() if(validEvent(normalized_event)){ startSession(normalized_event,options) }else{ console.log(chalk.red( `${event} is not a valid/supported event` )); } }else{ select({ message:'Select an event', choices:event_choices }) .then((event_choice:string)=>{ startSession(event_choice,options) }).catch((error)=>{ console.log(chalk.bgRed(`An error occurred`)) }) } }) program .command("settings") .argument("[property]","configure the cli to your liking") .action((setting_to_change:string | undefined)=>{ let current_settings:settings = settingsUtil.loadSettings() const settings_list:string[] = Object.keys(current_settings) if(setting_to_change === undefined){ console.log(chalk.green(`Configure any of the below to some new and preferred value`)) console.table(current_settings) select({ message: "Select the setting you'd like to alter", choices: settings_list }).then((answer:string)=>{ updateSetting(current_settings,answer) }) }else{ if(settings_list.indexOf(setting_to_change) !== -1){ updateSetting(current_settings,setting_to_change) }else{ console.log(chalk.red('Invalid argument:' + chalk.white('The argument is not a setting to change'))) } } }) program .command('show-session') .action(()=>{ const menu_length:number = settingsUtil.loadSettings().show_session_menu_length function newChoices(menu_page:number){ const session_array:sessionLog[] = Array.from(storage.loadData().data.values()) let menu_choices:any = session_array .map((session:sessionLog)=>{ return { name: session.date_formatted, value: session.date } }).filter((v,index)=>{ return (index>=menu_page*(menu_length)) && (index<((menu_page+1)*menu_length)) }) if(menu_page !== 0){ menu_choices.unshift({ name:chalk.blue(`Back`), value: 'back' }) } if(session_array[(menu_page+1)*menu_length] !== undefined){ menu_choices.push({ name:chalk.blue(`next`), value: 'next' }) } select({ message:`Select the session you'd like to observe`, choices:menu_choices }).then((value:string)=>{ switch(value){ case 'back': newChoices(menu_page-1) break; case 'next': newChoices(menu_page+1) break; default: const current_session_data:sessionLog = storage.loadData().data.get(value) let info_table = current_session_data.entries.map((instance,index)=>{ const label = (instance.label === "DNF") ? chalk.red(instance.label) : instance.label return { n: index+1, time: instance.time.toFixed(3), label: label ?? chalk.green('OK'), } }) console.log(`\n`) console.log(createTable(info_table,['n','time','label'])) const current_session_stats:session_statistics = storage.loadStats().session_data.get(value) if(current_session_stats !== undefined){ console.log(Object.keys(current_session_stats).map((key_name:string)=>{ return `${key_name}: ${current_session_stats[key_name].toFixed(3)} ${chalk.green('s')}` }) .join(chalk.blue('\n'))) }else{ console.log(`Statistics unavailable`) } break; } }).catch((err)=>{ console.log(chalk.red(`An error has occurred:${err}`)) }) } newChoices(0) }) program.parse(process.argv) function updateSetting(current_settings:settings,property:string):void{ const prompt = (typeof current_settings[property] === 'number') ? number : input prompt({ message: `Enter new value for ${property}`, default: `${current_settings[property]}` as never }).then((new_value:number|string)=>{ current_settings[property] = new_value settingsUtil.saveSettings(current_settings) console.log(chalk.green('settings updated!')) console.table(current_settings) }) } function validEvent(event_to_check:string):boolean{ return (events_list.indexOf(event_to_check) !== -1) } function startSession(event: string,options:any):void{ main_window_id = activeWindowSync().id console.clear() const session = Date.now() const session_date = new Date(session) const session_date_ISO = session_date.toISOString() cfonts.say(`session: ${session}`, { font: 'tiny', // define the font face align: 'center', // define text alignment colors: ['magenta'], background: 'transparent', // define the background color, you can also use `backgroundColor` here as key letterSpacing: 1, // define letter spacing }); const current_settings:settings = settingsUtil.loadSettings() saved_data.data.set(session_date_ISO,storage.newSessionLog(session_date,event)) saved_data.last_accessed_log = session_date_ISO storage.saveData(saved_data) new_scramble = true listener.kill() if (options.window || options.w) { const scriptPath = path.join(__dirname, 'window.js'); const cmd = spawn('cmd.exe', ['/K', `start cmd /K node ${scriptPath} ${session_date.toISOString()}`], { detached: true, stdio: 'ignore', windowsHide: false }); cmd.unref(); // Allow the parent process to exit without waiting for this new process cmd.on('error', (err) => console.error(`Process error: ${err.message}`)); } newSolve(current_settings,event,session_date,options) } function newSolve(current_settings:settings,event: string,session_date:Date,option:any):void{ const session_date_ISO:string = session_date.toISOString() var scramble_generator = new Scrambow() process.stdin.resume(); let scramble: string = scramble_generator .setType(event) .setLength(current_settings.scramble_length) .get(1)[0] .scramble_string process.stdout.write(`\x1b[2K\r`) console.log(chalk.bold.red(`Scramble:`)) process.stdout.write("\x1b[2K") console.log(stylizeScramble(scramble)) listener.addListener(function (e, down) { process.stdout.write('\x1b[2K\r'); if(activeWindowSync().id !== main_window_id){ return } if((e.name === "D") && (e.state === "UP") && (!new_scramble)){ const current_session:sessionLog = saved_data.data.get(session_date_ISO) if(current_session.entries.length>=1){ current_session.entries.pop() console.log(chalk.blue(`Last solve deleted`)) saved_data.data.set(session_date_ISO,current_session) storage.saveData(saved_data) }else{ console.log(chalk.red(`There exist no entries in the current session to delete`)) } return } if((e.name === "N") && (e.state === "UP")){ if(!new_scramble){ process.stdout.write('\x1b[2K'); listener.kill() new_scramble = true solve_labelled = false newSolve(current_settings,event,session_date,option) } return } if((e.name === "E") && (e.state === "UP") && (!new_scramble)){ if(!solve_labelled){ solve_labelled = true const current_session:sessionLog = saved_data.data.get(session_date_ISO) console.log(`\n \n`) if(current_session.entries.length>=1){ select({ message:`Select the label for the previous solve`, choices:[ '+3', 'DNF', 'OK' ] }).then((answer:string)=>{ current_session.entries.at(-1).label = answer saved_data.data.set(session_date_ISO,current_session) storage.saveData(saved_data) console.log(chalk.green(`Last solve labelled ${answer}`)) console.log(chalk.bold.magentaBright(`Whenever ready use the spacebar to start a new solve`)) }).catch((err)=>{ console.log(chalk.red(`An error has occurred:${err}`)) }) }else{ console.log(chalk.redBright(`There exist no entries in the current session to label`)) } console.log(`\n \n`) return }else{ console.log(chalk.redBright(`The solve has already been labelled.`)) return } } if((e.name === "SPACE") && (new_scramble)){ if(!timer_running){ if(e.state === "DOWN"){ if(!space_been_pressed){ space_been_pressed = true process.stdout.write(chalk.bgRed('...') +`\n`); }else{ process.stdout.write("\b \b") } }else{ if(space_been_pressed){ space_been_pressed = false process.stdout.write("\x1b[F"); //move back up a line process.stdout.write('\x1b[2K'); // Clear the line console.log(chalk.bgGreenBright('SOLVE') + '\n \n'); startTimer() } } }else{ if(e.state === "DOWN"){ const elapsedTime:number = stopTimer() new_scramble = false const current_session:sessionLog = saved_data.data.get(session_date_ISO) current_session.entries.push({ scramble: scramble, time: elapsedTime, label: null }) const session_average = current_session .entries .reduce((acc,curr)=>{ return acc += curr.time },0)/current_session.entries.length const best_time:number = current_session .entries .reduce((acc,curr)=>{ if(acc<curr.time){ return acc }else{ return curr.time } },Infinity) const worst_time:number = current_session .entries .reduce((acc,curr)=>{ if(acc>curr.time){ return acc }else{ return curr.time } },-Infinity) const variance:number = current_session .entries .reduce((acc,curr)=>{ return acc += (session_average - curr.time)**2 },0)/current_session.entries.length const stats_data = storage.loadStats() const current_stats:session_statistics = { session_mean: session_average, standard_deviation: Math.sqrt(variance), variance: variance, fastest_solve: best_time, slowest_solve: worst_time } const current_Ao5:number = storage.Ao5(current_session) const current_Ao12:number = storage.Ao12(current_session) stats_data.session_data.set(session_date_ISO,current_stats) stats_data.pb_Ao5 = (current_Ao5<stats_data.pb_Ao5) ? current_Ao5 : stats_data.pb_Ao5 stats_data.pb_Ao12 = (current_Ao12<stats_data.pb_Ao12) ? current_Ao12 : stats_data.pb_Ao12 storage.saveStats(stats_data) saved_data.data.set(session_date_ISO,current_session) storage.saveData(saved_data) process.stdout.write("\b \b") cfonts.say(`${elapsedTime.toFixed(2)}s`, { font: 'block', // define the font face align: 'center', // define text alignment colors: ['white'], background: 'transparent', // define the background color, you can also use `backgroundColor` here as key letterSpacing: 1, // define letter spacing }); process.stdout.write("\b \b") console.log( chalk.bold(`Time: `) + elapsedTime.toFixed(4) + chalk.green('s') + `\n`); console.log(chalk.bold(`Ao5: `)+ chalk.magenta(current_Ao5 ?? "--") + chalk.green(`s`)) console.log(chalk.bold(`Ao12: `)+ chalk.magenta(current_Ao12 ?? "--") + chalk.green(`s`) + `\n \n`) if(!(option.focusMode || option.f) && !(option.w || option.window)){ //solves console.table(createTable(current_session.entries.map((instance)=>{ return { time: instance.time.toFixed(3), label: instance.label ?? 'OK' } }),['time','label'])) //stats const titles:string[] = ['average','std. dev.','variance','fastest','slowest'] const stats_string:string = Object.keys(current_stats) .map((stat_name:string,index:number)=>{ return `${titles[index]}: ${chalk.bold(current_stats[stat_name].toFixed(3))}` }) .join(chalk.blue(` | `)) console.log(stats_string + `\n`) console.log(`\n`) console.log(chalk.dim(`To label/delete the last solve simply use (`) + chalk.italic.yellow(`e`) + chalk.dim(`/`) + chalk.italic.yellow(`d`) + chalk.dim(`) respectively`)) console.log(chalk.dim(`Exit session mode using`), chalk.green(`Ctrl+C`) + `\n`) console.log(chalk.bold.magentaBright(`Whenever ready use the spacebar to start a new solve using`) + chalk.italic.yellow(` n`)+ `\n \n`) } //reset timer_running = false startTime = null space_been_pressed = false } } return } }); } function stopTimer():number{ if (!startTime) return; timer_running = false const endTime = process.hrtime(startTime) return endTime[0] + endTime[1] / 1e9; } function startTimer():void{ startTime = process.hrtime() timer_running = true } function stylizeScramble(scramble: string): string { const colorMap: Record<string, (s: string) => string> = { 'r': chalk.redBright, 'l': chalk.blueBright, 'u': chalk.cyanBright, 'd': chalk.greenBright, "'": chalk.whiteBright, '2': chalk.cyan, 'F': chalk.magenta.underline, }; return scramble .trim() .split('') .map(char => { const stylize = colorMap[char] || chalk.magenta; return stylize(char); }) .join(''); }