Skip to content

Instantly share code, notes, and snippets.

@nvlled
Last active December 9, 2018 12:01
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nvlled/9e57d086a5d9024b7e8ac85b5ec0eaae to your computer and use it in GitHub Desktop.
Save nvlled/9e57d086a5d9024b7e8ac85b5ec0eaae to your computer and use it in GitHub Desktop.
A CLI time-tracking tool
#!/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