Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 117 additions & 84 deletions src/timekeeping/commands/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,65 @@ import pc from 'picocolors';
const SHIFT_MINUTE = 1;
const SHIFT_MINUTES = 15;

function column(str, n) {
if (str.length > n) {
str = str.substr(0, n - 1) + "…";
}
return str.padEnd(n);
}

/**
* interactively edit a frame's start/stop time with keystrokes
* @param config
* @param id
* formats a frame as a single list row, shared by the interactive and
* non-interactive menus; `focus` ('start'|'stop') highlights that field,
* `edited` ('start'|'stop'|'both') colors edited field(s) red
* @param frame
* @param focus
* @param edited
* @returns {string}
*/
function formatFrameRow(frame, focus, edited) {
const startText = frame.start.clone().format("MMMM Do YYYY HH:mm");
const stopText = frame.stop ? "to " + frame.stop.clone().format("HH:mm") : "(running)";
const startColor = edited === "start" || edited === "both" ? pc.red : pc.green;
const stopColor = edited === "stop" || edited === "both" ? pc.red : pc.green;
const start = focus === "start" ? pc.inverse(startColor(startText)) : startColor(startText);
const stop = focus === "stop" ? pc.inverse(stopColor(stopText)) : stopColor(stopText);
const issue = `${
pc.blue((frame.resource.type + " #" + frame.resource.id).padEnd(20))
}${column(frame.title != null ? frame.title : "", 50)}`;
return `${frame.id} ${start} ${stop}\t${pc.magenta(column(frame.project, 50))}${issue}${
frame.note != null ? frame.note : ""
}`;
}

