workwatch
Version:
A Linux terminal program for honest worktime tracking and billing.
336 lines (322 loc) • 11.5 kB
JavaScript
/**
* This file is part of the WorkWatch, a Linux terminal program for honest
* worktime tracking and billing.
*
* Copyright (C) 2020-2025 by Artur Rutkowski
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* WorkWatch is beeing developped and maintained by Artur (locust) Rutkwoski
* <locust@mailbox.org>
*/
/**
* This is a file-handling module representing a content and operations on
* time measurement JSON file. Its getters provide and validate particular
* fields of that file. Methods are manipulating and validating it.
*/
const fs = require("node:fs");
const WorkWatchFile = require("./file.js");
const utils = require("../utils.js");
function TimeMeasurement(jsonContent, pathToJSONFile = null) {
// The day of measurement. Used when saving in the worklog.
let measurementDay;
// Provides measurementDay content.
Object.defineProperty(this, "day", {
get() {
return measurementDay;
},
enumerable: true,
configurable: false
});
// Extends base object.
WorkWatchFile.call(this, jsonContent, pathToJSONFile);
Object.setPrototypeOf(TimeMeasurement.prototype, WorkWatchFile.prototype);
// Sets the measurement day when the file is stored on disk.
if (this.filePath !== null) {
fs.stat(this.filePath, {bigint: false}, (statError, stats) => {
if (statError !== null && statError.code === "ENOENT") {
// Finishes when the measurement file is not stored yet.
return;
} else if (statError === null) {
measurementDay = stats.birthtime.toISOString().split("T")[0];
} else {
throw statError;
}
});
}
}
// Represents a time field containing measured time in hh:mm format.
Object.defineProperty(TimeMeasurement.prototype, "time", {
get() {
if (
"time" in this.fileContent
&& utils.verifyTimeValue(this.fileContent.time)
) {
return this.fileContent.time;
} else {
const error = new Error("You have invalid measured time or your measurement file is empty.");
error.code = "ERR_FILE_MEASUREMENT_NO_TIME_PROPERTY_OR_INVALID";
throw error;
}
},
enumerable: true,
configurable: false
});
// Represents a number field containing sequential number of a given
// measurement in the particular day.
Object.defineProperty(TimeMeasurement.prototype, "number", {
get() {
if (
"number" in this.fileContent
&& Number.isInteger(this.fileContent.number)
&& this.fileContent.number >= 1
) {
return this.fileContent.number;
} else {
const error = new Error("You have wrong measurement number or your measurement file is empty.");
error.code = "ERR_FILE_MEASUREMENT_NO_NUMBER_PROPERTY_OR_WRONG_VALUE";
throw error;
}
},
enumerable: true,
configurable: false
});
// Represents an hoursRange field containing an array of objects. Each
// object has "from" and "to" fields containing time of work in hh:mm format.
Object.defineProperty(TimeMeasurement.prototype, "hoursRange", {
get() {
if (
"hoursRange" in this.fileContent
&& Array.isArray(this.fileContent.hoursRange)
&& this.fileContent.hoursRange.length > 0
) {
if (
this.fileContent.hoursRange.every(
hourRange => (
"from" in hourRange
&& utils.verifyTimeValue(hourRange.from)
) && (
"to" in hourRange
&& utils.verifyTimeValue(hourRange.to)
) && (
utils.timeToMinutesNumber(
hourRange.to
) > utils.timeToMinutesNumber(
hourRange.from
)
)
)
) {
return this.fileContent.hoursRange;
} else {
const error = new Error("You have invalid range of measured hours in your measurement file.");
error.code = "ERR_FILE_MEASUREMENT_HOURSRANGE_WRONG_SYNTAX";
throw error;
}
} else {
const error = new Error("You have no range of measured hours or your measurement file is empty.");
error.code = "ERR_FILE_MEASUREMENT_NO_HOURSRANGE_PROPERTY_OR_INVALID";
throw error;
}
},
enumerable: true,
configurable: false
});
// Represents a project field naming a project or task you are working on.
Object.defineProperty(TimeMeasurement.prototype, "project", {
get() {
if (
"project" in this.fileContent
&& typeof this.fileContent.project === "string"
) {
return this.fileContent.project;
} else {
const error = new Error("You have wrong project name or your measurement file is empty.");
error.code = "ERR_FILE_MEASUREMENT_NO_PROJECT_PROPERTY";
throw error;
}
},
enumerable: true,
configurable: false
});
// Verifies entire measurement file.
Object.defineProperty(TimeMeasurement.prototype, "verify", {
value: function verify() {
return new Promise((resolve, reject) => {
// Empty object check.
if (Object.keys(this.fileContent).length === 0) {
const error = new Error("Your measurement file is empty.");
error.code = "ERR_FILE_MEASUREMENT_EMPTY";
reject(error);
}
// Extra fields check. No extra fields are allowed.
if (
Object.keys(this.fileContent).filter(
key => !["time", "number", "hoursRange", "project"].includes(key)
).length > 0
) {
const error = new Error("Your measurement file shouldn't contain any extra fields.");
error.code = "ERR_FILE_MEASUREMENT_EXTRA_PROPERTIES";
reject(error);
}
// Force validations of measurement's fields.
const {time, number, hoursRange, project} = this;
// Additional checks for hoursRange.
hoursRange.forEach((hourRange, rangeIndex) => {
// Check if from and to fields are in correct order.
if (rangeIndex < hoursRange.length - 1) {
if (
utils.timeToMinutesNumber(
hourRange.to
) > utils.timeToMinutesNumber(
hoursRange[rangeIndex + 1].from
)
) {
const error = new Error("The range of measured hours of your measurement file has to be in incremental order.");
error.code = "ERR_FILE_MEASUREMENT_HOURSRANGE_WRON_ORDER";
reject(error);
}
}
});
// Check if hoursRange has the same amount of time as "time" field.
const [fullMinutes, hrMinutes] = [
utils.timeToMinutesNumber(time),
utils.timeRangesToMinutesNumber(hoursRange)
];
if (fullMinutes !== hrMinutes) {
const error = new Error("The range of measured hours in your measurement file has to be equal the measured time.");
error.code = "ERR_FILE_MEASUREMENT_HOURSRANGE_WRONG_RANGES";
reject(error);
}
// Check if user tries to continue the same measurement next day.
if (
utils.timeToMinutesNumber(
utils.getCorrectTime()
) < utils.timeToMinutesNumber(
hoursRange[hoursRange.length - 1].to
)
&& this.filePath !== null
) {
const error = new Error("Your measured time can't excess 24 hours.");
error.code = "ERR_FILE_MEASUREMENT_TOO_LONG_MEASUREMENT";
reject(error);
}
// Check if measurement made any time ago has been saved.
if (this.filePath !== null) {
fs.stat(this.filePath, {bigint: false}, (statError, stats) => {
if (statError !== null && statError.code === "ENOENT") {
resolve(true);
} else if (statError === null) {
const today = new Date();
if (
stats.birthtime.getFullYear() < today.getFullYear()
|| stats.birthtime.getMonth() < today.getMonth()
|| stats.birthtime.getDate() < today.getDate()
|| (
stats.birthtime.getHours() === 0
&& stats.birthtime.getMinutes() === 0
)
) {
const error = new Error(`You haven't saved your previous measurement made on ${this.day} at ${this.hoursRange[this.hoursRange.length - 1].to}.`);
error.code = "ERR_FILE_MEASUREMENT_PREVIOUS_MEASUREMENT_NOT_SAVED";
reject(error);
} else {
resolve(true);
}
} else {
reject(statError);
}
});
} else {
resolve(true);
}
});
},
writable: false,
enumerable: false,
configurable: false,
});
// Modifies the measurement.
Object.defineProperty(TimeMeasurement.prototype, "update", {
value: function update({time, number, hoursRange, project}) {
return new Promise((resolve, reject) => {
const updatedProperties = {time, number, hoursRange, project};
Object.keys(updatedProperties).forEach(key => {
if (
updatedProperties[key] !== undefined
&& updatedProperties[key] !== null
&& this.fileContent[key] !== updatedProperties[key]
) {
this.fileContent[key] = updatedProperties[key];
}
});
this.createOrSave()
.then(createOrSaveSuccess => resolve(true))
.catch(updateVerificationOrCreationError => {
reject(updateVerificationOrCreationError)
});
});
},
writable: false,
enumerable: false,
configurable: false
});
// Removes the measurement file. Used in reset command.
Object.defineProperty(TimeMeasurement.prototype, "delete", {
value: function remove() {
return new Promise((resolve, reject) => {
fs.unlink(this.filePath, (deleteError) => {
if (deleteError !== null && deleteError.code === "ENOENT") {
const error = new Error(`You have no measurement file to delete at ${this.filePath}.`);
error.code = "ERR_FILE_MEASUREMENT_NOTHING_TO_DELETE";
reject(error);
} else if (deleteError === null) {
resolve(true);
} else {
reject(deleteError);
}
});
});
},
writable: false,
enumerable: false,
configurable: false
});
// Reads and instantiates an existing measurement file. Utilises base
// object's load method.
Object.defineProperty(TimeMeasurement, "load", {
value: function load(pathToJSONFile, validateContent = true) {
return new Promise((resolve, reject) => {
WorkWatchFile.load(pathToJSONFile, validateContent, TimeMeasurement)
.then(fileObject => {
if (fileObject instanceof TimeMeasurement) {
resolve(fileObject);
}
})
.catch(fileLoadError => {
if (fileLoadError.code === "ENOENT") {
const error = new Error(`There is no measurement file at ${pathToJSONFile}.`);
error.code = "ERR_FILE_NO_MEASUREMENT";
reject(error);
} else {
reject(fileLoadError);
}
});
});
},
writable: false,
enumerable: false,
configurable: false
});
module.exports = TimeMeasurement;