diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..476cbaf --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ + + +- Fixes # + +# Purpose + + + +# Description of Change + + + +# Todos + + diff --git a/package-lock.json b/package-lock.json index d1878f0..31ff50a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -174,6 +174,12 @@ "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=", "dev": true }, + "assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true + }, "async": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/async/-/async-2.6.0.tgz", @@ -430,6 +436,12 @@ "concat-map": "0.0.1" } }, + "browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true + }, "builtin-modules": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", @@ -535,6 +547,20 @@ "lazy-cache": "1.0.4" } }, + "chai": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.1.2.tgz", + "integrity": "sha1-D2RYS6ZC8PKs4oBiefTwbKI61zw=", + "dev": true, + "requires": { + "assertion-error": "1.1.0", + "check-error": "1.0.2", + "deep-eql": "3.0.1", + "get-func-name": "2.0.0", + "pathval": "1.1.0", + "type-detect": "4.0.8" + } + }, "chalk": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.3.1.tgz", @@ -550,6 +576,12 @@ "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.4.2.tgz", "integrity": "sha1-tUc7M9yXxCTl2Y3IfVXU2KKci/I=" }, + "check-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", + "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", + "dev": true + }, "ci-info": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-1.1.2.tgz", @@ -1093,6 +1125,15 @@ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" }, + "deep-eql": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", + "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, "deep-extend": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.4.2.tgz", @@ -1294,6 +1335,12 @@ "integrity": "sha1-UYZnt2kUYKXn4KNBvnbrfOgJAYQ=", "dev": true }, + "diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "dev": true + }, "doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -1667,6 +1714,12 @@ "integrity": "sha1-9wLmMSfn4jHBYKgMFVSstw1QR+U=", "dev": true }, + "get-func-name": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", + "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", + "dev": true + }, "get-pkg-repo": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/get-pkg-repo/-/get-pkg-repo-1.4.0.tgz", @@ -1863,6 +1916,12 @@ "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=", "dev": true }, + "growl": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.3.tgz", + "integrity": "sha512-hKlsbA5Vu3xsh1Cg3J7jSmX/WaW6A5oBeqzM88oNbCRQFz+zUaXm6yxS4RVytp1scBoJzSYl4YAEOQIt6O8V1Q==", + "dev": true + }, "handlebars": { "version": "4.0.11", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.0.11.tgz", @@ -1911,6 +1970,12 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" }, + "he": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", + "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=", + "dev": true + }, "highlight-es": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/highlight-es/-/highlight-es-1.0.1.tgz", @@ -2641,6 +2706,47 @@ } } }, + "mocha": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-5.0.5.tgz", + "integrity": "sha512-3MM3UjZ5p8EJrYpG7s+29HAI9G7sTzKEe4+w37Dg0QP7qL4XGsV+Q2xet2cE37AqdgN1OtYQB6Vl98YiPV3PgA==", + "dev": true, + "requires": { + "browser-stdout": "1.3.1", + "commander": "2.11.0", + "debug": "3.1.0", + "diff": "3.5.0", + "escape-string-regexp": "1.0.5", + "glob": "7.1.2", + "growl": "1.10.3", + "he": "1.1.1", + "mkdirp": "0.5.1", + "supports-color": "4.4.0" + }, + "dependencies": { + "commander": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.11.0.tgz", + "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==", + "dev": true + }, + "has-flag": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-2.0.0.tgz", + "integrity": "sha1-6CB68cx7MNRGzHC3NLXovhj4jVE=", + "dev": true + }, + "supports-color": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-4.4.0.tgz", + "integrity": "sha512-rKC3+DyXWgK0ZLKwmRsrkyHVZAjNkfzeehuFWdGGcqGDTZFH73+RH6S/RDAAxl9GusSjZSUWYLmT9N5pzXFOXQ==", + "dev": true, + "requires": { + "has-flag": "2.0.0" + } + } + } + }, "modify-values": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/modify-values/-/modify-values-1.0.0.tgz", @@ -3153,6 +3259,12 @@ "pinkie-promise": "2.0.1" } }, + "pathval": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.0.tgz", + "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=", + "dev": true + }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -4042,6 +4154,12 @@ "prelude-ls": "1.1.2" } }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + }, "typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", diff --git a/package.json b/package.json index 21e9bb4..c19690b 100644 --- a/package.json +++ b/package.json @@ -43,17 +43,18 @@ }, "main": "source/mite.js", "scripts": { - "changelog": "conventional-changelog -s -i CHANGELOG.md -p angular -r 0", "changelog:preview": "conventional-changelog --output-unreleased -p angular", + "changelog": "conventional-changelog -s -i CHANGELOG.md -p angular -r 0", "commitmsg": "conventional-changelog-lint -e", "lint": "eslint source/**/*.js", "postversion": "git push && git push --tags", "preversion": "npm test", "start": "node $npm_package_main", - "test": "npm run lint", + "tdd": "npm run test -- --watch", + "test": "mocha --check-leaks --throw-deprecation --use_strict source/**/*test.js --exit", "update": "npm-check --update", - "version": "npm run changelog && git add CHANGELOG.md", - "version:recommend": "conventional-recommended-bump --preset angular" + "version:recommend": "conventional-recommended-bump --preset angular", + "version": "npm run changelog && git add CHANGELOG.md" }, "dependencies": { "async": "^2.5.0", @@ -67,11 +68,13 @@ "table": "^4.0.2" }, "devDependencies": { + "chai": "^4.1.2", "conventional-changelog-cli": "^1.3.13", "conventional-changelog-lint": "^2.1.1", "conventional-recommended-bump": "^2.0.4", "eslint": "^4.17.0", "husky": "^0.14.3", + "mocha": "^5.0.5", "npm-check": "^5.5.2" } } diff --git a/source/lib/formater.js b/source/lib/formater.js new file mode 100644 index 0000000..afff37f --- /dev/null +++ b/source/lib/formater.js @@ -0,0 +1,66 @@ +'use strict'; + +const BUDGET_TYPE = { + MINUTES_PER_MONTH: 'minutes_per_month', + MINUTES: 'minutes', + CENTS: 'cents', + CENTS_PER_MONTH: 'cents_per_month', +}; + +const DEFAULT_CURRENCY = '€'; + +module.exports = { + BUDGET_TYPE: BUDGET_TYPE, + + minutesToWorkDays(minutes) { + return this.number(minutes / 8 / 60, 2); + }, + + minutesToDuration(minutes) { + if (typeof minutes !== 'number') { + throw new TypeError('Expected minutes to be a of type Number'); + } else if (isNaN(minutes)) { + throw new Error('Expected minutes to be a valid Number not NaN'); + } else if (!isFinite(minutes)) { + throw new Error('Expecte minutes not to be a finite value.'); + } + let hours = Math.floor(minutes / 60) + let remainingMinutes = Math.round(minutes - hours * 60); + if (String(remainingMinutes).length === 1) { + return hours + ':0' + remainingMinutes + } + return hours + ':' + remainingMinutes; + }, + + number(value, precision) { + if (typeof value !== 'number') { + throw new TypeError('Expected value to be a of type Number'); + } else if (isNaN(value)) { + throw new Error('Expected value to be a valid Number not NaN'); + } else if (!isFinite(value)) { + throw new Error('Expecte value not to be a finite value.'); + } + precision = precision || 2; + return String(value.toFixed(precision)); + }, + + price(value, precision) { + precision = precision || 2; + return String(value.toFixed(precision)); + }, + + budget(type, value) { + switch(type) { + case BUDGET_TYPE.MINUTES_PER_MONTH: + return this.minutesToDuration(value) + ' h/m'; + case BUDGET_TYPE.MINUTES: + return this.minutesToDuration(value) + ' h'; + case BUDGET_TYPE.CENTS: + return this.price(value / 100) + ' ' + DEFAULT_CURRENCY; + case BUDGET_TYPE.CENTS_PER_MONTH: + return this.price(value / 100) + ' ' + DEFAULT_CURRENCY + '/m'; + default: + throw new Error('Unknown budget format type: "' + type + '"'); + } + } +} diff --git a/source/lib/formater.test.js b/source/lib/formater.test.js new file mode 100644 index 0000000..4f72b07 --- /dev/null +++ b/source/lib/formater.test.js @@ -0,0 +1,104 @@ +'use strict'; + +const expect = require('chai').expect; + +const formater = require('./formater'); +const BUDGET_TYPE = require('./formater').BUDGET_TYPE; + +describe('formater', () => { + + describe('number', () => { + it('returns the number with default precision of 2', () => { + expect(formater.number(2.128391)).to.equal('2.13'); + }); + it('can have a differenct precision', () => { + expect(formater.number(2.128391, 4)).to.equal('2.1284'); + }); + it('throws an exception when the first argument is not a number', () => { + expect(() => { formater.number(null) }).to.throw(TypeError); + expect(() => { formater.number(8/0) }).to.throw(Error); + expect(() => { formater.number(parseInt('a')) }).to.throw(Error); + expect(() => { formater.number(Infinity) }).to.throw(Error); + }); + }) // number + + describe('minutesToWorkDays', () => { + [ + [0, '0.00'], + [1, '0.00'], + [5, '0.01'], + [8 * 60, '1.00'], + [8 * 60 + 1, '1.00'], + [8 * 60 + 8, '1.02'], + [365 * 8 * 60 - 123, '364.74'], + [false, '0.00'] + ].forEach((row) => { + it(`formats ${row[0]} to ${row[1]}`, () => { + expect(formater.minutesToWorkDays(row[0])).to.equal(row[1]); + }); + }); + }); // minutesToWorkDays + + describe('minutesToDuration', () => { + [ + [0, '0:00'], + [0.09, '0:00'], + [0.015, '0:00'], + [0.1, '0:00'], + [0.9, '0:01'], + [1, '0:01'], + [1.05, '0:01'], + [9.99, '0:10'], + [20, '0:20'], + [60, '1:00'], + [60.5, '1:01'], + [61, '1:01'], + [120, '2:00'], + [2300, '38:20'], + ].forEach((row) => { + it(`formats ${row[0]} to ${row[1]}`, () => { + expect(formater.minutesToDuration(row[0])).to.equal(row[1]); + }); + }); + }); // minutesToDuration + + describe('budget', () => { + describe('invalid types', () => { + it('throws an Error', () => { + expect(() => { + formater.format('something', 1238); + }).to.throw(Error); + }) + }); + describe('minutes & minutes_per_month', () => { + it('returns durations', () => { + expect(formater.budget(BUDGET_TYPE.MINUTES, 60)).to.equal('1:00 h'); + }) + }); + describe('cents per month', () => { + it('adds €/m', () => { + const result = formater.budget(BUDGET_TYPE.CENTS_PER_MONTH, 100); + expect(result).to.equal('1.00 €/m'); + }); + }); + describe('cents', () => { + it('rounds correctly', () => { + const result = formater.budget(BUDGET_TYPE.CENTS, 9.985); + expect(result).to.equal('0.10 €'); + }); + it('can format big values', () => { + const result = formater.budget(BUDGET_TYPE.CENTS, 2912.21121); + expect(result).to.equal('29.12 €'); + }); + it('can format very big values', () => { + const result = formater.budget(BUDGET_TYPE.CENTS, 98726134.91928); + expect(result).to.equal('987261.35 €'); + }); + it('formats the value to 2 number diget', () => { + const result = formater.budget(BUDGET_TYPE.CENTS, 0.01); + expect(result).to.equal('0.00 €'); + }); + }); + }); // budget + +}); // test suite diff --git a/source/mite-budgets.js b/source/mite-budgets.js index 8668185..e456572 100644 --- a/source/mite-budgets.js +++ b/source/mite-budgets.js @@ -9,21 +9,14 @@ const table = tableLib.table; const pkg = require('./../package.json') const config = require('./config.js') +const formater = require('./lib/formater'); +const BUDGET_TYPE = formater.BUDGET_TYPE; // set a default period argument if not set if (!process.argv[2] || process.argv[2].substr(0, 1) === '-' && process.argv[2] !== '--help') { process.argv.splice(2, 0, 'this_month') } -function minutesToHoursDuration(minutes) { - let hours = Math.floor(minutes / 60) - let remainingMinutes = minutes - hours * 60; - if (String(remainingMinutes).length === 1) { - return hours + ':0' + remainingMinutes - } - return hours + ':' + remainingMinutes -} - program .version(pkg.version) .arguments('') @@ -56,12 +49,16 @@ program const tableData = results .map((entry) => entry.time_entry_group) .map((entry) => { + let revenue = formater.budget(BUDGET_TYPE.CENTS, entry.revenue || 0); + if (entry.revenue === null) { + revenue = '-' + } return [ entry.project_id, entry.project_name, - minutesToHoursDuration(entry.minutes), - (entry.minutes / 8 / 60).toFixed(2), - (entry.revenue / 100).toFixed(2) + formater.minutesToDuration(entry.minutes || 0), + formater.minutesToWorkDays(entry.minutes || 0), + revenue, ] }) @@ -84,9 +81,9 @@ program tableData.push([ '', '', - minutesToHoursDuration(minutesTotal), - (minutesTotal / 8 / 60).toFixed(2), - (revenueTotal / 100).toFixed(2) + formater.minutesToDuration(minutesTotal), + formater.minutesToWorkDays(minutesTotal), + formater.budget(BUDGET_TYPE.CENTS, revenueTotal) ].map(s => chalk.bold(s))); const tableConfig = { diff --git a/source/mite-list.js b/source/mite-list.js index 8941e98..95cc98c 100755 --- a/source/mite-list.js +++ b/source/mite-list.js @@ -9,10 +9,8 @@ const miteApi = require('mite-api') const pkg = require('./../package.json') const config = require('./config.js') - -function durationFromMinutes(minutes) { - return (new Date(minutes * 60 * 1000)).toISOString().substr(11, 5) -} +const formater = require('./lib/formater'); +const BUDGET_TYPE = formater.BUDGET_TYPE; // set a default period argument if not set if (!process.argv[2] || process.argv[2].substr(0, 1) === '-' && process.argv[2] !== '--help') { @@ -138,7 +136,7 @@ program if (timeEntry.tracking) { minutes = timeEntry.tracking.minutes; } - let duration = durationFromMinutes(minutes) + let duration = formater.minutesToDuration(minutes) // add a lock symbol to the duration when the time entry cannot be edited if (timeEntry.locked) { duration = chalk.green('✔') + ' ' + duration; @@ -152,7 +150,7 @@ program timeEntry.date_at, timeEntry.project_name, duration, - (timeEntry.revenue / 100).toFixed(2), + formater.budget(BUDGET_TYPE.CENTS, timeEntry.revenue || 0), timeEntry.service_name, timeEntry.note ] @@ -181,8 +179,8 @@ program null, null, null, - chalk.bold(durationFromMinutes(minutesTotal)), - chalk.bold((revenueTotal / 100).toFixed(2)), + chalk.bold(formater.minutesToDuration(minutesTotal)), + chalk.bold(formater.budget(BUDGET_TYPE.CENTS, revenueTotal)), null, null, ]) diff --git a/source/mite-projects.js b/source/mite-projects.js new file mode 100755 index 0000000..42fe352 --- /dev/null +++ b/source/mite-projects.js @@ -0,0 +1,141 @@ +#!/usr/bin/env node +'use strict' + +const program = require('commander'); +const miteApi = require('mite-api'); +const chalk = require('chalk'); +const tableLib = require('table') +const table = tableLib.table; + +const pkg = require('./../package.json'); +const config = require('./config.js'); +const formater = require('./lib/formater'); +const BUDGET_TYPE = formater.BUDGET_TYPE; +const SORT_OPTIONS = [ + 'id', + 'name', + 'customer_name', + 'updated_at', + 'created_at', +]; +const SORT_OPTIONS_DEFAULT = 'name'; + +program + .version(pkg.version) + .description('list, filter & search for projects') + .option( + '--sort ', + `optional column the results should be case-insensitive ordered by `+ + `(default: "${SORT_OPTIONS_DEFAULT}"), ` + + `valid values: ${SORT_OPTIONS.join(', ')}`, + (value) => { + if (SORT_OPTIONS.indexOf(value) === -1) { + console.error( + 'Invalid value for sort option: "%s", valid values are: ', + value, + SORT_OPTIONS.join(', ') + ); + process.exit(2); + } + return value; + }, + 'name' // default sor + ) + .option( + '--search ', + 'optional search string which must be somewhere in the project’s name ' + + '(case insensitive)' + ) + .option( + '--customer_id ', + 'optional id of a customer (use mite customer) to filter the projects by' + ) + .option( + '-a, --archived', + 'When used will only return archived projects which are not returned when ' + + 'not used.', + false + ) + .parse(process.argv); + +const opts = { + 'limit': 10000 +}; +if (program.customer_id) { + opts.customer_id = program.customer_id; +} +if (program.search) { + opts.name = program.search; +} +let method = 'getProjects'; +if (program.archived) { + method = 'getArchivedProjects'; +} + +const mite = miteApi(config.get()); +mite[method](opts, (err, responseData) => { + if (err) { + throw err; + } + + const tableData = responseData + .map((e) => e.project) + .sort((a, b) => { + if (!program.sort) return 0; + const sortByAttributeName = program.sort; + var val1 = String(a[sortByAttributeName]).toLowerCase(); + var val2 = String(b[sortByAttributeName]).toLowerCase(); + if (val1 > val2) { + return 1; + } else if (val1 < val2) { + return -1; + } else { + return 0; + } + }) + .map((data) => { + let budget = formater.budget(data.budget_type, data.budget); + if (!data.budget) { + budget = '-' + } + let rate = formater.budget(BUDGET_TYPE.CENTS, data.hourly_rate); + if (!data.hourly_rate) { + rate = '-'; + } + return [ + data.id, + data.name, + data.customer_name + ' (' + data.customer_id + ')', + budget, + rate, + data.note.replace(/[\r\n]+/, ' '), + ]; + }); + tableData.unshift([ + 'id', + 'name', + 'customer', + 'budget', + 'rate', + 'note', + ].map((v) => chalk.bold(v))); + const tableConfig = { + border: tableLib.getBorderCharacters('norc'), + columns: { + 0: {}, + 1: {}, + 2: {}, + 3: { + alignment: 'right', + }, + 4: { + alignment: 'right', + }, + 5: { + width: 80, + wrapWord: true, + } + } + }; + console.log(table(tableData, tableConfig)); +}); diff --git a/source/mite.js b/source/mite.js index 1a44835..43462ab 100755 --- a/source/mite.js +++ b/source/mite.js @@ -15,11 +15,14 @@ program isDefault: true }) .alias('ls') + .alias('status') + .alias('st') .command('new', 'create a new time entry') .alias('create') .command('open', 'open the given time entry in browser') .command('stop', 'stop any running counter') .command('start', 'start the tracker for the given id, will also stop allready running entry') .command('users', 'list, filter & search for users') + .command('projects', 'list, filter & search projects') .description(pkg.description) .parse(process.argv)