/**
* interactively edit start/stop time of a list of frames inline, with keystrokes
* @param frames
* @returns {Promise<void>}
*/
function interactiveEdit(config, id) {
function showInteractiveMenu(frames) {
return new Promise((resolve, reject) => {
let frame = Frame.fromFile(config, Fs.join(config.frameDir, id + '.json'));
let field = 'start';
if (frames.length === 0) {
Cli.error('No records found.');
resolve();
return;
}

// fields is a flat cycle of [frameIndex, 'start'|'stop'] pairs
const fields = frames.flatMap((_, i) => [[i, 'start'], [i, 'stop']]);
let cursor = 0;
// per-frame set of edited field names ('start'/'stop')
const editedFields = frames.map(() => new Set());

function render() {
Cli.out('\x1Bc');
Cli.out(`Editing frame ${frame.id}\n\n`);
Cli.out(`${field === 'start' ? '>' : ' '} Start: ${frame.start.format('YYYY-MM-DD HH:mm')}\n`);
Cli.out(`${field === 'stop' ? '>' : ' '} Stop: ${frame.stop ? frame.stop.format('YYYY-MM-DD HH:mm') : '(running)'}\n\n`);
Cli.out('Tab: switch field ↑/↓: ±15 min Enter: save Esc/q: cancel\n');
Cli.out('Tab/Shift+Tab: switch field ↑/↓: ±15 min (Shift: ±1 min) Enter: save all Esc/q: cancel\n\n');
frames.forEach((frame, i) => {
const focus = fields[cursor][0] === i ? fields[cursor][1] : null;
const edited = editedFields[i];
const editedArg = edited.has('start') && edited.has('stop') ? 'both' : edited.has('start') ? 'start' : edited.has('stop') ? 'stop' : null;
Cli.out(`${formatFrameRow(frame, focus, editedArg)}\n`);
});
}

function cleanup() {
Expand All @@ -44,20 +86,24 @@ function interactiveEdit(config, id) {
process.exit();
}

const [frameIdx, field] = fields[cursor];
const frame = frames[frameIdx];

if (key.name === 'tab') {
field = field === 'start' ? 'stop' : 'start';
cursor = key.shift ? (cursor - 1 + fields.length) % fields.length : (cursor + 1) % fields.length;
} else if (key.name === 'up' || key.name === 'down') {
let shift = key.shift ? SHIFT_MINUTE: SHIFT_MINUTES;
let shift = key.shift ? SHIFT_MINUTE : SHIFT_MINUTES;
const delta = key.name === 'up' ? shift : -shift;
if (field === 'start') {
frame.start = frame.start.clone().add(delta, 'minutes');
} else {
frame.stop = (frame.stop || frame.start).clone().add(delta, 'minutes');
}
editedFields[frameIdx].add(field);
} else if (key.name === 'return') {
cleanup();
try {
frame.write();
frames.forEach((frame) => frame.write());
resolve();
} catch (error) {
reject(error);
Expand Down Expand Up @@ -87,100 +133,87 @@ function edit() {
.option('-f, --following <number>', 'edit also the following (by ctime) of the given [id]')
.option('-n, --listsize <number>', 'list size', 30)
.option('-i, --interactive', 'edit start/stop time interactively with keystrokes')
.option('--today', 'only list entries for today')
.option('--this_week', 'only list entries for this week')
.action((id, opts ,program) => {

let config = new Config(process.cwd());
let timeFormat = config.set('timeFormat', program.opts().time_format).get('timeFormat', 'log');
let timekeeper = new Timekeeper(config);
const listSize = program.opts().listsize;

function column(str, n){
if(str.length > n) {
str = str.substr(0, n-1) + "…"
}
return str.padEnd(n);
}

function toHumanReadable(input) {
return Time.toHumanReadable(Math.ceil(input), config.get('hoursPerDay'), timeFormat);
}

function showMenu() {
timekeeper
.all()
.then(({ frames }) => {
frames.sort((a, b) => (a.start.isBefore(b.start) ? -1 : 1));
if (id) {
let idx = frames.findIndex((fr) => fr.id == id);
let following = Number(program.opts().following);
if (!Number.isInteger(following)) {
following = 0;
}
if (idx >= 0) {
frames = frames.slice(idx, idx + following);
}
else
{
frames = [];
}
function getMenuFrames() {
return timekeeper.all().then(({ frames }) => {
frames.sort((a, b) => (a.start.isBefore(b.start) ? -1 : 1));
if (id) {
let idx = frames.findIndex((fr) => fr.id == id);
let following = Number(program.opts().following);
if (!Number.isInteger(following)) {
following = 0;
}
if (idx >= 0) {
frames = frames.slice(idx, idx + following);
} else {
frames = frames.slice(-listSize); // last listSize frames (one page of inquirer)
frames = [];
}
let lastFramesDetails = frames.map((frame) => {
let issue = `${
pc.blue((frame.resource.type + " #" + frame.resource.id).padEnd(20))
}${column(frame.title != null ? frame.title : "", 50)}`;
return {
name:
` ${frame.id} ${pc.green(frame.start.clone().format("MMMM Do YYYY HH:mm"))} ${
frame.stop
? "to " + pc.green(frame.stop.clone().format("HH:mm"))
: "(running)"
}\t` +
`${pc.magenta(column(frame.project, 50))}${issue}${
frame.note != null ? frame.note : ""
}`,
value: frame.id,
};
});
} else if (program.opts().today || program.opts().this_week) {
let from = program.opts().today ? dayjs().startOf('day') : dayjs().startOf('week');
let to = program.opts().today ? dayjs().endOf('day') : dayjs().endOf('week');
frames = frames.filter((fr) => !fr.start.isBefore(from) && !fr.start.isAfter(to));
} else {
frames = frames.slice(-listSize); // last listSize frames (one page of inquirer)
}
return frames;
});
}

function showNonInteractiveMenu(frames) {
let lastFramesDetails = frames.map((frame) => ({
name: ` ${formatFrameRow(frame)}`,
value: frame.id,
}));

if (lastFramesDetails.length == 0) {
Cli.error("No records found.");
return;
}

if (lastFramesDetails.length == 0) {
Cli.error("No records found.");
select({
message: "Frame?",
default: lastFramesDetails[lastFramesDetails.length - 1].value,
choices: lastFramesDetails,
pageSize: listSize,
}).then((answers) => {
for (const answer of answers) {
if (!Fs.exists(Fs.join(config.frameDir, answer + ".json"))) {
Cli.error("record not found.");
} else {
select({
message: "Frame?",
default: lastFramesDetails[lastFramesDetails.length - 1].value,
choices: lastFramesDetails,
pageSize: listSize,
}).then(async (answers) => {
let didInteractiveEdit = false;
for (const answer of answers) {
if (!Fs.exists(Fs.join(config.frameDir, answer + ".json"))) {
Cli.error("record not found.");
} else if (program.opts().interactive) {
await interactiveEdit(config, answer);
didInteractiveEdit = true;
} else {
Fs.open(Fs.join(config.frameDir, answer + ".json"));
}
}

if (didInteractiveEdit) {
showMenu();
}
});
Fs.open(Fs.join(config.frameDir, answer + ".json"));
}
})
.catch((error) => Cli.error(error));
}
});
}

if (!id || program.opts().following) {
showMenu();
getMenuFrames()
.then((frames) => {
if (program.opts().interactive) {
return showInteractiveMenu(frames);
} else {
return showNonInteractiveMenu(frames);
}
})
.catch((error) => Cli.error(error));
} else {
if (!Fs.exists(Fs.join(config.frameDir, id + ".json")))
Cli.error("No record found.");
if (program.opts().interactive) {
interactiveEdit(config, id.replace(".json", "")).catch((error) => Cli.error(error));
let frame = Frame.fromFile(config, Fs.join(config.frameDir, id.replace(".json", "") + ".json"));
showInteractiveMenu([frame]).catch((error) => Cli.error(error));
} else {
Fs.open(Fs.join(config.frameDir, id.replace(".json", "") + ".json"));
}
Expand Down
Loading