@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
text/typescript
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('');
}