Last active
December 9, 2018 12:01
-
-
Save nvlled/9e57d086a5d9024b7e8ac85b5ec0eaae to your computer and use it in GitHub Desktop.
A CLI time-tracking tool
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env node | |
const path = require("path"); | |
const readline = require("readline"); | |
const fs = require("fs"); | |
const logFilename = "stein.txt"; | |
const getArgv = () => { | |
const argv = process.argv.slice(0); | |
if (path.basename(argv[0]) == "node") { | |
argv.shift(); | |
} | |
const args = []; | |
let params = {}; | |
json = ''; | |
for (let arg of argv) { | |
if (arg.startsWith('{') || !!json) { | |
json += arg + ' '; | |
} else { | |
args.push(arg); | |
} | |
if (arg.endsWith('}')) { | |
console.log('parsing', json); | |
try { | |
const data = parsePseudoJSON(json); | |
console.log("data>", data); | |
params = { ...params, ...data }; | |
json = ''; | |
} catch (e) { | |
console.log("***", e.message); | |
return null; | |
} | |
} | |
} | |
if (!!json) { | |
throw "unterminated JSON data"; | |
} | |
args.params = params; | |
return args; | |
} | |
const parsePseudoJSON = json => { | |
const m = json.match(/.*{(.*)}.*/); | |
if (!m) { | |
return {}; | |
} | |
return m[1].split(/;|,/).reduce((obj, entry) => { | |
let [key, value] = entry.split(/:/); | |
if (typeof value !== 'string') { | |
value = true; | |
} else { | |
try { | |
value = JSON.parse(value); | |
} catch (e) { } | |
} | |
return { | |
...obj, | |
[key.trim()]: value, | |
} | |
}, {}); | |
} | |
const enterTimes = (n, onDone, onCancel) => { | |
const rl = readline.createInterface({ | |
input: process.stdin, | |
output: process.stdout, | |
terminal: false, | |
}); | |
let lastEnter = null; | |
let count = 0; | |
let lines = []; | |
let done = false; | |
const lineLoop = line => { | |
lines.push(line); | |
if (line.trim()) | |
return; | |
if (lastEnter != null) { | |
const elapsed = +new Date - lastEnter; | |
if (elapsed <= 250) { | |
lastEnter = +new Date; | |
count++; | |
if (count >= n) { | |
rl.removeListener("line", lineLoop); | |
done = true; | |
rl.close(); | |
onDone(lines); | |
} | |
} else { | |
if (!lines[lines.length-1]) | |
console.log(`<press enter rapidly ${n}x to end>`); | |
lastEnter = null; | |
count = 0; | |
} | |
} else { | |
count = 1; | |
lastEnter = +new Date; | |
} | |
} | |
console.log(`<press enter rapidly ${n}x to end>`); | |
rl.on("line", lineLoop); | |
rl.on("close", () => { | |
if (done) | |
return; | |
if (typeof onCancel == "function") { | |
onCancel(lines); | |
} | |
}); | |
} | |
const usage = argv => { | |
console.log(`usage:`); | |
console.log(`\t${argv[0]} | show all logs`); | |
console.log(`\t${argv[0]} [N] | show Nth entry`); | |
console.log(`\t${argv[0]} @taskID | show logs for @taskID`); | |
console.log(`\t${argv[0]} @taskID[N] | show Nth entry for @taskID`); | |
console.log(`\t${argv[0]} +taskID | log new entry for task`); | |
} | |
const formatTime = time => { | |
const d = new Date(time); | |
const hours = (d.getHours()+"").padStart(2, '0'); | |
const mins = (d.getMinutes()+"").padStart(2, '0'); | |
return `${hours}:${mins}`; | |
} | |
const logTask = ({id, start, end, desc}) => { | |
const filename = `${process.env.HOME}/${logFilename}`; | |
const d = new Date(start); | |
const time = `${formatTime(start)}-${formatTime(end)}`; | |
const data = `\n\n@${id} ${d.getFullYear()}/${d.getMonth()}/${d.getDate()} ${time}` + | |
'\n' + desc.split("\n").map(l => "> "+l).join("\n") + '\n'; | |
fs.appendFileSync(filename, data, 'utf-8'); | |
} | |
const getLogEntries = ({id} = {}) => { | |
const filename = `${process.env.HOME}/${logFilename}`; | |
const data = fs.readFileSync(filename, 'utf-8'); | |
const lines = data.split("\n") | |
const readDesc = false; | |
const entries = [] | |
let entry = null; | |
const re = /@(\w+)\s+(\d{4})\/(\d{1,2})\/(\d{1,2})\s+(\d{1,2}):(\d{1,2})-(\d{1,2}):(\d{1,2})/; | |
for (let i = 0; i < lines.length+1; i++) { | |
const line = (lines[i] || "").trim(); | |
const m = line.match(re); | |
if ((m || lines.length == i) && entry && | |
(id == null || id == entry.id)) { | |
entries.push(entry); | |
} | |
if (m) { | |
let [_, id, year, month, date, startHour, startMin, endHour, endMin] = m; | |
entry = { | |
id, | |
date: +date, | |
start: +new Date(+year, +month, +date, +startHour, +startMin), | |
end: +new Date(+year, +month, +date, +endHour, +endMin), | |
desc: '', | |
} | |
} else if (entry && (!line.trim() || line.match("^>"))) { | |
entry.desc += line + '\n'; | |
} else if (line) { | |
console.log(`** bad format on line ${i+1}: ${line}`); | |
} | |
} | |
return entries; | |
} | |
const isArray = obj => obj && obj.__proto__ == Array.prototype; | |
const getDuration = (arg) => { | |
let compute = ({start, end}) => { | |
const hours = new Date(end - start).getUTCHours(); | |
const minutes = new Date(end - start).getUTCMinutes(); | |
return {hours, minutes}; | |
} | |
if ( ! isArray(arg)) { | |
return compute(arg); | |
} | |
const total = {hours: 0, minutes: 0}; | |
for (let entry of arg) { | |
const d = compute(entry); | |
total.hours += d.hours; | |
total.minutes += d.minutes; | |
} | |
total.hours += Math.floor(total.minutes / 60); | |
total.minutes = total.minutes % 60; | |
return total; | |
} | |
const formatDuration = ({hours, minutes, start, end}) => { | |
if (hours == null && minutes == null) { | |
({hours, minutes} = getDuration({start, end})); | |
} | |
const hoursText = hours == 0 ? '' : `${hours.toFixed(0)}h`; | |
const minutesText = minutes == 0 ? '' : `${(minutes.toFixed(0)).padStart(2, '0')}m`; | |
return `${hoursText}${minutesText}`; | |
} | |
const shorten = (text, maxlen=50) => { | |
let shortText = text.split("\n") | |
.map(l => l.replace(/^>/, '').trim()) | |
.filter(l => l) | |
.join(" "); | |
if (shortText.length > maxlen) { | |
shortText = shortText.slice(0, maxlen) + "..."; | |
} | |
return shortText; | |
} | |
const formatEntry = ({fulldesc, index, id, start, end, desc=""}) => { | |
const indexStr = index != null ? `[${index}] ` : ''; | |
const d = new Date(start); | |
let desc_ = fulldesc ? '\n' + desc.trim() + '\n' : "> " + shorten(desc); | |
return `${indexStr}@${id} ${d.getMonth()}/${d.getDate()} ` + | |
`${formatTime(start)}-${formatTime(end)} ${formatDuration({start, end})} ` + | |
`${desc_}`; | |
} | |
const formatEntries = (args, entries) => { | |
if (!entries || entries.length == 0) { | |
return; | |
} | |
const entry = { ...args, ...(entries[0] || {}), }; | |
const {fulldesc, index, id, start, end, desc=""} = entry; | |
const indexStr = index != null ? `[${index}] ` : ''; | |
const d = new Date(start); | |
const duration = getDuration(entries); | |
return `${indexStr}@${id} ${d.getMonth()}/${d.getDate()} ` + `${formatDuration(duration)} `; | |
} | |
const createBackup = () => { | |
const filename = `${process.env.HOME}/${logFilename}`; | |
const backupDir = `${process.env.HOME}/.stein`; | |
fs.mkdir(backupDir, true, () => { | |
fs.readFile(filename, 'utf-8', (err, data) => { | |
if (err) { | |
console.log(err); | |
return; | |
} | |
fs.writeFileSync(`${backupDir}/${(new Date).getDate()%5}-${logFilename}`, data, 'utf-8'); | |
fs.writeFileSync(`${backupDir}/${logFilename}`, data, 'utf-8'); | |
}); | |
}); | |
} | |
const main = () => { | |
const argv = getArgv(); | |
if (argv == null) { | |
return; | |
} | |
const progName = argv.shift(); | |
let taskID = argv.shift(); | |
const initialDesc = argv.join(" "); | |
const isQuery = taskID => !taskID || taskID[0] == "@" || taskID.match(/\[\d+\]/); | |
const isNewEntry = taskID => taskID && taskID[0] == "+"; | |
const parseQuery = taskID => { | |
const m = (taskID||"").match(/(?:@(\w+))?(?:\[(\d+)\])?/); | |
return !m ? {} : { | |
id: m[1], | |
index: +m[2], | |
} | |
} | |
const showDate = (+argv.params.d || null); | |
if (isQuery(taskID)) { | |
const query = parseQuery(taskID); | |
let entries = getLogEntries(query); | |
if (query.index >= 0) { | |
entries = entries.slice(query.index, query.index+1); | |
} | |
let index = entries.length > 1 ? 0 : null; | |
let total = { hours: 0, minutes: 0}; | |
if (argv.params.perday) { | |
let grouped = entries.reduce((result, entry) => { | |
if (!result[entry.date]) { | |
result[entry.date] = []; | |
} | |
result[entry.date].push(entry); | |
return result; | |
}, {}); | |
entries = Object.keys(grouped).sort().map(k => grouped[k]); | |
} | |
for (let entry of entries) { | |
if (showDate && entry.date != showDate) { | |
continue; | |
} | |
const duration = getDuration(entry); | |
total.hours += duration.hours; | |
total.minutes += duration.minutes; | |
if ( ! isArray(entry)) { | |
console.log(formatEntry({ ...argv.params, index, ...entry })); | |
} else { | |
console.log(formatEntries({ ...argv.params, index}, entry)); | |
} | |
index++; | |
} | |
if (entries.length > 0) { | |
const totalHours = total.hours + total.minutes/60; | |
const hours = Math.floor(totalHours); | |
const minutes = Math.floor((totalHours - hours) * 60); | |
console.log(`total hours: ${hours}h${minutes}m (${(totalHours).toFixed(2)})`); | |
} | |
return; | |
} | |
if (isNewEntry(taskID)) { | |
taskID = taskID.slice(1); | |
} else { | |
console.log("unknown directive: " + taskID); | |
return | |
} | |
process.on('SIGINT', function() { | |
console.log("nope"); | |
}); | |
createBackup(); | |
const start = +new Date(); | |
console.log(`# task @${taskID} started on ${formatTime(start)}`); | |
enterTimes(2, () => { | |
const end = +new Date(); | |
console.log(`# task @${taskID} stopped ${formatTime(end)}`); | |
console.log("what did you do?"); | |
const done = (lines) => { | |
if (!lines.join("\n").trim()) { | |
console.log("you didn't do anything?"); | |
return enterTimes(2, done); | |
} | |
const {hours, minutes} = getDuration({start, end}); | |
const desc = [initialDesc.trim()].concat(lines.filter(l => l.trim())).join("\n").trim(); | |
console.log(`# task @${taskID} duration: ${hours}h${minutes}m`); | |
console.log("-------------------------"); | |
console.log(desc); | |
logTask({id: taskID, start, end, desc}); | |
process.exit(0); | |
}; | |
enterTimes(2, done, done); | |
}, () => { | |
console.log("******************************************"); | |
console.log("** WARNING Task closed and not recorded **"); | |
console.log("******************************************"); | |
const end = +new Date(); | |
const {hours, minutes} = getDuration({start, end}); | |
console.log(`# task @${taskID} stopped ${formatTime(end)} duration: ${hours}h${minutes}m`); | |
}); | |
} | |
// Usage: | |
// ./stein.js +345 start task 345 | |
// ./stein.js +345 fix some bugs | |
// ./stein.js +123 revert changes | |
// ./stein.js # show all | |
// ./stein.js @345 # show entries for task @345 | |
// ./stein.js {d: 5} # show entries on 5th day of month | |
// ./stein.js {d: 5} @345 # show entries for task @345 on 5th day | |
// ./stein.js {fulldesc} # show full description | |
// ./stein.js {perday} # summarize total hours per day | |
main(); | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment