diff --git a/documentation.md b/documentation.md index 3212ed1..0691e0f 100644 --- a/documentation.md +++ b/documentation.md @@ -282,7 +282,7 @@ Defaults to `table`. `csv` and `markdown` can be printed to stdout, `pdf` and `x There are additional options for the invoice output as given in the following example: ```shell -gtt report --output=invoice --file=invoice.md --from 2021-02-01 --to 2021-02-28 --closed --invoiceCurrencyMaxUnit 1 --invoiceTitle "Rechnung" --invoiceAddress "Firma" "Mr. X" "Strasse" "10000 Ort" "Land" --invoiceCurrency "EUR" --invoiceCurrencyPerHour "50" --invoiceVAT "0.15" --invoiceDate "1.03.2021" +gtt report --output=invoice --file=invoice.md --from 2021-02-01 --to 2021-02-28 --closed --invoiceCurrencyMaxUnit 1 --invoiceTitle "Rechnung" --invoiceAddress "Firma" "Mr. X" "Strasse" "10000 Ort" "Land" --invoiceCurrency "EUR" --invoiceCurrencyPerHour "50" --invoiceVAT "0.15" --invoiceDate "1.03.2021" --invoicePositionText "Position Text" ``` For paper invoice, further process the output with a css, see the folder preview (styles.css, invoice.pdf) @@ -658,7 +658,6 @@ invoiceSettings: opening: - Satz 1. - Satz 2. - positionText: Positionstext closing: - Grussformel - diff --git a/src/gtt-config.js b/src/gtt-config.js index 3c08a98..0bef1b1 100755 --- a/src/gtt-config.js +++ b/src/gtt-config.js @@ -13,4 +13,4 @@ if (program.opts().local) { config.assertLocalConfig(); } -Fs.open(program.opts().local ? config.local : config.global); \ No newline at end of file +Fs.open(program.opts().local ? config.local : config.global); diff --git a/src/gtt-list.js b/src/gtt-list.js new file mode 100755 index 0000000..085cc2f --- /dev/null +++ b/src/gtt-list.js @@ -0,0 +1,39 @@ +const program = require('commander'); +const colors = require('colors'); +const moment = require('moment'); +const Table = require('cli-table'); + + +const Config = require('./include/file-config'); +const Cli = require('./include/cli'); +const Tasks = require('./include/tasks'); + +program + .arguments('[project]') + .option('--verbose', 'show verbose output') + .option('-c, --closed', 'show closed issues (instead of opened only)') + .option('--my', 'show only issues assigned to me') + .parse(process.argv); + +Cli.verbose = program.verbose; + +let config = new Config(process.cwd()), + tasks = new Tasks(config), + type = program.type ? program.type : 'issue', + project = program.args[0]; + +tasks.list(project, program.closed ? 'closed' : 'opened', program.my) + .then(issues => { + let table = new Table({ + style : {compact : true, 'padding-left' : 1} + }); + if (issues.length == 0) { + console.log("No issues found."); + } + issues.forEach(issue => { + table.push([issue.iid.toString().magenta, issue.title.green + "\n" + issue.data.web_url.gray, issue.state]) + }) + console.log(table.toString()); + }) + .catch(error => Cli.error(error)); + diff --git a/src/gtt-log.js b/src/gtt-log.js index 3a5dad6..5d87ec5 100755 --- a/src/gtt-log.js +++ b/src/gtt-log.js @@ -39,4 +39,4 @@ tasks.log() }); } ) - .catch(error => Cli.error(error)); \ No newline at end of file + .catch(error => Cli.error(error)); diff --git a/src/gtt-report.js b/src/gtt-report.js index 64a20e0..13ea2c5 100755 --- a/src/gtt-report.js +++ b/src/gtt-report.js @@ -72,6 +72,7 @@ program .option('--invoiceVAT ', 'vat decimal (20% = 0.2)') .option('--invoiceDate ', 'date string') .option('--invoiceCurrencyMaxUnit ', 'rouning invoice total, e.g. 0.01, 0.05 or 1') + .option('--invoicePositionText ', 'invoice position text') .parse(process.argv); // init helpers @@ -138,6 +139,7 @@ config .set('invoiceVAT', program.opts().invoiceVAT) .set('invoiceDate', program.opts().invoiceDate) .set('invoiceCurrencyMaxUnit', program.opts().invoiceCurrencyMaxUnit) + .set('invoicePositionText', program.opts().invoicePositionText) .set('_createDump', program.opts().output === 'dump'); // date shortcuts diff --git a/src/gtt.js b/src/gtt.js index 387c105..b3cea48 100755 --- a/src/gtt.js +++ b/src/gtt.js @@ -12,6 +12,7 @@ program .command('stop', 'stop monitoring time') .command('resume [project]', 'resume monitoring time for last stopped record') .command('cancel', 'cancel and discard active monitoring time') + .command('list [project]', 'list all open issues') .command('log', 'log recorded time records') .command('sync', 'sync local time records to GitLab') .command('edit [id]', 'edit time record by the given id') diff --git a/src/models/hasTimes.js b/src/models/hasTimes.js index 095dd30..8d0b300 100755 --- a/src/models/hasTimes.js +++ b/src/models/hasTimes.js @@ -15,6 +15,7 @@ class hasTimes extends Base { constructor(config) { super(config); this.times = []; + this.timesWarnings = []; } /** @@ -58,6 +59,7 @@ class hasTimes extends Base { */ getTimes() { let times = [], + timesWarnings = [], timeSpent = 0, totalTimeSpent = 0, timeUsers = {}, @@ -127,18 +129,22 @@ class hasTimes extends Base { !(created.isSameOrAfter(moment(this.config.get('from'))) && created.isSameOrBefore(moment(this.config.get('to')))) ) return resolve(); + // warn about difference, but do not correct as gitlab API + // stats forget the times after an issue is moved to another project. let difference = this.data.time_stats.total_time_spent - totalTimeSpent, note = Object.assign({noteable_type: this._typeSingular}, this.data); - - times.unshift(new Time(Time.toHumanReadable(difference, this.config.get('hoursPerDay')), null, note, this, this.config)); - + note.timeWarning = {}; + note.timeWarning['stats'] = this.data.time_stats.total_time_spent; + note.timeWarning['notes'] = totalTimeSpent; + timesWarnings.push(new Time(Time.toHumanReadable(difference, this.config.get('hoursPerDay')), null, note, this, this.config)); resolve(); })); promise.then(() => { _.each(timeUsers, (time, name) => this[`time_${name}`] = Time.toHumanReadable(time, this.config.get('hoursPerDay'), timeFormat)); this.timeSpent = timeSpent; - this.times = times + this.times = times; + this.timesWarnings = timesWarnings; }); return promise; diff --git a/src/models/issue.js b/src/models/issue.js index adeed56..8f669e1 100755 --- a/src/models/issue.js +++ b/src/models/issue.js @@ -29,6 +29,23 @@ class issue extends hasTimes { return promise; } + list(project, state, my) { + return new Promise((resolve, reject) => { + let promise; + const query = `scope=${my ? "assigned-to-me" : "all"}&state=${state}`; + if (project) { + promise = this.get(`projects/${encodeURIComponent(project)}/issues?${query}`); + } else { + promise = this.get(`issues/?${query}`); + } + promise.then(response => { + const issues = response.body.map(issue => new this.constructor(this.config, issue)) + resolve(issues) + }); + promise.catch(error => reject(error)) + }) + } + /* * properties */ diff --git a/src/models/report.js b/src/models/report.js index c5fad8f..2ea3f79 100755 --- a/src/models/report.js +++ b/src/models/report.js @@ -79,7 +79,10 @@ class report extends Base { */ getMergeRequests() { let promise = this.all(`projects/${this.project.id}/merge_requests${this.params()}`); - promise.then(mergeRequests => this.mergeRequests = mergeRequests); + let excludes = this.config.get('excludeByLabels'); + promise.then(mergeRequests => this.mergeRequests = mergeRequests.filter(mr => ( + excludes.filter(l=>mr.labels.includes(l)).length==0 // keep all merge requests not including a exclude label + ))); return promise; } @@ -90,8 +93,11 @@ class report extends Base { */ getIssues() { let promise = this.all(`projects/${this.project.id}/issues${this.params()}`); - promise.then(issues => this.issues = issues); - + let excludes = this.config.get('excludeByLabels'); + promise.then(issues => this.issues = issues.filter(issue => ( + issue.moved_to_id == null && // filter moved issues in any case + excludes.filter(l=>issue.labels.includes(l)).length==0 // keep all issues not including a exclude label + ))); return promise; } diff --git a/src/output/base.js b/src/output/base.js index b1bdd2f..b815d67 100755 --- a/src/output/base.js +++ b/src/output/base.js @@ -38,6 +38,14 @@ class base { this.write(this.formats.headline(string)); } + /** + * print a headline for warnings + * @param string + */ + warningHeadline(string) { + if (this.config.get('noWarnings')) return; + this.headline(string); + } /** * print a warning * @param string @@ -104,6 +112,9 @@ class base { let users = {}; let projects = {}; let times = []; + let timesWarnings = []; + let days = {}; + let daysMoment = {}; let spentFreeLabels = this.config.get('freeLabels'); if(undefined === spentFreeLabels) { @@ -113,11 +124,24 @@ class base { ['issues', 'mergeRequests'].forEach(type => { this.report[type].forEach(issue => { issue.times.forEach(time => { + let dateGrp = time.date.format(this.config.get('dateFormatGroupReport')); if (!users[time.user]) users[time.user] = 0; if (!projects[time.project_namespace]) projects[time.project_namespace] = 0; + if (!days[dateGrp]) { + days[dateGrp] = {} + daysMoment[dateGrp] = time.date; + }; + if(!days[dateGrp][time.project_namespace]) { + days[dateGrp][time.project_namespace] = {}; + } + if(!days[dateGrp][time.project_namespace][time.iid]) { + days[dateGrp][time.project_namespace][time.iid] = 0; + } + users[time.user] += time.seconds; projects[time.project_namespace] += time.seconds; + days[dateGrp][time.project_namespace][time.iid] += time.seconds; spent += time.seconds; //if(time.parent.labels) @@ -132,6 +156,7 @@ class base { } times.push(time); }); + issue.timesWarnings.forEach(warning => timesWarnings.push(warning)); totalEstimate += parseInt(issue.stats.time_estimate); totalSpent += parseInt(issue.stats.total_time_spent); @@ -152,6 +177,8 @@ class base { return a.date.isBefore(b.date) ? 1 : -1; }); + this.days = days; + this.daysMoment = daysMoment; this.users = _.mapObject(users, user => this.config.toHumanReadable(user, 'stats')); this.projects = _.mapObject(projects, project => this.config.toHumanReadable(project, 'stats')); this.stats = { @@ -164,6 +191,7 @@ class base { this.spent = spent; this.spentFree = spentFree; this.totalSpent = totalSpent; + this.timesWarnings = timesWarnings; } /** diff --git a/src/output/invoice.js b/src/output/invoice.js index dec18bf..6abdda6 100644 --- a/src/output/invoice.js +++ b/src/output/invoice.js @@ -18,13 +18,13 @@ class invoice extends Base { this.invoiceCurrency = this.config.get('invoiceCurrency'); this.invoiceCurrencyPerHour = this.config.get('invoiceCurrencyPerHour'); this.invoiceVAT = this.config.get('invoiceVAT'); + this.invoicePositionText = this.config.get('invoicePositionText'); this.invoiceCurrencyMaxUnit = this.config.get('invoiceCurrencyMaxUnit'); this.totalhForInvoice = (this.spent-this.spentFree) / 3600.0; - this.totalForInvoiceExkl = this.totalhForInvoice * this.invoiceCurrencyPerHour; - this.totalForInvoiceMwst = this.totalForInvoiceExkl * this.invoiceVAT; - this.totalForInvoice = this.totalForInvoiceExkl + this.totalForInvoiceMwst; - // round - this.totalForInvoice = Math.round(this.totalForInvoice/this.invoiceCurrencyMaxUnit)*this.invoiceCurrencyMaxUnit; + // round subtotals to 0.01 and total to invoiceCurrencyMaxUnit. + this.totalForInvoiceExkl = Math.round(this.totalhForInvoice * this.invoiceCurrencyPerHour * 100) * 0.01; + this.totalForInvoiceMwst = Math.round(this.totalhForInvoice * this.invoiceCurrencyPerHour * this.invoiceVAT * 100) * 0.01; + this.totalForInvoice = Math.round((this.totalForInvoiceExkl + this.totalForInvoiceMwst)/this.invoiceCurrencyMaxUnit)*this.invoiceCurrencyMaxUnit; } @@ -57,7 +57,7 @@ class invoice extends Base { let from = this.concat(this.config.get('invoiceSettings').from, '
'); let opening = this.concat(this.config.get('invoiceSettings').opening, '
'); let closing = this.concat(this.config.get('invoiceSettings').closing, '
'); - let positionText = this.concat(this.config.get('invoiceSettings').positionText, '
'); + this.out += `
${from}
@@ -73,7 +73,7 @@ class invoice extends Base { ${opening}
-
${positionText} (${this.totalhForInvoice.toFixed(2)} Stunden zu ${this.invoiceCurrencyPerHour} ${this.invoiceCurrency})
+
${this.invoicePositionText} (${this.totalhForInvoice.toFixed(2)} Stunden zu ${this.invoiceCurrencyPerHour} ${this.invoiceCurrency})
${this.invoiceCurrency} ${this.totalForInvoiceExkl.toFixed(2)}
MWST (${this.invoiceVAT*100}%)
${this.invoiceCurrency} ${this.totalForInvoiceMwst.toFixed(2)}
@@ -89,7 +89,23 @@ ${closing}



Stundenrapport

`; this.headline('Total'); - this.write(stats.substr(1)); + //this.write(stats.substr(1)); + this.write(this.config.toHumanReadable(this.spent, 'stats')); + this.write(this.config.toHumanReadable(this.spentFree, 'statsFree')); + + // warnings + let warnings = ''; + + this.timesWarnings.forEach( warning => { + let stats = this.config.toHumanReadable(warning.data.timeWarning.stats, 'stats'); + let notes = this.config.toHumanReadable(warning.data.timeWarning.notes, 'stats'); + warnings += `\n* ${warning.data.iid} ${warning.data.title}: Difference between stats and notes of ${warning.time}.`; + warnings += `
Stats: ${stats}, Notes: ${notes}` + }); + if(warnings != '') { + this.warningHeadline('Warnings'); + this.warning(warnings+'\n'); + } } makeIssues() { @@ -117,11 +133,31 @@ ${closing} } makeRecords() { - this.headline('Details'); + this.out += ` - let times = [this.config.get('recordColumns').map(c => c.replace('_', ' '))]; - this.times.forEach(time => times.push(this.prepare(time, this.config.get('recordColumns')))); +



Stundenrapport detailliert

`; + this.headline('Details'); + let times = [['date', 'project', 'iid', 'time']]; + let days = Object.keys(this.days); + days.sort(); + days.forEach( + k => { + let day = this.days[k]; + let refD = this.daysMoment[k].format(this.config.get('dateFormat')); + let projects = Object.keys(day); + projects.forEach( + p => { + let iids = Object.keys(day[p]); + iids.sort(); + iids.forEach( + iid => { + times.push([refD, p, iid, this.config.toHumanReadable(day[p][iid], 'records')]); + }); + }); + }); + //let times = [this.config.get('recordColumns').map(c => c.replace('_', ' '))]; + //this.times.forEach(time => times.push(this.prepare(time, this.config.get('recordColumns')))); this.write(Table(times)); } } diff --git a/src/output/table.js b/src/output/table.js index 157e5dd..973b9c3 100755 --- a/src/output/table.js +++ b/src/output/table.js @@ -57,7 +57,37 @@ class table extends Base { this.write(mergeRequests.toString()); } + makeDailyStats() { + this.headline('DAILY RECORDS'); + + var tabledt = new Table({head: ['date', 'time']}); + var tabledit = new Table({head: ['date', 'project', 'iid', 'time']}); + let days = Object.keys(this.days); + days.sort(); + days.forEach( + k => { + let day = this.days[k]; + let refD = this.daysMoment[k].format(this.config.get('dateFormat')); + let projects = Object.keys(day); + let time = 0; + projects.forEach( + p => { + let iids = Object.keys(day[p]); + iids.sort(); + iids.forEach( + iid => { + tabledit.push([refD, p, iid, this.config.toHumanReadable(day[p][iid], 'records')]); + time += day[p][iid]; + }); + }); + tabledt.push([refD, this.config.toHumanReadable(time)]); + }); + this.write(tabledt.toString()); + this.write(tabledit.toString()); + } + makeRecords() { + this.makeDailyStats(); this.headline('TIME RECORDS'); let times = new Table({head: this.config.get('recordColumns').map(c => c.replace('_', ' '))}); this.times.forEach(time => times.push(this.prepare(time, this.config.get('recordColumns'))));